diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b249e77e8d2..d3944b74e4b 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -57,7 +57,10 @@ has matching runner capacity, secrets, and branch-protection expectations. - `codex-lab-app` The runner must have Rust, Python 3, Xcode command line tools, and macOS -`ditto` available. The generated Codex Lab app artifact is currently unsigned. +`ditto` available. Release runs also require the keychain identity +`Developer ID Application: Shiny Computers Leasing LLC (MM5YXC7T6E)`; the +workflow checks for that exact identity before signing. The generated Codex Lab +app artifact is currently unsigned. `exec-harness.yml` expects a self-hosted Linux x64 runner with these labels: @@ -93,35 +96,47 @@ default target directory when no artifact root is configured or available. - `codex-lab-app-aarch64-apple-darwin.zip` - `codex-lab-shim-aarch64-apple-darwin.zip` +- `codex-lab-engine-aarch64-apple-darwin.zip` - `SHA256SUMS` - `codex-lab-distribution.json` The distribution manifest is the contract for future installers and updaters. It marks the app zip as the canonical app update unit, the shim zip as a -companion wrapper, and records supported layouts for extracted sibling installs, -`CODEX_LAB_APP_PATH` overrides, and `/Applications` installs. Artifacts remain -`signed: false` and `notarized: false` until the signing pipeline exists. +companion wrapper, and the engine zip as the managed supervisor execution unit. +It also records supported layouts for extracted sibling installs, +`CODEX_LAB_APP_PATH` overrides, and `/Applications` installs. Pull-request app +artifacts keep all three payloads unsigned so untrusted changes never receive +signing credentials; their manifest is packaging-validation metadata, not a +publishable installer manifest. ## Codex Lab Release Publication -`codex-lab-release.yml` builds the same macOS ARM64 distribution files and -stages them for GitHub Releases. It separates trust boundaries deliberately: - -- the self-hosted macOS runner builds and uploads a workflow artifact with - `contents: read` permissions; +`codex-lab-release.yml` builds the macOS ARM64 app, shim, and engine, then signs +and verifies the engine before staging the final distribution for GitHub +Releases. It separates trust boundaries deliberately: + +- the self-hosted macOS runner builds the app and shim, copies the release + engine, and signs it with the runner's Shiny Developer ID identity while the + job retains only `contents: read` permissions; +- that same job applies hardened runtime plus + `com.apple.security.cs.allow-jit`, then validates the signature, + TeamIdentifier, entitlement, executable digest, source commit, and version + before archiving the engine; - an `ubuntu-latest` validation job downloads the staged artifact, verifies checksums, and checks that the manifest has release metadata and download URLs. This validates internal consistency, not artifact provenance; - a separate `ubuntu-latest` publish job has `contents: write` and creates a public prerelease only for explicit manual dispatches with `publish: true`. -Manual dispatch with `publish: false` is the dry-run path: it builds and -validates the release artifact set, including checking that the release tag is -available, without creating a GitHub Release. Publishing is restricted to manual -dispatches from the repository default branch. Published Codex Lab releases are -public prereleases and are not marked as latest while the artifacts remain -unsigned and unnotarized. Public prereleases are used so manifest `downloadUrl` -entries are immediately usable by installers and updaters. +Manual dispatch with `publish: false` is the dry-run path: it builds, signs, and +validates the release artifact set, including checking that the +release tag is available, without creating a GitHub Release. Publishing is +restricted to manual dispatches from the repository default branch. Published +Codex Lab releases remain public prereleases and are not marked as latest. The +app and shim are unsigned Lab launch surfaces; the managed engine is the signed +execution boundary whose digest, source commit, version, stable identifier, +TeamIdentifier, and JIT entitlement are pinned by the installer and LaunchAgent +supervisor. Release IDs use this namespace: diff --git a/.github/workflows/codex-lab-app.yml b/.github/workflows/codex-lab-app.yml index adf59643dd5..af33d618893 100644 --- a/.github/workflows/codex-lab-app.yml +++ b/.github/workflows/codex-lab-app.yml @@ -145,18 +145,28 @@ jobs: 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" + engine_zip="${dist_dir}/codex-lab-engine-aarch64-apple-darwin.zip" + engine_dir="${output_root}/engine" source_commit="$(git rev-parse HEAD)" + mkdir -p "$engine_dir" + cp "$CODEX_LAB_BIN" "${engine_dir}/codex" + chmod 0755 "${engine_dir}/codex" start=$SECONDS ditto -c -k --norsrc --keepParent "${output_root}/Codex Lab.app" "$app_zip" app_zip_seconds=$((SECONDS - start)) start=$SECONDS ditto -c -k --norsrc --keepParent "${output_root}/bin/codex-lab" "$shim_zip" shim_zip_seconds=$((SECONDS - start)) + start=$SECONDS + ditto -c -k --norsrc "${engine_dir}/codex" "$engine_zip" + engine_zip_seconds=$((SECONDS - start)) app_zip_bytes="$(wc -c < "$app_zip" | tr -d '[:space:]')" shim_zip_bytes="$(wc -c < "$shim_zip" | tr -d '[:space:]')" + engine_zip_bytes="$(wc -c < "$engine_zip" | tr -d '[:space:]')" unzip -l "$app_zip" | grep -F "Codex Lab.app/Contents/Resources/codex-lab" unzip -l "$shim_zip" | grep -F "bin/codex-lab" - if { unzip -l "$app_zip"; unzip -l "$shim_zip"; } | grep -E '/\._|(^|[[:space:]])\._'; then + unzip -l "$engine_zip" | grep -F "codex" + if { unzip -l "$app_zip"; unzip -l "$shim_zip"; unzip -l "$engine_zip"; } | grep -E '/\._|(^|[[:space:]])\._'; then echo "unexpected AppleDouble file in archive" >&2 exit 1 fi @@ -181,9 +191,11 @@ jobs: { echo "- App zip seconds: \`$app_zip_seconds\`" echo "- Shim zip seconds: \`$shim_zip_seconds\`" + echo "- Engine zip seconds: \`$engine_zip_seconds\`" echo "- Manifest/SHA seconds: \`$manifest_seconds\`" echo "- App zip bytes: \`$app_zip_bytes\`" echo "- Shim zip bytes: \`$shim_zip_bytes\`" + echo "- Engine zip bytes: \`$engine_zip_bytes\`" } >> "$GITHUB_STEP_SUMMARY" echo "dist_dir=$dist_dir" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/codex-lab-release.yml b/.github/workflows/codex-lab-release.yml index 2084267b02c..91e5f4c5aa5 100644 --- a/.github/workflows/codex-lab-release.yml +++ b/.github/workflows/codex-lab-release.yml @@ -158,6 +158,56 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "output_root=$output_root" >> "$GITHUB_OUTPUT" + - name: Sign and verify managed Codex Lab engine + id: engine + shell: bash + run: | + set -euo pipefail + engine_dir="${RUNNER_TEMP}/codex-lab-signed-engine" + engine_path="${engine_dir}/codex" + engine_identifier="$(PYTHONPATH=scripts python3 -c 'from codex_lab_package.engine_contract import ENGINE_SIGNING_IDENTIFIER; print(ENGINE_SIGNING_IDENTIFIER)')" + engine_team_identifier="$(PYTHONPATH=scripts python3 -c 'from codex_lab_package.engine_contract import ENGINE_TEAM_IDENTIFIER; print(ENGINE_TEAM_IDENTIFIER)')" + signing_identity="Developer ID Application: Shiny Computers Leasing LLC (${engine_team_identifier})" + signing_keychain="$HOME/Library/Keychains/login.keychain-db" + rm -rf "$engine_dir" + mkdir -p "$engine_dir" + cp "$CODEX_LAB_BIN" "$engine_path" + chmod 0755 "$engine_path" + security unlock-keychain -p "" "$signing_keychain" + security set-keychain-settings -lut 21600 "$signing_keychain" + security find-identity -v -p codesigning | grep -F "$signing_identity" + .github/scripts/macos-signing/sign_macos_code.sh \ + --target "$engine_path" \ + --identity "$signing_identity" \ + --deep false \ + --identifier "$engine_identifier" \ + --options runtime \ + --timestamp true \ + --entitlements .github/scripts/macos-signing/codex.entitlements.plist + PYTHONPATH=scripts python3 - "$engine_path" "$(git rev-parse HEAD)" <<'PY' + from pathlib import Path + import sys + + from codex_lab_package.engine_contract import ENGINE_SIGNING_IDENTIFIER + from codex_lab_package.engine_contract import ENGINE_TEAM_IDENTIFIER + from codex_lab_package.supervisor import inspect_engine + from codex_package.version import read_workspace_version + + identity = inspect_engine(Path(sys.argv[1])) + expected_source_commit = sys.argv[2] + expected_version = read_workspace_version() + if identity.signing_identifier != ENGINE_SIGNING_IDENTIFIER: + raise ValueError(f"unexpected engine signing identifier: {identity}") + if identity.team_identifier != ENGINE_TEAM_IDENTIFIER: + raise ValueError(f"unexpected engine TeamIdentifier: {identity}") + if identity.source_commit != expected_source_commit: + raise ValueError(f"unexpected engine source commit: {identity}") + if identity.version != expected_version: + raise ValueError(f"unexpected engine version: {identity}") + print(identity) + PY + echo "engine_path=$engine_path" >> "$GITHUB_OUTPUT" + - name: Archive Codex Lab release artifacts id: archive env: @@ -166,27 +216,45 @@ jobs: run: | set -euo pipefail output_root="${{ steps.package.outputs.output_root }}" + signed_engine="${{ steps.engine.outputs.engine_path }}" release_tag="${RELEASE_TAG:?missing release tag}" dist_dir="${RUNNER_TEMP}/codex-lab-dist" + engine_dir="${RUNNER_TEMP}/codex-lab-engine-archive" 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" + engine_zip="${dist_dir}/codex-lab-engine-aarch64-apple-darwin.zip" source_commit="$(git rev-parse HEAD)" download_base_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${release_tag}" + rm -rf "$engine_dir" + mkdir -p "$engine_dir" + cp "$signed_engine" "${engine_dir}/codex" start=$SECONDS ditto -c -k --norsrc --keepParent "${output_root}/Codex Lab.app" "$app_zip" app_zip_seconds=$((SECONDS - start)) start=$SECONDS ditto -c -k --norsrc --keepParent "${output_root}/bin/codex-lab" "$shim_zip" shim_zip_seconds=$((SECONDS - start)) + start=$SECONDS + ditto -c -k --norsrc "${engine_dir}/codex" "$engine_zip" + engine_zip_seconds=$((SECONDS - start)) app_zip_bytes="$(wc -c < "$app_zip" | tr -d '[:space:]')" shim_zip_bytes="$(wc -c < "$shim_zip" | tr -d '[:space:]')" + engine_zip_bytes="$(wc -c < "$engine_zip" | tr -d '[:space:]')" unzip -l "$app_zip" | grep -F "Codex Lab.app/Contents/Resources/codex-lab" unzip -l "$shim_zip" | grep -F "bin/codex-lab" - if { unzip -l "$app_zip"; unzip -l "$shim_zip"; } | grep -E '/\._|(^|[[:space:]])\._'; then + unzip -l "$engine_zip" | grep -F "codex" + if { unzip -l "$app_zip"; unzip -l "$shim_zip"; unzip -l "$engine_zip"; } | grep -E '/\._|(^|[[:space:]])\._'; then echo "unexpected AppleDouble file in archive" >&2 exit 1 fi + engine_validation_dir="${RUNNER_TEMP}/codex-lab-engine-archive-validation" + rm -rf "$engine_validation_dir" + mkdir -p "$engine_validation_dir" + ditto -x -k "$engine_zip" "$engine_validation_dir" + PYTHONPATH=scripts python3 -c \ + 'from pathlib import Path; import sys; from codex_lab_package.supervisor import inspect_engine; print(inspect_engine(Path(sys.argv[1])))' \ + "${engine_validation_dir}/codex" start=$SECONDS (cd "$dist_dir" && shasum -a 256 -- *.zip > SHA256SUMS) python3 scripts/build_codex_lab_distribution_manifest.py generate \ @@ -199,7 +267,8 @@ jobs: --run-id "$GITHUB_RUN_ID" \ --run-attempt "$GITHUB_RUN_ATTEMPT" \ --release-tag "$release_tag" \ - --download-base-url "$download_base_url" + --download-base-url "$download_base_url" \ + --engine-signed python3 scripts/build_codex_lab_distribution_manifest.py validate \ "${dist_dir}/codex-lab-distribution.json" \ --dist-dir "$dist_dir" \ @@ -210,9 +279,11 @@ jobs: { echo "- App zip seconds: \`$app_zip_seconds\`" echo "- Shim zip seconds: \`$shim_zip_seconds\`" + echo "- Engine zip seconds: \`$engine_zip_seconds\`" echo "- Manifest/SHA seconds: \`$manifest_seconds\`" echo "- App zip bytes: \`$app_zip_bytes\`" echo "- Shim zip bytes: \`$shim_zip_bytes\`" + echo "- Engine zip bytes: \`$engine_zip_bytes\`" } >> "$GITHUB_STEP_SUMMARY" echo "dist_dir=$dist_dir" >> "$GITHUB_OUTPUT" @@ -242,12 +313,18 @@ jobs: set -euo pipefail test -f dist/codex-lab-app-aarch64-apple-darwin.zip test -f dist/codex-lab-shim-aarch64-apple-darwin.zip + test -f dist/codex-lab-engine-aarch64-apple-darwin.zip test -f dist/SHA256SUMS test -f dist/codex-lab-distribution.json (cd dist && sha256sum -c SHA256SUMS) jq -e \ --arg tag "${{ needs.build-macos-aarch64.outputs.release_tag }}" \ - '.release.tag == $tag and .artifacts.appZip.downloadUrl and .artifacts.shimZip.downloadUrl' \ + '.release.tag == $tag + and .artifacts.appZip.downloadUrl + and .artifacts.shimZip.downloadUrl + and .artifacts.engineZip.downloadUrl + and .artifacts.engineZip.signed == true + and .managedEngine.sha256' \ dist/codex-lab-distribution.json - name: Verify release tag is available @@ -300,11 +377,14 @@ jobs: cat > release-notes.md <<'EOF' Codex Lab macOS ARM64 distribution artifact. - This prerelease is unsigned and not notarized. It is intended for Codex Lab validation before the signing and updater pipeline exists. + The managed Codex Lab engine is individually Developer ID signed and + carries the required V8 JIT entitlement. The app and companion shim + remain unsigned Lab launch surfaces. Assets: - codex-lab-app-aarch64-apple-darwin.zip - codex-lab-shim-aarch64-apple-darwin.zip + - codex-lab-engine-aarch64-apple-darwin.zip - codex-lab-distribution.json - SHA256SUMS EOF @@ -350,5 +430,6 @@ jobs: --latest=false \ dist/codex-lab-app-aarch64-apple-darwin.zip \ dist/codex-lab-shim-aarch64-apple-darwin.zip \ + dist/codex-lab-engine-aarch64-apple-darwin.zip \ dist/codex-lab-distribution.json \ dist/SHA256SUMS diff --git a/scripts/codex_lab_package/README.md b/scripts/codex_lab_package/README.md index fa8f664737e..fba77d286df 100644 --- a/scripts/codex_lab_package/README.md +++ b/scripts/codex_lab_package/README.md @@ -68,22 +68,23 @@ GUI is running beside the launchd-supervised websocket app-server. The embedded and managed CLI builds must have matching fixed source/build provenance. The GitHub workflow uploads `codex-lab-distribution.json` beside the app zip, -shim zip, and `SHA256SUMS`. The manifest records artifact roles, sizes, -checksums, source workflow metadata, supported install layouts, release tags, -download URLs when published, and the current signing state. Codex Lab artifacts -are currently marked `signed: false` and `notarized: false` until a later -signing/notarization stage is implemented. +shim zip, managed-engine zip, and `SHA256SUMS`. The manifest records artifact +roles, sizes, checksums, source workflow metadata, supported install layouts, +release tags, download URLs when published, and the managed engine's binary +digest, Developer ID identifier, TeamIdentifier, version, source commit, and +required JIT entitlement. PR app artifacts carry an unsigned engine for package +validation; published release manifests require the engine artifact to be +individually Developer ID signed. Packaging workflows bind the static smoke to the expected source commit before the interactive GUI smoke is performed. ## Installing a published release -The current published-release installer installs only the app and optional shim. -It does not yet provision the individually signed managed engine or its user -LaunchAgent. Until signed engine provisioning is added to the release path, the -launcher intentionally fails closed unless that matching supervisor has already -been installed by the Codex Lab canary workflow. +The published-release installer provisions the app, optional shim, individually +signed managed engine, and the `dev.everycode.codex-lab.app-server.v1` user +LaunchAgent as one rollback-aware transaction. No manual canary provisioning is +required for a supported release. Use `scripts/install_codex_lab.py` to install or manually update Codex Lab from a published release manifest: @@ -122,15 +123,27 @@ scripts/install_codex_lab.py --update ``` `--update` reads the recorded install state, preserves the installed app path and -shim path, and replaces only when a newer published Lab release is available. - -The installer downloads the manifest, `SHA256SUMS`, app zip, and shim zip into a -temporary staging directory. It validates the manifest shape, requires artifact -URLs to be siblings of the manifest URL, checks artifact sizes and SHA-256 -hashes, rejects unsafe zip members, smoke-checks the staged app and shim, then -replaces the requested install paths. Existing targets are refused unless -`--force` is supplied. - -Codex Lab release artifacts are currently unsigned and unnotarized. This -installer is a manual Lab installer/update path; silent automatic updates should -wait for signed or notarized artifacts, or a signed manifest. +shim path, installs the matching engine, and restarts the pinned supervisor only +when a newer published Lab release is available. It does not enable the upstream +standalone updater. + +To remove the recorded install and restore any managed engine that predated the +first supported installer run, use: + +```shell +scripts/install_codex_lab.py --uninstall +``` + +The installer downloads the manifest, `SHA256SUMS`, app zip, shim zip, and engine +zip into a temporary staging directory. It validates release URLs, sizes, and +SHA-256 hashes; rejects unsafe zip members; smoke-checks the app and shim; and +uses macOS code-signing inspection plus engine provenance to require the exact +binary digest, source commit, version, stable identifier, TeamIdentifier, and V8 +JIT entitlement from the release metadata. It then replaces the engine, app, +shim, and state as a rollback set before installing and health-checking the +LaunchAgent. A provisioning failure restores the prior files and the +supervisor's own rollback restores its prior runner, plist, and load state. +Existing targets are refused unless `--force` is supplied. + +The app and shim remain unsigned Lab launch surfaces. The managed engine is the +individually Developer ID signed execution boundary pinned by the supervisor. diff --git a/scripts/codex_lab_package/distribution_manifest.py b/scripts/codex_lab_package/distribution_manifest.py index b055c6d09e7..b450f0e497b 100644 --- a/scripts/codex_lab_package/distribution_manifest.py +++ b/scripts/codex_lab_package/distribution_manifest.py @@ -4,6 +4,8 @@ import hashlib import json import re +import stat +import zipfile from dataclasses import dataclass from datetime import datetime from datetime import timezone @@ -15,15 +17,20 @@ from codex_lab_package.layout import OFFICIAL_APP_BUNDLE_IDENTIFIER from codex_lab_package.layout import OFFICIAL_APP_CANDIDATE_PATHS from codex_lab_package.layout import OFFICIAL_APP_TEAM_IDENTIFIER +from codex_lab_package.engine_contract import ENGINE_ARCHIVE_ROOT +from codex_lab_package.engine_contract import ENGINE_SIGNING_IDENTIFIER +from codex_lab_package.engine_contract import ENGINE_TEAM_IDENTIFIER +from codex_lab_package.engine_contract import REQUIRED_ENGINE_ENTITLEMENTS from codex_package.version import read_workspace_version -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 PRODUCT = "codex-lab" CHANNEL = "lab" PLATFORM = "aarch64-apple-darwin" APP_ZIP = "codex-lab-app-aarch64-apple-darwin.zip" SHIM_ZIP = "codex-lab-shim-aarch64-apple-darwin.zip" +ENGINE_ZIP = "codex-lab-engine-aarch64-apple-darwin.zip" MANIFEST_NAME = "codex-lab-distribution.json" RELEASE_TAG_PATTERN = re.compile( r"^codex-lab-v(?P[0-9]+\.[0-9]+\.[0-9]+)" @@ -52,6 +59,12 @@ class ArtifactSpec: archive_root="bin/codex-lab", description="Companion CLI wrapper that resolves an installed or sibling Codex Lab.app.", ), + ArtifactSpec( + role="engineZip", + file_name=ENGINE_ZIP, + archive_root=ENGINE_ARCHIVE_ROOT, + description="Individually signed managed engine pinned by the Codex Lab supervisor.", + ), ) @@ -76,6 +89,8 @@ def parse_args() -> argparse.Namespace: generate.add_argument("--release-tag") generate.add_argument("--download-base-url") generate.add_argument("--generated-at") + generate.add_argument("--engine-signed", action="store_true") + generate.add_argument("--engine-notarized", action="store_true") generate.set_defaults(func=cmd_generate) validate = subparsers.add_parser( @@ -111,6 +126,8 @@ def cmd_generate(args: argparse.Namespace) -> None: release_tag=args.release_tag, download_base_url=args.download_base_url, generated_at=args.generated_at, + engine_signed=args.engine_signed, + engine_notarized=args.engine_notarized, ) validate_manifest(manifest, dist_dir=args.dist_dir, checksums=checksums) args.output.parent.mkdir(parents=True, exist_ok=True) @@ -139,7 +156,11 @@ def build_manifest( release_tag: str | None = None, download_base_url: str | None = None, generated_at: str | None = None, + engine_signed: bool = False, + engine_notarized: bool = False, ) -> dict[str, Any]: + if engine_notarized and not engine_signed: + raise ValueError("The managed engine cannot be notarized unless it is signed") timestamp = generated_at or utc_timestamp() base_url = normalize_download_base_url(download_base_url) if (release_tag is None) != (base_url is None): @@ -157,13 +178,14 @@ def build_manifest( raise ValueError( f"Checksum mismatch for {artifact.file_name}: {checksum} != {actual_checksum}" ) + is_engine = artifact.role == "engineZip" artifacts[artifact.role] = { "archiveRoot": artifact.archive_root, "description": artifact.description, "fileName": artifact.file_name, - "notarized": False, + "notarized": engine_notarized if is_engine else False, "sha256": checksum, - "signed": False, + "signed": engine_signed if is_engine else False, "sizeBytes": path.stat().st_size, } if base_url is not None: @@ -188,6 +210,18 @@ def build_manifest( "requiresValidOfficialSignature": True, }, "generatedAt": timestamp, + "managedEngine": { + "artifactRole": "engineZip", + "requiredEntitlements": list(REQUIRED_ENGINE_ENTITLEMENTS), + "sha256": sha256_zip_member( + dist_dir / ENGINE_ZIP, + ENGINE_ARCHIVE_ROOT, + ), + "signingIdentifier": ENGINE_SIGNING_IDENTIFIER if engine_signed else None, + "sourceCommit": commit, + "teamIdentifier": ENGINE_TEAM_IDENTIFIER if engine_signed else None, + "version": version, + }, "platform": PLATFORM, "product": PRODUCT, "schemaVersion": SCHEMA_VERSION, @@ -242,6 +276,7 @@ def validate_manifest( "channel", "desktopIntegration", "generatedAt", + "managedEngine", "platform", "product", "schemaVersion", @@ -280,6 +315,14 @@ def validate_manifest( if not isinstance(entry, dict): raise ValueError(f"{artifact.role} must be an object") validate_artifact_entry(artifact, entry, dist_dir=dist_dir, checksums=checksums) + validate_managed_engine( + manifest["managedEngine"], + artifacts["engineZip"], + version=version, + source=manifest["source"], + release=manifest.get("release"), + dist_dir=dist_dir, + ) validate_release_download_urls(manifest.get("release"), artifacts) @@ -386,8 +429,14 @@ def validate_artifact_entry( raise ValueError( f"{artifact.role} has unexpected archiveRoot: {entry['archiveRoot']}" ) - if entry["signed"] is not False or entry["notarized"] is not False: + signed = entry["signed"] + notarized = entry["notarized"] + if not isinstance(signed, bool) or not isinstance(notarized, bool): + raise ValueError(f"{artifact.role} signing fields must be booleans") + if artifact.role != "engineZip" and (signed or notarized): raise ValueError(f"{artifact.role} must be marked unsigned and not notarized") + if artifact.role == "engineZip" and notarized and not signed: + raise ValueError("engineZip cannot be notarized unless it is signed") if not is_sha256(entry["sha256"]): raise ValueError(f"{artifact.role} has invalid sha256: {entry['sha256']}") if not isinstance(entry["sizeBytes"], int) or entry["sizeBytes"] <= 0: @@ -407,6 +456,66 @@ def validate_artifact_entry( raise ValueError(f"{artifact.role} sha256 does not match {path}") +def validate_managed_engine( + managed_engine: object, + engine_artifact: dict[str, Any], + *, + version: str, + source: object, + release: object, + dist_dir: Path | None, +) -> None: + if not isinstance(managed_engine, dict): + raise ValueError("Manifest managedEngine must be an object") + required_fields = { + "artifactRole", + "requiredEntitlements", + "sha256", + "signingIdentifier", + "sourceCommit", + "teamIdentifier", + "version", + } + missing = sorted(required_fields - managed_engine.keys()) + if missing: + raise ValueError(f"managedEngine is missing required fields: {missing}") + if managed_engine["artifactRole"] != "engineZip": + raise ValueError("managedEngine artifactRole must be engineZip") + if managed_engine["version"] != version: + raise ValueError("managedEngine version must match the manifest version") + if not isinstance(source, dict) or managed_engine["sourceCommit"] != source.get( + "commit" + ): + raise ValueError("managedEngine sourceCommit must match the manifest source") + if not is_sha256(managed_engine["sha256"]): + raise ValueError("managedEngine sha256 must be a lowercase SHA-256 digest") + if managed_engine["requiredEntitlements"] != list(REQUIRED_ENGINE_ENTITLEMENTS): + raise ValueError("managedEngine requiredEntitlements are invalid") + + if engine_artifact["signed"]: + if managed_engine["signingIdentifier"] != ENGINE_SIGNING_IDENTIFIER: + raise ValueError("managedEngine signingIdentifier is invalid") + if managed_engine["teamIdentifier"] != ENGINE_TEAM_IDENTIFIER: + raise ValueError("managedEngine teamIdentifier is invalid") + elif ( + managed_engine["signingIdentifier"] is not None + or managed_engine["teamIdentifier"] is not None + ): + raise ValueError( + "Unsigned managedEngine metadata must not claim a signing identity" + ) + + if release is not None and not engine_artifact["signed"]: + raise ValueError("Published releases require a signed managed engine") + if dist_dir is not None: + actual_sha256 = sha256_zip_member( + dist_dir / ENGINE_ZIP, + ENGINE_ARCHIVE_ROOT, + ) + if managed_engine["sha256"] != actual_sha256: + raise ValueError("managedEngine sha256 does not match the engine archive") + + def read_sha256sums(path: Path) -> dict[str, str]: checksums = {} for line_number, line in enumerate( @@ -438,6 +547,23 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def sha256_zip_member(path: Path, member_name: str) -> str: + with zipfile.ZipFile(path) as archive: + members = [info for info in archive.infolist() if not info.is_dir()] + if len(members) != 1 or members[0].filename != member_name: + raise ValueError( + f"Engine archive must contain exactly {member_name}: {path}" + ) + mode = (members[0].external_attr >> 16) & 0o777777 + if mode and not stat.S_ISREG(mode): + raise ValueError(f"Engine archive member must be a regular file: {path}") + digest = hashlib.sha256() + with archive.open(members[0]) as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def normalize_download_base_url(url: str | None) -> str | None: if url is None: return None diff --git a/scripts/codex_lab_package/engine_contract.py b/scripts/codex_lab_package/engine_contract.py new file mode 100644 index 00000000000..24bb2bb8ff2 --- /dev/null +++ b/scripts/codex_lab_package/engine_contract.py @@ -0,0 +1,6 @@ +"""Stable release contract for the managed Codex Lab engine.""" + +ENGINE_ARCHIVE_ROOT = "codex" +ENGINE_SIGNING_IDENTIFIER = "com.shinycomputers.codex-lab.engine" +ENGINE_TEAM_IDENTIFIER = "MM5YXC7T6E" +REQUIRED_ENGINE_ENTITLEMENTS = ("com.apple.security.cs.allow-jit",) diff --git a/scripts/codex_lab_package/installer.py b/scripts/codex_lab_package/installer.py index fcb0f329adb..c6c675a7a07 100644 --- a/scripts/codex_lab_package/installer.py +++ b/scripts/codex_lab_package/installer.py @@ -7,6 +7,7 @@ import re import shutil import stat +import subprocess import tempfile import urllib.parse import urllib.request @@ -15,13 +16,21 @@ from .distribution_manifest import APP_ZIP from .distribution_manifest import ARTIFACTS +from .distribution_manifest import ENGINE_ZIP from .distribution_manifest import MANIFEST_NAME from .distribution_manifest import SHIM_ZIP from .distribution_manifest import is_https_url from .distribution_manifest import read_sha256sums from .distribution_manifest import validate_manifest +from .engine_contract import ENGINE_ARCHIVE_ROOT from .layout import build_shim_script from .smoke import smoke_check +from .supervisor import EngineIdentity +from .supervisor import SupervisorPaths +from .supervisor import default_supervisor_paths +from .supervisor import inspect_engine +from .supervisor import install_supervisor +from .supervisor import uninstall_supervisor DEFAULT_REPOSITORY = "cbusillo/codex-lab" @@ -51,6 +60,10 @@ class CodexLabUpdateError(Exception): pass +class CodexLabRollbackError(Exception): + pass + + @dataclass(frozen=True) class CodexLabReleaseSummary: published_at: str @@ -60,9 +73,11 @@ class CodexLabReleaseSummary: @dataclass(frozen=True) class CodexLabInstallResult: app_dir: Path + engine_path: Path release_tag: str shim_path: Path | None state_path: Path + supervisor_label: str version: str @@ -70,10 +85,17 @@ class CodexLabInstallResult: class CodexLabInstallStatus: app_path: Path bundle_version: str + engine_backup_path: Path | None + engine_path: Path | None + lab_home: Path | None + launch_agents_dir: Path | None + listen_host: str | None + listen_port: int | None release_tag: str shim_path: Path | None source_commit: str | None state_path: Path + supervisor_label: str | None version: str @@ -90,10 +112,64 @@ class CodexLabUpdateResult: install: CodexLabInstallResult | None +@dataclass(frozen=True) +class CodexLabUninstallResult: + app_path: Path + engine_path: Path | None + restored_engine_path: Path | None + shim_path: Path | None + state_path: Path + + +@dataclass(frozen=True) +class ManagedEngineRelease: + sha256: str + signing_identifier: str + source_commit: str + team_identifier: str + version: str + + +InspectEngineFunc = Callable[[Path], EngineIdentity] +InstallSupervisorFunc = Callable[[SupervisorPaths, ManagedEngineRelease], None] +UninstallSupervisorFunc = Callable[[SupervisorPaths], None] + + +@dataclass(frozen=True) +class EngineProvisioningOperations: + inspect: InspectEngineFunc + install_supervisor: InstallSupervisorFunc + uninstall_supervisor: UninstallSupervisorFunc + + @dataclass(frozen=True) class Replacement: target: Path backup_path: Path + preserve_backup: bool = False + + +def install_release_supervisor( + paths: SupervisorPaths, + release: ManagedEngineRelease, +) -> None: + install_supervisor( + paths, + expected_sha256=release.sha256, + expected_source_commit=release.source_commit, + expected_version=release.version, + ) + + +def uninstall_release_supervisor(paths: SupervisorPaths) -> None: + uninstall_supervisor(paths) + + +DEFAULT_ENGINE_OPERATIONS = EngineProvisioningOperations( + inspect=inspect_engine, + install_supervisor=install_release_supervisor, + uninstall_supervisor=uninstall_release_supervisor, +) def manifest_url_for_release_tag( @@ -143,12 +219,16 @@ def install_from_manifest_url( app_dir: Path = DEFAULT_APP_DIR, shim_dir: Path | None = DEFAULT_SHIM_DIR, state_path: Path = DEFAULT_STATE_PATH, + supervisor_paths: SupervisorPaths | None = None, force: bool = False, download: DownloadFunc | None = None, + engine_operations: EngineProvisioningOperations | None = None, ) -> CodexLabInstallResult: if not is_https_url(manifest_url): raise ValueError(f"manifest URL must be an HTTPS URL: {manifest_url}") download = download or download_url + engine_operations = engine_operations or DEFAULT_ENGINE_OPERATIONS + supervisor_paths = supervisor_paths or default_supervisor_paths() app_dir = resolve_destination(app_dir) shim_path = resolve_destination(shim_dir / "codex-lab") if shim_dir else None @@ -168,6 +248,10 @@ def install_from_manifest_url( manifest = json.loads(manifest_path.read_text(encoding="utf-8")) validate_manifest(manifest) + release = manifest.get("release") + if not isinstance(release, dict): + raise ValueError("Installer requires a published release manifest") + engine_release = managed_engine_release_from_manifest(manifest) artifacts = manifest["artifacts"] for artifact in ARTIFACTS: @@ -192,7 +276,14 @@ def install_from_manifest_url( archive_root="bin/codex-lab", extract_dir=extract_dir / "shim", ) + engine_source = extract_artifact( + dist_dir / ENGINE_ZIP, + archive_root=ENGINE_ARCHIVE_ROOT, + extract_dir=extract_dir / "engine", + ) smoke_check(app_source, shim_source) + staged_identity = engine_operations.inspect(engine_source) + require_engine_release_identity(staged_identity, engine_release) preflight_install_parent(app_dir.parent) preflight_install_target(app_dir, force=force) @@ -200,10 +291,55 @@ def install_from_manifest_url( preflight_install_parent(shim_path.parent) preflight_install_target(shim_path, force=force) preflight_install_parent(state_path.parent) + preflight_install_parent(supervisor_paths.managed_cli.parent) + preflight_install_target(supervisor_paths.managed_cli, force=force) + + previous_status = read_optional_install_state(state_path) + if previous_status is not None: + require_recorded_install( + previous_status, + supervisor_paths=supervisor_paths, + engine_operations=engine_operations, + ) + if ( + previous_status is not None + and previous_status.engine_path is not None + and previous_status.engine_path != supervisor_paths.managed_cli + ): + raise ValueError( + "Recorded managed engine path does not match the requested Lab home: " + f"{previous_status.engine_path} != {supervisor_paths.managed_cli}" + ) + engine_backup_path = ( + previous_status.engine_backup_path if previous_status is not None else None + ) + if engine_backup_path is not None: + require_engine_backup(engine_backup_path) + + engine_was_installer_managed = ( + previous_status is not None + and previous_status.engine_path == supervisor_paths.managed_cli + ) + preserve_existing_engine = ( + not engine_was_installer_managed + and engine_backup_path is None + and path_exists(supervisor_paths.managed_cli) + ) + if preserve_existing_engine: + engine_backup_path = default_engine_backup_path(state_path) + preflight_new_backup_path(engine_backup_path) replacements = [] installed_shim = None try: + engine_replacement = replace_path( + engine_source, + supervisor_paths.managed_cli, + force=force, + backup_path=engine_backup_path if preserve_existing_engine else None, + preserve_backup=preserve_existing_engine, + ) + replacements.append(engine_replacement) app_replacement = replace_path( app_source, app_dir, @@ -225,25 +361,147 @@ def install_from_manifest_url( make_executable(installed_shim) smoke_check(installed_app, installed_shim) + state_source = temp_dir / "install-state.json" write_install_state( - state_path, + state_source, manifest, app_dir=installed_app, + engine_backup_path=engine_backup_path, shim_path=installed_shim, + supervisor_paths=supervisor_paths, ) - except Exception: - rollback_replacements(replacements) + replacements.append( + replace_path( + state_source, + state_path, + force=True, + ) + ) + engine_operations.install_supervisor(supervisor_paths, engine_release) + except Exception as install_error: + try: + rollback_replacements(replacements) + except Exception as rollback_error: + raise CodexLabRollbackError( + "Codex Lab installation failed and file rollback did not complete: " + f"{rollback_error}" + ) from install_error raise cleanup_replacements(replacements) return CodexLabInstallResult( app_dir=installed_app, - release_tag=manifest["release"]["tag"], + engine_path=supervisor_paths.managed_cli, + release_tag=release["tag"], shim_path=installed_shim, state_path=state_path, + supervisor_label=supervisor_paths.label, version=manifest["version"], ) +def managed_engine_release_from_manifest(manifest: dict) -> ManagedEngineRelease: + managed_engine = manifest["managedEngine"] + return ManagedEngineRelease( + sha256=managed_engine["sha256"], + signing_identifier=managed_engine["signingIdentifier"], + source_commit=managed_engine["sourceCommit"], + team_identifier=managed_engine["teamIdentifier"], + version=managed_engine["version"], + ) + + +def managed_engine_release_from_identity( + identity: EngineIdentity, +) -> ManagedEngineRelease: + return ManagedEngineRelease( + sha256=identity.sha256, + signing_identifier=identity.signing_identifier, + source_commit=identity.source_commit, + team_identifier=identity.team_identifier, + version=identity.version, + ) + + +def require_engine_release_identity( + identity: EngineIdentity, + release: ManagedEngineRelease, +) -> None: + expected = { + "sha256": release.sha256, + "signing identifier": release.signing_identifier, + "source commit": release.source_commit, + "team identifier": release.team_identifier, + "version": release.version, + } + actual = { + "sha256": identity.sha256, + "signing identifier": identity.signing_identifier, + "source commit": identity.source_commit, + "team identifier": identity.team_identifier, + "version": identity.version, + } + mismatches = [ + f"{field}: {actual[field]} != {expected[field]}" + for field in expected + if actual[field] != expected[field] + ] + if mismatches: + raise ValueError( + "Managed engine identity does not match the release manifest: " + + "; ".join(mismatches) + ) + + +def read_optional_install_state(state_path: Path) -> CodexLabInstallStatus | None: + if not path_exists(state_path): + return None + return read_install_state(state_path) + + +def require_recorded_install( + status: CodexLabInstallStatus, + *, + supervisor_paths: SupervisorPaths, + engine_operations: EngineProvisioningOperations, +) -> None: + try: + smoke_check(status.app_path, status.shim_path) + except (OSError, subprocess.CalledProcessError, ValueError) as exc: + raise ValueError( + "Recorded app or shim is not a managed Codex Lab install" + ) from exc + if status.engine_path is None: + return + if status.engine_path != supervisor_paths.managed_cli: + raise ValueError( + "Recorded managed engine path does not match the requested Lab home: " + f"{status.engine_path} != {supervisor_paths.managed_cli}" + ) + identity = engine_operations.inspect(status.engine_path) + if identity.version != status.version or ( + status.source_commit is not None + and identity.source_commit != status.source_commit + ): + raise ValueError( + "Recorded managed engine provenance does not match the install state" + ) + + +def default_engine_backup_path(state_path: Path) -> Path: + return state_path.parent / "engine-backup" / "codex" + + +def require_engine_backup(path: Path) -> None: + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Recorded engine backup is not a regular file: {path}") + + +def preflight_new_backup_path(path: Path) -> None: + preflight_install_parent(path.parent) + if path_exists(path): + raise FileExistsError(f"Engine backup path already exists: {path}") + + def read_install_state(state_path: Path = DEFAULT_STATE_PATH) -> CodexLabInstallStatus: state_path = resolve_destination(state_path) try: @@ -273,13 +531,33 @@ def read_install_state(state_path: Path = DEFAULT_STATE_PATH) -> CodexLabInstall raise CodexLabInstallStateError( f"Install state field shimPath must be a string or null: {state_path}" ) + engine_backup_path = optional_state_path(state, "engineBackupPath", state_path) + engine_path = optional_state_path(state, "enginePath", state_path) + lab_home = optional_state_path(state, "labHome", state_path) + launch_agents_dir = optional_state_path(state, "launchAgentsDir", state_path) + listen_host = optional_state_string(state, "listenHost", state_path) + listen_port = optional_state_positive_int(state, "listenPort", state_path) + supervisor_label = state.get("supervisorLabel") + if supervisor_label is not None and ( + not isinstance(supervisor_label, str) or not supervisor_label + ): + raise CodexLabInstallStateError( + f"Install state field supervisorLabel must be a non-empty string or null: {state_path}" + ) return CodexLabInstallStatus( app_path=Path(required_state_string(state, "appPath", state_path)), bundle_version=required_state_string(state, "bundleVersion", state_path), + engine_backup_path=engine_backup_path, + engine_path=engine_path, + lab_home=lab_home, + launch_agents_dir=launch_agents_dir, + listen_host=listen_host, + listen_port=listen_port, release_tag=required_state_string(state, "releaseTag", state_path), shim_path=Path(shim_path) if isinstance(shim_path, str) else None, source_commit=source_commit, state_path=state_path, + supervisor_label=supervisor_label, version=required_state_string(state, "version", state_path), ) @@ -315,6 +593,7 @@ def update_from_latest_release( repository: str = DEFAULT_REPOSITORY, state_path: Path = DEFAULT_STATE_PATH, download: DownloadFunc | None = None, + engine_operations: EngineProvisioningOperations | None = None, ) -> CodexLabUpdateResult: check = check_for_update(repository=repository, state_path=state_path) if not check.update_available: @@ -325,6 +604,7 @@ def update_from_latest_release( repository=repository, ) installed = check.installed + supervisor_paths = supervisor_paths_from_status(installed) return CodexLabUpdateResult( check=check, install=install_from_manifest_url( @@ -334,12 +614,107 @@ def update_from_latest_release( if installed.shim_path is not None else None, state_path=installed.state_path, + supervisor_paths=supervisor_paths, force=True, download=download, + engine_operations=engine_operations, ), ) +def uninstall_codex_lab( + *, + state_path: Path = DEFAULT_STATE_PATH, + engine_operations: EngineProvisioningOperations | None = None, +) -> CodexLabUninstallResult: + engine_operations = engine_operations or DEFAULT_ENGINE_OPERATIONS + status = read_install_state(state_path) + supervisor_paths = supervisor_paths_from_status(status) + require_recorded_install( + status, + supervisor_paths=supervisor_paths, + engine_operations=engine_operations, + ) + manages_engine = ( + status.engine_path is not None + and status.supervisor_label is not None + and status.engine_path == supervisor_paths.managed_cli + ) + if status.engine_backup_path is not None: + require_engine_backup(status.engine_backup_path) + current_engine_release = None + if manages_engine: + current_engine_release = managed_engine_release_from_identity( + engine_operations.inspect(supervisor_paths.managed_cli) + ) + + removals: list[Replacement] = [] + restored_engine_path = None + try: + for target in (status.app_path, status.shim_path): + if target is not None and path_exists(target): + removals.append(stage_path_removal(target)) + removals.append(stage_path_removal(status.state_path)) + + if manages_engine: + engine_operations.uninstall_supervisor(supervisor_paths) + removals.append(stage_path_removal(supervisor_paths.managed_cli)) + if manages_engine and status.engine_backup_path is not None: + supervisor_paths.managed_cli.parent.mkdir(parents=True, exist_ok=True) + shutil.move( + str(status.engine_backup_path), + str(supervisor_paths.managed_cli), + ) + restored_engine_path = supervisor_paths.managed_cli + except Exception as uninstall_error: + try: + if restored_engine_path is not None: + status.engine_backup_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move( + str(restored_engine_path), + str(status.engine_backup_path), + ) + rollback_replacements(removals) + if current_engine_release is not None: + engine_operations.install_supervisor( + supervisor_paths, + current_engine_release, + ) + except Exception as rollback_error: + raise CodexLabRollbackError( + "Codex Lab uninstall failed and rollback did not complete: " + f"{rollback_error}" + ) from uninstall_error + raise + + cleanup_replacements(removals) + return CodexLabUninstallResult( + app_path=status.app_path, + engine_path=status.engine_path if manages_engine else None, + restored_engine_path=restored_engine_path, + shim_path=status.shim_path, + state_path=status.state_path, + ) + + +def supervisor_paths_from_status(status: CodexLabInstallStatus) -> SupervisorPaths: + if ( + status.lab_home is None + or status.launch_agents_dir is None + or status.listen_host is None + or status.listen_port is None + or status.supervisor_label is None + ): + return default_supervisor_paths() + return SupervisorPaths( + lab_home=status.lab_home, + launch_agents_dir=status.launch_agents_dir, + label=status.supervisor_label, + listen_host=status.listen_host, + listen_port=status.listen_port, + ) + + def required_state_string(state: dict, field: str, state_path: Path) -> str: value = state.get(field) if not isinstance(value, str) or not value: @@ -349,6 +724,43 @@ def required_state_string(state: dict, field: str, state_path: Path) -> str: return value +def optional_state_path(state: dict, field: str, state_path: Path) -> Path | None: + value = state.get(field) + if value is None: + return None + if not isinstance(value, str) or not value: + raise CodexLabInstallStateError( + f"Install state field {field} must be a non-empty string or null: {state_path}" + ) + return Path(value) + + +def optional_state_string(state: dict, field: str, state_path: Path) -> str | None: + value = state.get(field) + if value is None: + return None + if not isinstance(value, str) or not value: + raise CodexLabInstallStateError( + f"Install state field {field} must be a non-empty string or null: {state_path}" + ) + return value + + +def optional_state_positive_int( + state: dict, + field: str, + state_path: Path, +) -> int | None: + value = state.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise CodexLabInstallStateError( + f"Install state field {field} must be a positive integer or null: {state_path}" + ) + return value + + def download_url(url: str, dest: Path) -> None: if not is_https_url(url): raise ValueError(f"download URL must be an HTTPS URL: {url}") @@ -563,20 +975,35 @@ def replace_path( target: Path, *, force: bool, + backup_path: Path | None = None, + preserve_backup: bool = False, ) -> Replacement: target.parent.mkdir(parents=True, exist_ok=True) - backup_path = backup_path_for(target) - if target.exists() or target.is_symlink(): + backup_path = backup_path or backup_path_for(target) + if path_exists(target): preflight_install_target(target, force=force) + backup_path.parent.mkdir(parents=True, exist_ok=True) + if path_exists(backup_path): + raise FileExistsError(f"Backup path already exists: {backup_path}") target.rename(backup_path) try: shutil.move(str(source), str(target)) except Exception: remove_path(target) - if backup_path.exists() or backup_path.is_symlink(): + if path_exists(backup_path): shutil.move(str(backup_path), str(target)) raise + return Replacement( + target=target, + backup_path=backup_path, + preserve_backup=preserve_backup, + ) + + +def stage_path_removal(target: Path) -> Replacement: + backup_path = backup_path_for(target) + target.rename(backup_path) return Replacement(target=target, backup_path=backup_path) @@ -593,13 +1020,14 @@ def backup_path_for(target: Path) -> Path: def rollback_replacements(replacements: list[Replacement]) -> None: for replacement in reversed(replacements): remove_path(replacement.target) - if replacement.backup_path.exists() or replacement.backup_path.is_symlink(): + if path_exists(replacement.backup_path): shutil.move(str(replacement.backup_path), str(replacement.target)) def cleanup_replacements(replacements: list[Replacement]) -> None: for replacement in replacements: - remove_path(replacement.backup_path) + if not replacement.preserve_backup: + remove_path(replacement.backup_path) def remove_path(path: Path) -> None: @@ -609,6 +1037,10 @@ def remove_path(path: Path) -> None: path.unlink() +def path_exists(path: Path) -> bool: + return path.exists() or path.is_symlink() + + def preflight_install_target(target: Path, *, force: bool) -> None: if target.is_symlink(): raise ValueError(f"Install target must not be a symlink: {target}") @@ -619,6 +1051,11 @@ def preflight_install_target(target: Path, *, force: bool) -> None: def preflight_install_parent(parent: Path) -> None: if parent.is_symlink(): raise ValueError(f"Install parent must not be a symlink: {parent}") + ancestor = parent + while not path_exists(ancestor): + ancestor = ancestor.parent + if not ancestor.is_dir(): + raise NotADirectoryError(f"Install parent is not a directory: {ancestor}") def make_executable(path: Path) -> None: @@ -631,7 +1068,9 @@ def write_install_state( manifest: dict, *, app_dir: Path, + engine_backup_path: Path | None, shim_path: Path | None, + supervisor_paths: SupervisorPaths, ) -> None: state_path = resolve_destination(state_path) state = { @@ -645,9 +1084,18 @@ def write_install_state( for role, entry in manifest["artifacts"].items() }, "bundleVersion": manifest["bundleVersion"], + "engineBackupPath": str(engine_backup_path) + if engine_backup_path is not None + else None, + "enginePath": str(supervisor_paths.managed_cli), + "labHome": str(supervisor_paths.lab_home), + "launchAgentsDir": str(supervisor_paths.launch_agents_dir), + "listenHost": supervisor_paths.listen_host, + "listenPort": supervisor_paths.listen_port, "releaseTag": manifest["release"]["tag"], "shimPath": str(shim_path) if shim_path is not None else None, "source": manifest["source"], + "supervisorLabel": supervisor_paths.label, "version": manifest["version"], } preflight_install_parent(state_path.parent) diff --git a/scripts/codex_lab_package/test_distribution_manifest.py b/scripts/codex_lab_package/test_distribution_manifest.py index 1f81b9e5138..a7b7305f79b 100644 --- a/scripts/codex_lab_package/test_distribution_manifest.py +++ b/scripts/codex_lab_package/test_distribution_manifest.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 from pathlib import Path +from zipfile import ZipFile +from zipfile import ZipInfo import shutil +import stat import subprocess import sys import tempfile @@ -10,23 +13,25 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from codex_lab_package.distribution_manifest import APP_ZIP +from codex_lab_package.distribution_manifest import ENGINE_ZIP from codex_lab_package.distribution_manifest import SHIM_ZIP from codex_lab_package.distribution_manifest import build_manifest from codex_lab_package.distribution_manifest import read_sha256sums from codex_lab_package.distribution_manifest import sha256_file +from codex_lab_package.distribution_manifest import sha256_zip_member from codex_lab_package.distribution_manifest import validate_manifest +from codex_lab_package.engine_contract import ENGINE_SIGNING_IDENTIFIER +from codex_lab_package.engine_contract import ENGINE_TEAM_IDENTIFIER +from codex_lab_package.engine_contract import REQUIRED_ENGINE_ENTITLEMENTS class DistributionManifestTest(unittest.TestCase): def test_builds_and_validates_distribution_manifest(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) + app_zip, shim_zip, engine_zip = _create_fixtures(dist_dir) sha256sums = dist_dir / "SHA256SUMS" - _write_sha256sums(sha256sums, app_zip, shim_zip) + _write_sha256sums(sha256sums, app_zip, shim_zip, engine_zip) manifest = build_manifest( dist_dir=dist_dir, @@ -41,6 +46,7 @@ def test_builds_and_validates_distribution_manifest(self) -> None: release_tag="codex-lab-v1.2.3-lab.42", download_base_url="https://github.com/cbusillo/codex-lab/releases/download/codex-lab-v1.2.3-lab.42/", generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) self.assertEqual(manifest["version"], "1.2.3") @@ -48,7 +54,7 @@ def test_builds_and_validates_distribution_manifest(self) -> None: self.assertEqual(manifest["product"], "codex-lab") self.assertEqual(manifest["channel"], "lab") self.assertEqual(manifest["platform"], "aarch64-apple-darwin") - self.assertEqual(manifest["schemaVersion"], 1) + self.assertEqual(manifest["schemaVersion"], 2) self.assertEqual(manifest["source"]["commit"], "abc123") self.assertEqual(manifest["release"], {"tag": "codex-lab-v1.2.3-lab.42"}) self.assertEqual( @@ -105,6 +111,32 @@ def test_builds_and_validates_distribution_manifest(self) -> None: "sizeBytes": shim_zip.stat().st_size, }, ) + self.assertEqual( + manifest["artifacts"]["engineZip"], + { + "archiveRoot": "codex", + "description": "Individually signed managed engine pinned by the Codex Lab supervisor.", + "fileName": ENGINE_ZIP, + "downloadUrl": "https://github.com/cbusillo/codex-lab/releases/download/codex-lab-v1.2.3-lab.42/" + + ENGINE_ZIP, + "notarized": False, + "sha256": sha256_file(engine_zip), + "signed": True, + "sizeBytes": engine_zip.stat().st_size, + }, + ) + self.assertEqual( + manifest["managedEngine"], + { + "artifactRole": "engineZip", + "requiredEntitlements": list(REQUIRED_ENGINE_ENTITLEMENTS), + "sha256": sha256_zip_member(engine_zip, "codex"), + "signingIdentifier": ENGINE_SIGNING_IDENTIFIER, + "sourceCommit": "abc123", + "teamIdentifier": ENGINE_TEAM_IDENTIFIER, + "version": "1.2.3", + }, + ) validate_manifest( manifest, dist_dir=dist_dir, @@ -130,14 +162,11 @@ def test_rejects_checksum_manifest_drift(self) -> None: with self.assertRaisesRegex(ValueError, "artifact mismatch"): read_sha256sums(sha256sums) - def test_rejects_signed_artifacts_until_signing_is_real(self) -> None: + def test_rejects_signed_app_artifact(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -155,14 +184,36 @@ def test_rejects_signed_artifacts_until_signing_is_real(self) -> None: with self.assertRaisesRegex(ValueError, "unsigned and not notarized"): validate_manifest(manifest) + def test_published_release_requires_signed_engine(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + dist_dir = Path(temp_dir) + _create_fixtures(dist_dir) + manifest = build_manifest( + dist_dir=dist_dir, + checksums=_checksums(dist_dir), + version="1.2.3", + bundle_version="42", + commit="abc123", + repository="cbusillo/codex-lab", + workflow="codex-lab-release", + run_id="100", + run_attempt="2", + release_tag="codex-lab-v1.2.3", + download_base_url="https://example.invalid/downloads", + generated_at="2026-06-07T00:00:00Z", + ) + + with self.assertRaisesRegex( + ValueError, + "signed managed engine", + ): + validate_manifest(manifest) + def test_rejects_invalid_download_url(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -185,11 +236,8 @@ def test_rejects_invalid_download_url(self) -> None: def test_rejects_download_url_for_wrong_release(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -203,6 +251,7 @@ def test_rejects_download_url_for_wrong_release(self) -> None: release_tag="codex-lab-v1.2.3", download_base_url="https://github.com/cbusillo/codex-lab/releases/download/codex-lab-v1.2.3", generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) manifest["artifacts"]["appZip"]["downloadUrl"] = ( "https://github.com/cbusillo/codex-lab/releases/download/codex-lab-v9.9.9/" @@ -215,11 +264,8 @@ def test_rejects_download_url_for_wrong_release(self) -> None: def test_rejects_release_without_download_urls(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -231,6 +277,7 @@ def test_rejects_release_without_download_urls(self) -> None: run_id="100", run_attempt="2", generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) manifest["release"] = {"tag": "codex-lab-v1.2.3"} @@ -240,11 +287,8 @@ def test_rejects_release_without_download_urls(self) -> None: def test_rejects_partial_release_download_urls(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -258,6 +302,7 @@ def test_rejects_partial_release_download_urls(self) -> None: release_tag="codex-lab-v1.2.3", download_base_url="https://example.invalid/downloads", generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) del manifest["artifacts"]["shimZip"]["downloadUrl"] @@ -267,11 +312,8 @@ def test_rejects_partial_release_download_urls(self) -> None: def test_requires_release_tag_with_download_base_url(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) with self.assertRaisesRegex(ValueError, "provided together"): build_manifest( @@ -306,11 +348,8 @@ def test_requires_release_tag_with_download_base_url(self) -> None: def test_rejects_malformed_release_tag(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -324,6 +363,7 @@ def test_rejects_malformed_release_tag(self) -> None: release_tag="codex-lab-v1.2.3", download_base_url="https://example.invalid/downloads", generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) manifest["release"]["tag"] = "not a codex lab release tag" @@ -333,11 +373,8 @@ def test_rejects_malformed_release_tag(self) -> None: def test_rejects_release_tag_version_drift(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dist_dir = Path(temp_dir) - app_zip = dist_dir / APP_ZIP - shim_zip = dist_dir / SHIM_ZIP - _copy_fixture(app_zip) - _copy_fixture(shim_zip) - checksums = {APP_ZIP: sha256_file(app_zip), SHIM_ZIP: sha256_file(shim_zip)} + _create_fixtures(dist_dir) + checksums = _checksums(dist_dir) manifest = build_manifest( dist_dir=dist_dir, checksums=checksums, @@ -351,6 +388,7 @@ def test_rejects_release_tag_version_drift(self) -> None: release_tag="codex-lab-v9.9.9", download_base_url="https://example.invalid/downloads", generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) with self.assertRaisesRegex(ValueError, "does not match"): @@ -361,17 +399,44 @@ def _copy_fixture(dest: Path) -> None: shutil.copyfile(__file__, dest) -def _write_sha256sums(path: Path, app_zip: Path, shim_zip: Path) -> None: +def _create_fixtures(dist_dir: Path) -> tuple[Path, Path, Path]: + app_zip = dist_dir / APP_ZIP + shim_zip = dist_dir / SHIM_ZIP + engine_zip = dist_dir / ENGINE_ZIP + _copy_fixture(app_zip) + _copy_fixture(shim_zip) + engine_info = ZipInfo("codex") + engine_info.external_attr = (stat.S_IFREG | 0o755) << 16 + with ZipFile(engine_zip, "w") as archive: + archive.writestr(engine_info, b"signed engine fixture") + return app_zip, shim_zip, engine_zip + + +def _checksums(dist_dir: Path) -> dict[str, str]: + return { + file_name: sha256_file(dist_dir / file_name) + for file_name in (APP_ZIP, SHIM_ZIP, ENGINE_ZIP) + } + + +def _write_sha256sums( + path: Path, + app_zip: Path, + shim_zip: Path, + engine_zip: Path, +) -> None: subprocess.run( [ "/bin/sh", "-c", - 'printf \'%s %s\\n%s %s\\n\' "$1" "$2" "$3" "$4" > "$5"', + 'printf \'%s %s\\n%s %s\\n%s %s\\n\' "$1" "$2" "$3" "$4" "$5" "$6" > "$7"', "sh", sha256_file(app_zip), APP_ZIP, sha256_file(shim_zip), SHIM_ZIP, + sha256_file(engine_zip), + ENGINE_ZIP, str(path), ], check=True, diff --git a/scripts/codex_lab_package/test_installer.py b/scripts/codex_lab_package/test_installer.py index a00f5b91a4c..04b449cdf76 100644 --- a/scripts/codex_lab_package/test_installer.py +++ b/scripts/codex_lab_package/test_installer.py @@ -21,11 +21,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from codex_lab_package.distribution_manifest import APP_ZIP +from codex_lab_package.distribution_manifest import ENGINE_ZIP from codex_lab_package.distribution_manifest import MANIFEST_NAME from codex_lab_package.distribution_manifest import SHIM_ZIP from codex_lab_package.distribution_manifest import build_manifest from codex_lab_package.distribution_manifest import sha256_file from codex_lab_package.installer import DOWNLOAD_TIMEOUT_SECONDS +from codex_lab_package.installer import CodexLabRollbackError from codex_lab_package.installer import CodexLabReleaseSummary from codex_lab_package.installer import CodexLabUpdateError from codex_lab_package.installer import check_for_update @@ -34,6 +36,7 @@ from codex_lab_package.installer import download_url from codex_lab_package.installer import github_releases_url from codex_lab_package.installer import install_from_manifest_url +from codex_lab_package.installer import EngineProvisioningOperations from codex_lab_package.installer import latest_release_tag from codex_lab_package.installer import manifest_url_for_latest_release from codex_lab_package.installer import manifest_url_for_release_tag @@ -41,12 +44,57 @@ from codex_lab_package.installer import replace_path from codex_lab_package.installer import select_latest_lab_release_tag from codex_lab_package.installer import update_from_latest_release +from codex_lab_package.installer import uninstall_codex_lab +from codex_lab_package.engine_contract import ENGINE_SIGNING_IDENTIFIER +from codex_lab_package.engine_contract import ENGINE_TEAM_IDENTIFIER from codex_lab_package.layout import CodexLabAppOptions from codex_lab_package.layout import build_codex_lab_app +from codex_lab_package.supervisor import EngineIdentity +from codex_lab_package.supervisor import SupervisorPaths import install_codex_lab as install_codex_lab_cli class CodexLabInstallerTest(unittest.TestCase): + def setUp(self) -> None: + self.supervisor_temp_dir = tempfile.TemporaryDirectory() + supervisor_root = Path(self.supervisor_temp_dir.name) + self.supervisor_paths = SupervisorPaths( + lab_home=supervisor_root / "lab-home", + launch_agents_dir=supervisor_root / "LaunchAgents", + ) + self.supervisor_installs: list[tuple[SupervisorPaths, object]] = [] + self.supervisor_uninstalls: list[SupervisorPaths] = [] + engine_operations = EngineProvisioningOperations( + inspect=fake_inspect_engine, + install_supervisor=self._install_supervisor, + uninstall_supervisor=self._uninstall_supervisor, + ) + self.default_paths_patch = mock.patch( + "codex_lab_package.installer.default_supervisor_paths", + return_value=self.supervisor_paths, + ) + self.default_operations_patch = mock.patch( + "codex_lab_package.installer.DEFAULT_ENGINE_OPERATIONS", + engine_operations, + ) + self.default_paths_patch.start() + self.default_operations_patch.start() + self.addCleanup(self.default_paths_patch.stop) + self.addCleanup(self.default_operations_patch.stop) + self.addCleanup(self.supervisor_temp_dir.cleanup) + + def _install_supervisor(self, paths: SupervisorPaths, release: object) -> None: + self.supervisor_installs.append((paths, release)) + paths.runner.parent.mkdir(parents=True, exist_ok=True) + paths.launch_agents_dir.mkdir(parents=True, exist_ok=True) + write_file(paths.runner, "supervisor runner") + write_file(paths.plist, "launch agent") + + def _uninstall_supervisor(self, paths: SupervisorPaths) -> None: + self.supervisor_uninstalls.append(paths) + paths.plist.unlink(missing_ok=True) + shutil.rmtree(paths.supervisor_dir, ignore_errors=True) + def test_download_url_uses_timeout(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: dest = Path(temp_dir) / "artifact.zip" @@ -227,6 +275,17 @@ def test_installs_verified_release_artifacts(self) -> None: result.shim_path, (install_root / "bin").resolve(strict=False) / "codex-lab", ) + self.assertEqual(result.engine_path, self.supervisor_paths.managed_cli) + self.assertTrue(result.engine_path.is_file()) + self.assertEqual(result.supervisor_label, self.supervisor_paths.label) + self.assertEqual(len(self.supervisor_installs), 1) + installed_release = self.supervisor_installs[0][1] + self.assertEqual( + installed_release.sha256, + release.manifest["managedEngine"]["sha256"], + ) + self.assertEqual(installed_release.source_commit, "abc123") + self.assertEqual(installed_release.version, "1.2.3") assert result.shim_path is not None shim = result.shim_path.read_text(encoding="utf-8") @@ -241,8 +300,17 @@ def test_installs_verified_release_artifacts(self) -> None: state = json.loads(result.state_path.read_text(encoding="utf-8")) self.assertEqual(state["appPath"], str(result.app_dir)) + self.assertEqual(state["enginePath"], str(result.engine_path)) + self.assertEqual(state["labHome"], str(self.supervisor_paths.lab_home)) + self.assertEqual( + state["launchAgentsDir"], + str(self.supervisor_paths.launch_agents_dir), + ) + self.assertEqual(state["listenHost"], self.supervisor_paths.listen_host) + self.assertEqual(state["listenPort"], self.supervisor_paths.listen_port) self.assertEqual(state["releaseTag"], "codex-lab-v1.2.3-lab.1") self.assertEqual(state["shimPath"], str(result.shim_path)) + self.assertEqual(state["supervisorLabel"], self.supervisor_paths.label) self.assertEqual(state["version"], "1.2.3") def test_reads_install_state(self) -> None: @@ -261,12 +329,248 @@ def test_reads_install_state(self) -> None: self.assertEqual(status.app_path, result.app_dir) self.assertEqual(status.bundle_version, "42") + self.assertEqual(status.engine_path, result.engine_path) + self.assertEqual(status.lab_home, self.supervisor_paths.lab_home) + self.assertEqual( + status.launch_agents_dir, + self.supervisor_paths.launch_agents_dir, + ) + self.assertEqual(status.listen_host, self.supervisor_paths.listen_host) + self.assertEqual(status.listen_port, self.supervisor_paths.listen_port) self.assertEqual(status.release_tag, "codex-lab-v1.2.3-lab.1") self.assertEqual(status.shim_path, result.shim_path) self.assertEqual(status.source_commit, "abc123") self.assertEqual(status.state_path, result.state_path) + self.assertEqual(status.supervisor_label, self.supervisor_paths.label) self.assertEqual(status.version, "1.2.3") + def test_rejects_unsigned_engine_before_installing(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + release = build_test_release(root) + + def reject_unsigned_engine(path: Path) -> EngineIdentity: + raise ValueError(f"managed engine is unsigned: {path}") + + operations = EngineProvisioningOperations( + inspect=reject_unsigned_engine, + install_supervisor=self._install_supervisor, + uninstall_supervisor=self._uninstall_supervisor, + ) + with self.assertRaisesRegex(ValueError, "unsigned"): + install_from_manifest_url( + release.manifest_url, + app_dir=root / "install" / "Codex Lab.app", + shim_dir=root / "install" / "bin", + state_path=root / "install" / "install-state.json", + download=release.download, + engine_operations=operations, + ) + + self.assertFalse((root / "install").exists()) + self.assertFalse(self.supervisor_paths.managed_cli.exists()) + + def test_supervisor_failure_rolls_back_app_shim_engine_and_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + old_release = build_test_release(root / "old") + new_release = build_test_release( + root / "new", + release_tag="codex-lab-v1.2.4-lab.1", + version="1.2.4", + bundle_version="43", + commit="def456", + ) + install_root = root / "install" + old_result = install_from_manifest_url( + old_release.manifest_url, + app_dir=install_root / "Codex Lab.app", + shim_dir=install_root / "bin", + state_path=install_root / "install-state.json", + download=old_release.download, + ) + old_engine = old_result.engine_path.read_bytes() + old_state = old_result.state_path.read_bytes() + assert old_result.shim_path is not None + old_shim = old_result.shim_path.read_bytes() + write_file(old_result.app_dir / "old-marker", "old app") + + def fail_supervisor( + paths: SupervisorPaths, + release: object, + ) -> None: + raise RuntimeError(f"supervisor failed for {paths.label}: {release}") + + operations = EngineProvisioningOperations( + inspect=fake_inspect_engine, + install_supervisor=fail_supervisor, + uninstall_supervisor=self._uninstall_supervisor, + ) + with self.assertRaisesRegex(RuntimeError, "supervisor failed"): + install_from_manifest_url( + new_release.manifest_url, + app_dir=old_result.app_dir, + shim_dir=old_result.shim_path.parent, + state_path=old_result.state_path, + supervisor_paths=self.supervisor_paths, + force=True, + download=new_release.download, + engine_operations=operations, + ) + + self.assertTrue((old_result.app_dir / "old-marker").is_file()) + self.assertEqual(old_result.shim_path.read_bytes(), old_shim) + self.assertEqual(old_result.engine_path.read_bytes(), old_engine) + self.assertEqual(old_result.state_path.read_bytes(), old_state) + + def test_force_update_refuses_recorded_unmanaged_app_target(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + old_release = build_test_release(root / "old") + new_release = build_test_release( + root / "new", + release_tag="codex-lab-v1.2.4-lab.1", + version="1.2.4", + bundle_version="43", + commit="def456", + ) + old_result = install_from_manifest_url( + old_release.manifest_url, + app_dir=root / "install" / "Codex Lab.app", + shim_dir=root / "install" / "bin", + state_path=root / "install" / "install-state.json", + download=old_release.download, + ) + old_engine = old_result.engine_path.read_bytes() + old_state = old_result.state_path.read_bytes() + shutil.rmtree(old_result.app_dir) + old_result.app_dir.mkdir() + write_file(old_result.app_dir / "unmanaged-marker", "do not replace") + + with self.assertRaisesRegex(ValueError, "not a managed Codex Lab"): + install_from_manifest_url( + new_release.manifest_url, + app_dir=old_result.app_dir, + shim_dir=old_result.shim_path.parent, + state_path=old_result.state_path, + supervisor_paths=self.supervisor_paths, + force=True, + download=new_release.download, + ) + + self.assertTrue((old_result.app_dir / "unmanaged-marker").is_file()) + self.assertEqual(old_result.engine_path.read_bytes(), old_engine) + self.assertEqual(old_result.state_path.read_bytes(), old_state) + self.assertEqual(len(self.supervisor_installs), 1) + + def test_reports_file_rollback_failure(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + release = build_test_release(root) + + def fail_supervisor( + paths: SupervisorPaths, + managed_engine: object, + ) -> None: + raise RuntimeError( + f"supervisor provisioning failed: {paths.label} {managed_engine}" + ) + + operations = EngineProvisioningOperations( + inspect=fake_inspect_engine, + install_supervisor=fail_supervisor, + uninstall_supervisor=self._uninstall_supervisor, + ) + with ( + mock.patch( + "codex_lab_package.installer.rollback_replacements", + side_effect=OSError("rollback exploded"), + ), + self.assertRaisesRegex( + CodexLabRollbackError, + "rollback did not complete: rollback exploded", + ) as raised, + ): + install_from_manifest_url( + release.manifest_url, + app_dir=root / "install" / "Codex Lab.app", + shim_dir=root / "install" / "bin", + state_path=root / "install" / "install-state.json", + download=release.download, + engine_operations=operations, + ) + + self.assertIsInstance(raised.exception.__cause__, RuntimeError) + + def test_uninstall_restores_preinstaller_engine(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + release = build_test_release(root) + prior_engine = b"prior managed engine" + self.supervisor_paths.managed_cli.parent.mkdir(parents=True) + self.supervisor_paths.managed_cli.write_bytes(prior_engine) + os.chmod(self.supervisor_paths.managed_cli, 0o755) + + result = install_from_manifest_url( + release.manifest_url, + app_dir=root / "install" / "Codex Lab.app", + shim_dir=root / "install" / "bin", + state_path=root / "install" / "install-state.json", + force=True, + download=release.download, + ) + status = read_install_state(result.state_path) + assert status.engine_backup_path is not None + self.assertEqual(status.engine_backup_path.read_bytes(), prior_engine) + + uninstall = uninstall_codex_lab(state_path=result.state_path) + + self.assertFalse(result.app_dir.exists()) + assert result.shim_path is not None + self.assertFalse(result.shim_path.exists()) + self.assertFalse(result.state_path.exists()) + self.assertEqual(result.engine_path.read_bytes(), prior_engine) + self.assertEqual(uninstall.restored_engine_path, result.engine_path) + self.assertEqual(self.supervisor_uninstalls, [self.supervisor_paths]) + self.assertFalse(self.supervisor_paths.runner.exists()) + self.assertFalse(self.supervisor_paths.plist.exists()) + + def test_uninstall_failure_restores_install_and_supervisor(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + release = build_test_release(root) + result = install_from_manifest_url( + release.manifest_url, + app_dir=root / "install" / "Codex Lab.app", + shim_dir=root / "install" / "bin", + state_path=root / "install" / "install-state.json", + download=release.download, + ) + installed_engine = result.engine_path.read_bytes() + + def fail_uninstall(paths: SupervisorPaths) -> None: + raise RuntimeError(f"could not stop {paths.label}") + + operations = EngineProvisioningOperations( + inspect=fake_inspect_engine, + install_supervisor=self._install_supervisor, + uninstall_supervisor=fail_uninstall, + ) + with self.assertRaisesRegex(RuntimeError, "could not stop"): + uninstall_codex_lab( + state_path=result.state_path, + engine_operations=operations, + ) + + self.assertTrue(result.app_dir.is_dir()) + assert result.shim_path is not None + self.assertTrue(result.shim_path.is_file()) + self.assertTrue(result.state_path.is_file()) + self.assertEqual(result.engine_path.read_bytes(), installed_engine) + self.assertEqual(len(self.supervisor_installs), 2) + self.assertTrue(self.supervisor_paths.runner.is_file()) + self.assertTrue(self.supervisor_paths.plist.is_file()) + def test_status_command_prints_install_state(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -299,6 +603,8 @@ def test_status_command_prints_install_state(self) -> None: "Source commit: abc123\n" f"App: {result.app_dir}\n" f"Shim: {result.shim_path}\n" + f"Engine: {result.engine_path}\n" + f"Supervisor: {result.supervisor_label}\n" f"State: {result.state_path}\n", ) @@ -309,7 +615,7 @@ def test_status_command_reports_missing_install_state(self) -> None: state_path.parent.resolve(strict=False) / state_path.name ) - for command in ["--status", "--check", "--update"]: + for command in ["--status", "--check", "--update", "--uninstall"]: with self.subTest(command=command): completed = subprocess.run( [ @@ -945,7 +1251,8 @@ def test_rejects_unsafe_zip_member(self) -> None: write_file( release.dist_dir / "SHA256SUMS", f"{sha256_file(release.dist_dir / APP_ZIP)} {APP_ZIP}\n" - f"{sha256_file(release.dist_dir / SHIM_ZIP)} {SHIM_ZIP}\n", + f"{sha256_file(release.dist_dir / SHIM_ZIP)} {SHIM_ZIP}\n" + f"{sha256_file(release.dist_dir / ENGINE_ZIP)} {ENGINE_ZIP}\n", ) with self.assertRaisesRegex(ValueError, "Unsafe zip member"): @@ -1115,7 +1422,7 @@ def test_preflights_all_targets_before_installing(self) -> None: ) self.assertFalse(app_dir.exists()) - def test_rolls_back_replacements_when_state_write_fails(self) -> None: + def test_preflights_state_parent_file_before_installing(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) release = build_test_release(root) @@ -1128,7 +1435,7 @@ def test_rolls_back_replacements_when_state_write_fails(self) -> None: write_file(shim_dir / "codex-lab", "old shim") write_file(install_root / "state-parent", "not a directory") - with self.assertRaises(OSError): + with self.assertRaises(NotADirectoryError): install_from_manifest_url( release.manifest_url, app_dir=app_dir, @@ -1142,6 +1449,51 @@ def test_rolls_back_replacements_when_state_write_fails(self) -> None: self.assertEqual( (shim_dir / "codex-lab").read_text(encoding="utf-8"), "old shim" ) + self.assertFalse(self.supervisor_paths.managed_cli.exists()) + + def test_post_install_smoke_failure_restores_prior_engine_and_files(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + release = build_test_release(root) + install_root = root / "install" + app_dir = install_root / "Codex Lab.app" + shim_dir = install_root / "bin" + app_dir.mkdir(parents=True) + shim_dir.mkdir(parents=True) + write_file(app_dir / "old-app-marker", "old app") + write_file(shim_dir / "codex-lab", "old shim") + self.supervisor_paths.managed_cli.parent.mkdir(parents=True) + prior_engine = b"prior managed engine" + self.supervisor_paths.managed_cli.write_bytes(prior_engine) + os.chmod(self.supervisor_paths.managed_cli, 0o755) + + with ( + mock.patch( + "codex_lab_package.installer.smoke_check", + side_effect=[None, RuntimeError("installed smoke failed")], + ), + self.assertRaisesRegex(RuntimeError, "installed smoke failed"), + ): + install_from_manifest_url( + release.manifest_url, + app_dir=app_dir, + shim_dir=shim_dir, + state_path=install_root / "state.json", + force=True, + download=release.download, + ) + + self.assertTrue((app_dir / "old-app-marker").is_file()) + self.assertEqual( + (shim_dir / "codex-lab").read_text(encoding="utf-8"), + "old shim", + ) + self.assertEqual( + self.supervisor_paths.managed_cli.read_bytes(), + prior_engine, + ) + self.assertFalse((install_root / "state.json").exists()) + self.assertEqual(self.supervisor_installs, []) def test_replace_path_restores_backup_after_partial_move_failure(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -1257,6 +1609,19 @@ def run_install_cli(*args: str) -> tuple[int, str, str]: return exit_code, stdout.getvalue(), stderr.getvalue() +def fake_inspect_engine(path: Path) -> EngineIdentity: + metadata = json.loads(path.read_text(encoding="utf-8")) + return EngineIdentity( + build_channel="release", + build_profile="release", + sha256=sha256_file(path), + signing_identifier=ENGINE_SIGNING_IDENTIFIER, + source_commit=metadata["sourceCommit"], + team_identifier=ENGINE_TEAM_IDENTIFIER, + version=metadata["version"], + ) + + def build_test_release( root: Path, *, @@ -1285,10 +1650,19 @@ def build_test_release( assert result.shim_path is not None zip_tree(result.app_dir, dist_dir / APP_ZIP) zip_tree(result.shim_path, dist_dir / SHIM_ZIP, arcname=Path("bin/codex-lab")) + engine_bin = root / "engine" / "codex" + engine_bin.parent.mkdir() + write_file( + engine_bin, + json.dumps({"sourceCommit": commit, "version": version}), + ) + os.chmod(engine_bin, 0o755) + zip_tree(engine_bin, dist_dir / ENGINE_ZIP, arcname=Path("codex")) write_file( dist_dir / "SHA256SUMS", f"{sha256_file(dist_dir / APP_ZIP)} {APP_ZIP}\n" - f"{sha256_file(dist_dir / SHIM_ZIP)} {SHIM_ZIP}\n", + f"{sha256_file(dist_dir / SHIM_ZIP)} {SHIM_ZIP}\n" + f"{sha256_file(dist_dir / ENGINE_ZIP)} {ENGINE_ZIP}\n", ) manifest_url = manifest_url_for_release_tag(release_tag) @@ -1297,6 +1671,7 @@ def build_test_release( checksums={ APP_ZIP: sha256_file(dist_dir / APP_ZIP), SHIM_ZIP: sha256_file(dist_dir / SHIM_ZIP), + ENGINE_ZIP: sha256_file(dist_dir / ENGINE_ZIP), }, version=version, bundle_version=bundle_version, @@ -1308,6 +1683,7 @@ def build_test_release( release_tag=release_tag, download_base_url=manifest_url.rsplit("/", 1)[0], generated_at="2026-06-07T00:00:00Z", + engine_signed=True, ) return TestRelease(dist_dir, manifest_url, manifest) diff --git a/scripts/install_codex_lab.py b/scripts/install_codex_lab.py index ed28d3cc73a..aa69e38a562 100755 --- a/scripts/install_codex_lab.py +++ b/scripts/install_codex_lab.py @@ -14,6 +14,7 @@ from codex_lab_package.installer import DEFAULT_SHIM_DIR from codex_lab_package.installer import DEFAULT_STATE_PATH from codex_lab_package.installer import CodexLabInstallStateError +from codex_lab_package.installer import CodexLabRollbackError from codex_lab_package.installer import CodexLabUpdateError from codex_lab_package.installer import check_for_update from codex_lab_package.installer import install_from_manifest_url @@ -21,11 +22,12 @@ from codex_lab_package.installer import manifest_url_for_release_tag from codex_lab_package.installer import read_install_state from codex_lab_package.installer import update_from_latest_release +from codex_lab_package.installer import uninstall_codex_lab def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Install Codex Lab app and CLI shim from a release manifest.", + description="Install Codex Lab app, CLI shim, signed engine, and supervisor.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) source = parser.add_mutually_exclusive_group(required=True) @@ -53,6 +55,11 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Update the recorded Codex Lab install to the newest release.", ) + source.add_argument( + "--uninstall", + action="store_true", + help="Remove the recorded Codex Lab install and restore a prior managed engine.", + ) parser.add_argument( "--repository", default=DEFAULT_REPOSITORY, @@ -84,7 +91,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--force", action="store_true", - help="Replace an existing app bundle or shim after verification succeeds.", + help="Replace existing app, shim, or engine paths after verification succeeds.", ) return parser.parse_args() @@ -105,9 +112,31 @@ def main() -> int: print(f"Shim: {status.shim_path}") else: print("Shim: not installed") + if status.engine_path is not None: + print(f"Engine: {status.engine_path}") + if status.supervisor_label is not None: + print(f"Supervisor: {status.supervisor_label}") print(f"State: {status.state_path}") return 0 + if args.uninstall: + try: + result = uninstall_codex_lab(state_path=args.state_path) + except CodexLabInstallStateError as exc: + return print_install_state_error(exc) + except (CodexLabRollbackError, OSError, ValueError) as exc: + return print_command_error("Could not uninstall Codex Lab", exc) + print("Uninstalled Codex Lab") + print(f"App: {result.app_path}") + if result.shim_path is not None: + print(f"Shim: {result.shim_path}") + if result.engine_path is not None: + print(f"Engine: {result.engine_path}") + if result.restored_engine_path is not None: + print(f"Restored prior engine: {result.restored_engine_path}") + print(f"State: {result.state_path}") + return 0 + if args.check: try: check = check_for_update( @@ -137,6 +166,7 @@ def main() -> int: return print_install_state_error(exc) except ( CodexLabUpdateError, + CodexLabRollbackError, OSError, subprocess.CalledProcessError, ValueError, @@ -176,12 +206,15 @@ def main() -> int: except ( OSError, subprocess.CalledProcessError, + CodexLabRollbackError, ValueError, zipfile.BadZipFile, ) as exc: return print_command_error("Could not install Codex Lab", exc) print(f"Installed Codex Lab {result.version} from {result.release_tag}") print(f"App: {result.app_dir}") + print(f"Engine: {result.engine_path}") + print(f"Supervisor: {result.supervisor_label}") if result.shim_path is not None: print(f"Shim: {result.shim_path}") print(f"State: {result.state_path}")