diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ac6c2..1d19403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,12 +176,11 @@ login` from silently writing a plaintext refresh token to the alternate directory, including a workspace path that syncs back to the host. -- **Install Codex's complete runtime package** (#442) — Recent Codex releases - use a companion `codex-code-mode-host` executable, but coop installed only - the raw `codex` binary, causing Code Mode to fail closed at startup. Image - builds and `coop agent update --codex` now verify and install the upstream - package with its host and runtime resources intact, root-owned and behind a - shared current-release link. +- **Install the full native Codex package** (#442) — Image provisioning and + `coop agent update --codex` use OpenAI's native installer, preserving bundled + tools and upstream setup. The guest user owns the installation and can run + `codex update` directly. `/usr/local/bin/codex` remains a compatibility link, + and host-driven updates migrate older direct-binary installations. - **`install.sh` and `coop update` verify provenance without a GitHub credential** (#421) — Verification ran `gh attestation verify --repo diff --git a/docs/codex-integration.md b/docs/codex-integration.md index 1d4ba3d..2285122 100644 --- a/docs/codex-integration.md +++ b/docs/codex-integration.md @@ -265,14 +265,28 @@ This skips the guest bootstrap sequence entirely. The VM still includes both CLI ## Updating Codex -Codex is installed "latest at build time" during `coop setup` and has no background updater, so it stays at that version until the image is rebuilt. Unlike Claude Code, it does not refresh itself. coop installs the complete upstream package, including the `codex-code-mode-host` companion and runtime resources, under `/usr/local/lib/codex`; stable entrypoints live in `/usr/local/bin`. To update Codex in a running VM without rebuilding the image: +`coop setup` uses [OpenAI's native installer](https://developers.openai.com/codex/cli/) +to install the full Codex package, including bundled tools, as the configured +guest user. The installer manages its package under the user's home directory +and exposes `~/.local/bin/codex`. coop retains `/usr/local/bin/codex` as a +compatibility link for existing wrappers and scripts. + +To update directly inside the VM, run `codex update` as the guest user; sudo +is not required. To update from the host: ```bash coop agent update --codex # update Codex to the latest release coop agent update --check # report installed vs. latest, change nothing ``` -This re-runs coop's own Codex installer inside the guest as root, verifies the published package checksums, and switches the CLI and code-mode host through the same current-package link. To refresh the golden image so new VMs ship the latest Codex, rebuild it with `coop setup --rebuild`. See [`agent update`](commands.md#agent-update). +`coop agent update --codex` re-runs the native installer as the guest user and +refreshes the compatibility link. It also migrates older direct-binary +installations without rebuilding the VM or replacing the user's Codex config. +A profile-provided `/usr/local/bin/codex` is preserved during image setup; +an explicit update replaces it with the native installation. + +Updates affect that VM. To refresh the golden image for new VMs, run +`coop setup --rebuild`. See [`agent update`](commands.md#agent-update). ## Local model support diff --git a/docs/commands.md b/docs/commands.md index c86d21c..3a15e66 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -489,11 +489,11 @@ coop agent update [NAME] [--claude] [--codex] [--check] [-y] With no agent flag, both agents are updated; passing both `--claude` and `--codex` is the same as passing neither. The VM must be running. -Codex has no background updater, so `coop agent update --codex` re-runs coop's -own installer inside the guest as root. It installs the complete upstream -package, verifies its published checksums, and switches the CLI and code-mode -host through the same current-release link. -Claude Code already auto-updates in the background; +`coop agent update --codex` re-runs OpenAI's native installer as the guest +user, including when migrating an older direct-binary installation. The full +package stays in the user's home directory, with `/usr/local/bin/codex` linked +to `~/.local/bin/codex`. The guest user can also run `codex update` directly +without sudo. Claude Code already auto-updates in the background; `coop agent update --claude` runs `claude update` now, synchronously — a convenience rather than a fix. diff --git a/docs/images-and-profiles.md b/docs/images-and-profiles.md index b0b38c4..e6bec99 100644 --- a/docs/images-and-profiles.md +++ b/docs/images-and-profiles.md @@ -26,9 +26,11 @@ Every template installs these packages regardless of profile selection. **Claude Code CLI:** installed via the native installer during the template build. -**Codex CLI:** installed as a complete package during the template build. The -package contains the CLI, its code-mode host, and bundled runtime resources; -stable entrypoints under `/usr/local/bin` share one current-package link. +**Codex CLI:** installed with OpenAI's native installer as the guest user during +the template build. The full package, including bundled tools, stays under the +user's home directory. `~/.local/bin/codex` is the native launcher; +`/usr/local/bin/codex` is a compatibility link. The guest user can run +`codex update` directly without sudo. The image also installs `/usr/local/bin/codex-account`, a wrapper used by `[codex] auth = "chatgpt"` to run Codex with a D-Bus session and guest Linux Secret Service storage. The wrapper and its three supporting packages @@ -40,7 +42,7 @@ Codex, so it costs nothing at run time. Both agents are installed at whatever version was current when the template was built, and that version is not part of the staleness hash — a plain `coop setup` does not refresh them. There are two ways to get newer agents: -- **A live instance:** `coop agent update [--claude] [--codex]` updates the binaries inside a running VM in place (see [`agent update`](commands.md#agent-update)). Claude Code also auto-updates itself in the background; Codex does not, so it is the one that typically needs this. +- **A live instance:** run `codex update` inside the VM, or `coop agent update [--claude] [--codex]` from the host (see [`agent update`](commands.md#agent-update)). Claude Code also auto-updates itself in the background. - **The golden image:** `coop setup --rebuild` rebuilds the template from a fresh base, so every new instance ships the latest agents. ## Built-in profiles diff --git a/docs/testing.md b/docs/testing.md index cf0783f..4183658 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -47,12 +47,19 @@ When adding new features, consider whether they should be covered here. New commands or guest-visible changes are good candidates for a new test phase. Run `python3 tests/test-integration-probes.py` for host-only regression tests -of address discovery, ping result handling, and bounded HTTP retries. These -use a temporary loopback HTTP server and require Python 3, Bash, and curl; +of Codex installer failure propagation, update/config assertions, address +discovery, ping result handling, and bounded HTTP retries. These use a +temporary loopback HTTP server and require Python 3, Bash, and curl; Linux CI runs them. The full VM suite additionally checks these probes against real guests. A host FORWARD policy other than ACCEPT still causes an explicit skip of the routed guest-isolation probe, since it would mask the coop rule. +The full Codex update tests install native release `0.153.0` before running +`codex update` as the guest user, and require the installed version to change. +They compare the actual `config.toml` contents across host updates, self-updates, +and migration from a profile-provided system command. Package layout and +completeness remain the native installer's responsibility. + ## Host-only bridge isolation test `./tests/run-integration.sh --full` runs the bridge isolation gate before diff --git a/docs/trust-model.md b/docs/trust-model.md index 9a74d61..0167888 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -384,6 +384,13 @@ These are deliberate and documented in [`AGENTS.md`](../AGENTS.md) / [`docs/ARCHITECTURE.md`](ARCHITECTURE.md). Don't "fix" them without understanding the rationale; do flag a change that *widens* them: +- **Native agent installers are trusted build inputs.** Provisioning fetches + Claude's installer from `claude.ai` and Codex's from + `https://chatgpt.com/codex/install.sh` over HTTPS, then runs them as the + configured guest user. On Firecracker this happens in the host-side chroot; + a chroot does not provide VM isolation. Lima provisioning and live agent + updates execute inside a VM. This build-time trust is distinct from the + untrusted guest boundary described above. - **`DOCKER_INSECURE_NO_IPTABLES_RAW=1`** in the guest. The Firecracker CI kernel lacks `iptable_raw`, so Docker 28+ can't install its raw-table "direct access filtering" rule. Without it, other hosts on the guest's LAN diff --git a/scripts/guest/codex.sh b/scripts/guest/codex.sh index ab623ed..3b8f425 100644 --- a/scripts/guest/codex.sh +++ b/scripts/guest/codex.sh @@ -1,248 +1,38 @@ -set -euo pipefail - -# Using if/else (not early `exit 0`) because this file is concatenated with -# other guest installers; an unconditional exit would skip everything after it. - -case "$(uname -m)" in - x86_64) - CODEX_TARGET="x86_64-unknown-linux-musl" - ;; - aarch64|arm64) - CODEX_TARGET="aarch64-unknown-linux-musl" - ;; - *) - echo " [guest] ERROR: Unsupported architecture for Codex CLI: $(uname -m)" >&2 - exit 1 - ;; -esac - -CODEX_ASSET="codex-package-$CODEX_TARGET.tar.gz" -CODEX_SUMS_ASSET="codex-package_SHA256SUMS" -CODEX_INSTALL_ROOT="/usr/local/lib/codex" -CODEX_RELEASES_DIR="$CODEX_INSTALL_ROOT/releases" -CODEX_BIN_LINK="" - -codex_package_is_complete() { - local package_dir="$1" - local manifest_version reported_version - - [ -x "$package_dir/bin/codex" ] \ - && [ -x "$package_dir/bin/codex-code-mode-host" ] \ - && [ -x "$package_dir/codex-path/rg" ] \ - && [ -x "$package_dir/codex-resources/bwrap" ] \ - && [ -x "$package_dir/codex-resources/zsh/bin/zsh" ] \ - && [ -f "$package_dir/codex-package.json" ] \ - && jq -e --arg target "$CODEX_TARGET" \ - '.layoutVersion == 1 and .target == $target and .entrypoint == "bin/codex"' \ - "$package_dir/codex-package.json" >/dev/null \ - || return 1 - - manifest_version=$(jq -er '.version | strings | select(length > 0)' \ - "$package_dir/codex-package.json") || return 1 - reported_version=$("$package_dir/bin/codex" --version) || return 1 - [ "$reported_version" = "codex-cli $manifest_version" ] || return 1 -} - -codex_installed_package_is_safe() { - local package_dir="$1" - local resolved_dir - - resolved_dir=$(readlink -f "$package_dir") || return 1 - - codex_package_is_complete "$package_dir" \ - && [ -z "$(find "$resolved_dir" ! -type l ! -user root -print -quit)" ] \ - && [ -z "$(find "$resolved_dir" ! -type l -perm /022 -print -quit)" ] -} - -codex_reconcile_public_entrypoints() { - local executable - - # Install the helper first and Codex last. On migration from coop's legacy - # single-binary layout, Codex becomes the commit point only after its helper - # is ready. Both links resolve through `current`, so later release switches - # update the pair together. - for executable in codex-code-mode-host codex; do - CODEX_BIN_LINK="/usr/local/bin/.$executable-$$" - rm -f -- "$CODEX_BIN_LINK" - ln -s "$CODEX_INSTALL_ROOT/current/bin/$executable" "$CODEX_BIN_LINK" - mv -Tf "$CODEX_BIN_LINK" "/usr/local/bin/$executable" - CODEX_BIN_LINK="" - done -} - -codex_current_release_sha() { - local current_target current_sha - - current_target=$(readlink -f "$CODEX_INSTALL_ROOT/current" 2>/dev/null) || return 1 - [ "$(dirname "$current_target")" = "$CODEX_RELEASES_DIR" ] || return 1 - current_sha=$(basename "$current_target") - [[ "$current_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 - printf '%s\n' "$current_sha" -} - -codex_release_has_live_executable() { - local release_dir="$1" - local proc_exe executable - - # Processes can disappear, or become unreadable, between glob expansion - # and readlink. Those ordinary /proc races must not abort an update under - # `set -e`. - for proc_exe in /proc/[0-9]*/exe; do - [ -L "$proc_exe" ] || continue - executable=$(readlink "$proc_exe" 2>/dev/null) || continue - case "$executable" in - "$release_dir"/*) return 0 ;; - esac - done - return 1 -} - -codex_gc_releases() { - local current_sha="$1" - local previous_sha="$2" - local release_dir release_sha - - for release_dir in "$CODEX_RELEASES_DIR"/*; do - # Never follow or remove a symlink. Release directories created by - # this installer have strict lowercase SHA-256 names. - [ -d "$release_dir" ] && [ ! -L "$release_dir" ] || continue - release_sha=$(basename "$release_dir") - [[ "$release_sha" =~ ^[0-9a-f]{64}$ ]] || continue - [ "$release_sha" = "$current_sha" ] && continue - [ -n "$previous_sha" ] && [ "$release_sha" = "$previous_sha" ] && continue - if codex_release_has_live_executable "$release_dir"; then - continue - fi - rm -rf -- "$release_dir" - done -} - -# COOP_FORCE_INSTALL bypasses the installed-package guard so `coop agent -# update --codex` always checks upstream. Normal setup skips only a complete, -# root-owned package; two manually copied binaries are not enough. -if [ -z "${COOP_FORCE_INSTALL:-}" ] \ - && codex_installed_package_is_safe "$CODEX_INSTALL_ROOT/current"; then - codex_reconcile_public_entrypoints - /usr/local/bin/codex --version >/dev/null - echo ' [guest] Codex CLI package already installed, skipping.' -else - echo ' [guest] Installing Codex CLI package...' - - # Serialize the complete update, including resolving the moving `latest` - # release. Otherwise a slower updater can download an older release, wait - # for a newer updater to activate, and then roll `current` backward. - install -d -m 755 "$CODEX_RELEASES_DIR" - exec 9>"$CODEX_INSTALL_ROOT/install.lock" - flock 9 - - CODEX_TMP_DIR=$(mktemp -d) - CODEX_STAGING_DIR="" - CODEX_NEXT_LINK="" - - cleanup_codex_install() { - rm -rf "$CODEX_TMP_DIR" - [ -z "$CODEX_STAGING_DIR" ] || rm -rf -- "$CODEX_STAGING_DIR" - [ -z "$CODEX_NEXT_LINK" ] || rm -f -- "$CODEX_NEXT_LINK" - [ -z "$CODEX_BIN_LINK" ] || rm -f -- "$CODEX_BIN_LINK" - } - trap cleanup_codex_install EXIT - - download_codex_file() { - local url="$1" - local output="$2" - local description="$3" - local attempt curl_exit curl_error - local retry_delay=5 - - for attempt in $(seq 1 4); do - if curl -fsSL -o "$output" "$url" 2>"$CODEX_TMP_DIR/curl.err"; then - return 0 - else - curl_exit=$? - fi - curl_error=$(cat "$CODEX_TMP_DIR/curl.err" 2>/dev/null || true) - if [ "$attempt" -eq 4 ]; then - echo " [guest] ERROR: Failed to download $description after 4 attempts." >&2 - echo " [guest] curl exit code: $curl_exit" >&2 - echo " [guest] curl error: ${curl_error:-none}" >&2 - return 1 - fi - echo " [guest] Download failed (attempt $attempt/4, curl exit $curl_exit), retrying in ${retry_delay}s..." - sleep "$retry_delay" - retry_delay=$((retry_delay * 2)) - done - } - - CODEX_SUMS_FILE="$CODEX_TMP_DIR/$CODEX_SUMS_ASSET" - CODEX_ARCHIVE="$CODEX_TMP_DIR/$CODEX_ASSET" - CODEX_PACKAGE_SHA="" - CODEX_RELEASE_VERIFIED=0 - for release_attempt in $(seq 1 4); do - download_codex_file \ - "https://github.com/openai/codex/releases/latest/download/$CODEX_SUMS_ASSET" \ - "$CODEX_SUMS_FILE" "Codex package checksum manifest" - CODEX_PACKAGE_SHA=$(awk -v asset="$CODEX_ASSET" \ - '$2 == asset && length($1) == 64 && $1 !~ /[^0-9a-fA-F]/ { print tolower($1) }' \ - "$CODEX_SUMS_FILE") - - download_codex_file \ - "https://github.com/openai/codex/releases/latest/download/$CODEX_ASSET" \ - "$CODEX_ARCHIVE" "Codex CLI package" - if [[ "$CODEX_PACKAGE_SHA" =~ ^[0-9a-f]{64}$ ]] \ - && printf '%s %s\n' "$CODEX_PACKAGE_SHA" "$CODEX_ARCHIVE" \ - | sha256sum -c - >/dev/null 2>&1; then - CODEX_RELEASE_VERIFIED=1 - break - fi - - if [ "$release_attempt" -lt 4 ]; then - echo " [guest] Codex release changed during download; retrying the package and checksums..." - sleep 2 - fi - done - if [ "$CODEX_RELEASE_VERIFIED" -ne 1 ]; then - echo " [guest] ERROR: Codex package did not match its published checksum after 4 attempts." >&2 - exit 1 - fi - - find "$CODEX_RELEASES_DIR" -mindepth 1 -maxdepth 1 -type d \ - -name '.staging-*' -exec rm -rf -- {} + - - CODEX_PREVIOUS_RELEASE_SHA="" - if previous_release_sha=$(codex_current_release_sha); then - CODEX_PREVIOUS_RELEASE_SHA="$previous_release_sha" +# Keep the installer cleanup local when provisioning snippets are concatenated. +( + set -euo pipefail + : "${GUEST_USER:?GUEST_USER must be set by the orchestrator}" + + # Profiles may supply their own Codex; explicit updates replace it. + if [ -z "${COOP_FORCE_INSTALL:-}" ] && [ -x /usr/local/bin/codex ]; then + echo ' [guest] Codex CLI already installed, skipping.' + exit 0 fi - CODEX_RELEASE_DIR="$CODEX_RELEASES_DIR/$CODEX_PACKAGE_SHA" - if ! codex_installed_package_is_safe "$CODEX_RELEASE_DIR"; then - CODEX_STAGING_DIR="$CODEX_RELEASES_DIR/.staging-$CODEX_PACKAGE_SHA-$$" - install -d -m 755 "$CODEX_STAGING_DIR" - tar --no-same-owner --no-same-permissions -xzf "$CODEX_ARCHIVE" \ - -C "$CODEX_STAGING_DIR" - chown -R root:root "$CODEX_STAGING_DIR" - chmod -R go-w "$CODEX_STAGING_DIR" - codex_installed_package_is_safe "$CODEX_STAGING_DIR" || { - echo ' [guest] ERROR: Staged Codex package failed validation.' >&2 - exit 1 - } - if [ -e "$CODEX_RELEASE_DIR" ] || [ -L "$CODEX_RELEASE_DIR" ]; then - rm -rf -- "$CODEX_RELEASE_DIR" - fi - mv -T "$CODEX_STAGING_DIR" "$CODEX_RELEASE_DIR" - CODEX_STAGING_DIR="" + echo ' [guest] Installing Codex CLI with the native installer...' + CODEX_NATIVE_BIN="/home/${GUEST_USER}/.local/bin/codex" + CODEX_INSTALLER=$(mktemp) + CODEX_LINK_TMP="/usr/local/bin/codex.new.$$" + trap 'rm -f "$CODEX_INSTALLER" "$CODEX_LINK_TMP"' EXIT + chmod 0644 "$CODEX_INSTALLER" + + # curl retries transient failures without running a partially downloaded script. + curl -fsSL --retry 3 --retry-all-errors \ + -o "$CODEX_INSTALLER" https://chatgpt.com/codex/install.sh + + # Retain the upstream package layout and guest ownership for `codex update`. + # PATH is already managed by coop; the installer need not edit shell profiles. + su - "$GUEST_USER" -c \ + "PATH='/home/${GUEST_USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' CODEX_NON_INTERACTIVE=1 sh '$CODEX_INSTALLER'" \ + &2 + exit 1 fi - - # Once installed, both entrypoints resolve through this one link. Updating - # it switches Codex and its version-matched code-mode host atomically. - CODEX_NEXT_LINK="$CODEX_INSTALL_ROOT/.current-$$" - ln -s "releases/$CODEX_PACKAGE_SHA" "$CODEX_NEXT_LINK" - mv -Tf "$CODEX_NEXT_LINK" "$CODEX_INSTALL_ROOT/current" - CODEX_NEXT_LINK="" - - codex_reconcile_public_entrypoints - codex_installed_package_is_safe "$CODEX_INSTALL_ROOT/current" - /usr/local/bin/codex --version >/dev/null - codex_gc_releases "$CODEX_PACKAGE_SHA" "$CODEX_PREVIOUS_RELEASE_SHA" - flock -u 9 - exec 9>&- -fi +) diff --git a/src/commands/agent.rs b/src/commands/agent.rs index f9f7dbc..efbe9cc 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -9,10 +9,10 @@ //! The two agents differ in how they update, and the difference is encoded in //! [`UpdateStrategy`] so no caller can run the wrong one: //! -//! - **Codex** is a root-owned package exposed through `/usr/local/bin` and has -//! no background updater. coop re-runs its own installer -//! ([`guest::SCRIPT_CODEX`]) as root with `COOP_FORCE_INSTALL=1` to install -//! the current release and atomically switch its entrypoints. +//! - **Codex** uses a native per-user installation. coop re-runs its +//! installer wrapper ([`guest::SCRIPT_CODEX`]) with `COOP_FORCE_INSTALL=1` +//! to refresh the package or migrate an older direct-binary installation. +//! The guest user can also run `codex update` directly. //! - **Claude Code** lives in the guest user's `~/.local/bin` and already //! auto-updates in the background. `coop agent update --claude` just runs //! `claude update` synchronously as the guest user — a convenience, not a @@ -73,8 +73,8 @@ impl Agent { /// How an agent's binary is refreshed in the guest. enum UpdateStrategy { - /// Re-run coop's installer as root and activate its package. Carries the - /// embedded installer script. + /// Run the installer wrapper with sudo so it can replace the system link. + /// The wrapper runs the native installer as the configured guest user. ReinstallAsRoot { script: &'static str }, /// Invoke the agent's own updater as the guest user (no sudo). SelfUpdate, @@ -287,8 +287,12 @@ fn update_one(session: &SshSession, agent: Agent) -> Result { /// Re-run an embedded installer script as root with the force flag set, /// piping the script over stdin so it never lands on argv. fn reinstall_as_root(session: &SshSession, script: &str) -> Result<()> { + let user = guest::GuestUser::new(session.target.user.as_ref())?; session.target.exec_with_stdin( - RemoteCommand::new().literal("sudo env COOP_FORCE_INSTALL=1 bash -s"), + RemoteCommand::new() + .literal("sudo env GUEST_USER=") + .arg(user.as_str()) + .literal(" COOP_FORCE_INSTALL=1 bash -s"), script.as_bytes().to_vec(), ) } @@ -416,7 +420,7 @@ fn check_line(row: &CheckRow) -> String { // ── Guest binary resolution + version capture (IO) ──────────── /// Absolute guest path of an agent's binary. Claude lives under the guest -/// user's home; Codex is system-wide. +/// user's home; Codex is reached through its system compatibility link. fn agent_binary(session: &SshSession, agent: Agent) -> Result { Ok(match agent { Agent::Claude => guest::GuestUser::new(session.target.user.as_ref())?.claude_bin(), diff --git a/src/guest.rs b/src/guest.rs index 757e540..895bf8e 100644 --- a/src/guest.rs +++ b/src/guest.rs @@ -115,18 +115,11 @@ impl GuestUser { } } -/// Where the Codex CLI installer places the system-wide binary. -/// Independent of the guest user — Codex lives in `/usr/local/bin`, -/// not in any user's home — so callers don't pass a `GuestUser`. +/// Stable system path linking to the guest user's native Codex launcher. pub fn codex_bin() -> GuestPath { GuestPath::new("/usr/local/bin/codex") } -/// Companion process used by Codex for Code Mode execution. -pub fn codex_code_mode_host_bin() -> GuestPath { - GuestPath::new("/usr/local/bin/codex-code-mode-host") -} - /// Wrapper that runs Codex with a guest Linux Secret Service session. pub fn codex_account_bin() -> GuestPath { GuestPath::new("/usr/local/bin/codex-account") @@ -170,18 +163,19 @@ impl From for String { /// Binaries that must exist in the guest image after provisioning. /// Absolute paths are checked directly; bare names are looked up via /// `command -v` (i.e. must be in the default system PATH). +/// Codex's native installer owns package validation; coop checks its public +/// compatibility link without depending on the package's internal layout. /// /// Returns `GuestPath`s rather than raw strings so call sites that /// build shell commands or inspect the chroot get path semantics for /// free (and the `/usr/bin/docker`/`/usr/bin/gh` entries can't be /// mistaken for host paths). -pub fn required_guest_binaries(user: &GuestUser) -> [GuestPath; 9] { +pub fn required_guest_binaries(user: &GuestUser) -> [GuestPath; 8] { [ GuestPath::new("/usr/bin/docker"), GuestPath::new("/usr/bin/gh"), user.claude_bin(), codex_bin(), - codex_code_mode_host_bin(), codex_account_bin(), // The Secret Service stack `codex-account` drives. Checking the // wrapper alone proves nothing — the provision script always writes @@ -490,86 +484,6 @@ pub fn collect_codex_baked_lists(cfg: &CoopConfig) -> (Vec, Vec) mod tests { use super::*; - #[test] - fn codex_script_installs_complete_package_atomically() { - assert!( - SCRIPT_CODEX.contains("codex-package-$CODEX_TARGET.tar.gz"), - "Codex installer should download the complete upstream package", - ); - for expected in [ - "bin/codex-code-mode-host", - "codex-package.json", - "codex-resources", - "codex-path", - "codex-package_SHA256SUMS", - "sha256sum -c -", - "tar --no-same-owner --no-same-permissions", - "flock 9", - "flock -u 9", - "exec 9>&-", - "chown -R root:root", - "for executable in codex-code-mode-host codex", - "codex_reconcile_public_entrypoints", - "mv -Tf \"$CODEX_NEXT_LINK\" \"$CODEX_INSTALL_ROOT/current\"", - ] { - assert!( - SCRIPT_CODEX.contains(expected), - "Codex package installer is missing {expected:?}", - ); - } - } - - #[test] - fn codex_script_locks_before_resolving_latest_release() { - let lock = SCRIPT_CODEX.find("flock 9").unwrap(); - let first_fetch = SCRIPT_CODEX.find("releases/latest/download").unwrap(); - let final_validation = SCRIPT_CODEX - .rfind("codex_installed_package_is_safe \"$CODEX_INSTALL_ROOT/current\"") - .unwrap(); - let unlock = SCRIPT_CODEX.find("flock -u 9").unwrap(); - - assert!( - lock < first_fetch, - "update lock must precede the first fetch" - ); - assert!( - final_validation < unlock, - "activation must be validated before releasing the update lock", - ); - } - - #[test] - fn codex_script_garbage_collects_only_safe_inactive_releases() { - let final_validation = SCRIPT_CODEX - .rfind("codex_installed_package_is_safe \"$CODEX_INSTALL_ROOT/current\"") - .unwrap(); - let garbage_collection = SCRIPT_CODEX - .find("codex_gc_releases \"$CODEX_PACKAGE_SHA\" \"$CODEX_PREVIOUS_RELEASE_SHA\"") - .unwrap(); - let unlock = SCRIPT_CODEX.find("flock -u 9").unwrap(); - - assert!( - final_validation < garbage_collection && garbage_collection < unlock, - "release collection should run after validation and while holding the update lock", - ); - - for expected in [ - "codex_gc_releases \"$CODEX_PACKAGE_SHA\" \"$CODEX_PREVIOUS_RELEASE_SHA\"", - "[[ \"$release_sha\" =~ ^[0-9a-f]{64}$ ]] || continue", - "[ ! -L \"$release_dir\" ]", - "[ \"$release_sha\" = \"$current_sha\" ] && continue", - "[ \"$release_sha\" = \"$previous_sha\" ] && continue", - "for proc_exe in /proc/[0-9]*/exe", - "executable=$(readlink \"$proc_exe\" 2>/dev/null) || continue", - "codex_release_has_live_executable \"$release_dir\"", - ] { - assert!( - SCRIPT_CODEX.contains(expected), - "Codex release retention is missing {expected:?}", - ); - } - } - #[test] fn codex_account_script_uses_secret_service() { assert!( @@ -940,11 +854,6 @@ mod tests { bins.iter().any(|b| b.to_string() == "/usr/local/bin/codex"), "guest image should include codex in the default PATH", ); - assert!( - bins.iter() - .any(|b| b.to_string() == "/usr/local/bin/codex-code-mode-host"), - "guest image should include Codex's code-mode host", - ); assert!( bins.iter() .any(|b| b.to_string() == "/usr/local/bin/codex-account"), @@ -1032,7 +941,6 @@ mod tests { "/usr/bin/docker", "/usr/bin/gh", "/usr/local/bin/codex", - "/usr/local/bin/codex-code-mode-host", "/usr/local/bin/codex-account", ] { assert!(err.contains(expected), "{expected} missing from: {err}"); diff --git a/src/lima.rs b/src/lima.rs index a731aa9..f701f5c 100644 --- a/src/lima.rs +++ b/src/lima.rs @@ -1490,7 +1490,7 @@ fn compose_provision_script( s.push_str(SCRIPT_CLAUDE_CODE); s.push('\n'); - // Codex CLI package (stable entrypoints under /usr/local/bin) + // Codex CLI (native per-user package with a system compatibility link) s.push_str(SCRIPT_CODEX); s.push('\n'); s.push_str(SCRIPT_CODEX_ACCOUNT); diff --git a/src/setup.rs b/src/setup.rs index 0454cd9..d0702f5 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -886,7 +886,7 @@ fn compose_recipe( } // Direct binary download (runs as root in chroot, installs for guest user). s.push_str(SCRIPT_CLAUDE_CODE); - // Codex installs as a package with stable entrypoints under /usr/local/bin. + // Codex's native installer keeps the full package under the guest user's home. s.push_str(SCRIPT_CODEX); s.push_str(SCRIPT_CODEX_ACCOUNT); diff --git a/tests/integration.sh b/tests/integration.sh index a3f2218..b3c9811 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -1158,80 +1158,48 @@ test_claude_onboarding_seed() { fi } -validate_codex_package() { - local context="$1" +check_native_codex() { + local guest_home launcher version + guest_home=$(guest_exec printenv HOME) + launcher="$guest_home/.local/bin/codex" - if guest_exec test -x /usr/local/bin/codex; then - pass "codex binary exists at /usr/local/bin/codex ($context)" - else - fail "codex binary exists at /usr/local/bin/codex ($context)" \ - "stderr: $(guest_stderr)" - return - fi - - if coop_exec /usr/local/bin/codex --version >/dev/null; then - pass "codex binary invocable via full path ($context)" + if guest_exec test -x "$launcher" \ + && guest_exec test -L /usr/local/bin/codex \ + && guest_exec test /usr/local/bin/codex -ef "$launcher" \ + && [[ "$(guest_exec sh -c 'command -v codex')" == "$launcher" ]]; then + pass "Codex resolves through the guest's native launcher and compatibility link" else - fail "codex binary invocable via full path ($context)" \ - "stderr: $(guest_stderr)" + fail "Codex resolves through the guest's native launcher and compatibility link" \ + "expected launcher: $launcher; stderr: $(guest_stderr)" fi - if guest_exec test -x /usr/local/bin/codex-code-mode-host; then - pass "codex code-mode host exists and is executable ($context)" + if version=$(guest_exec /usr/local/bin/codex --version) \ + && [[ "$version" =~ ^codex-cli\ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + pass "Codex is executable through the compatibility link ($version)" else - fail "codex code-mode host exists and is executable ($context)" \ - "stderr: $(guest_stderr)" - return + fail "Codex is executable through the compatibility link" \ + "output: $version; stderr: $(guest_stderr)" fi +} - # `--help` exits before the host initializes its transport. Stdio with EOF - # exercises the real entrypoint without starting a persistent service; the - # timeout makes a regression fail instead of hanging the integration run. - if coop_exec sh -c \ - 'timeout 10 /usr/local/bin/codex-code-mode-host --listen stdio /dev/null'; then - pass "codex code-mode host accepts stdio transport ($context)" - else - fail "codex code-mode host accepts stdio transport ($context)" \ - "stderr: $(guest_stderr)" - fi +test_codex_bin_path() { + echo "" + echo "=== Phase: codex binary path ===" - local codex_path code_mode_path codex_release - codex_path=$(guest_exec readlink -f /usr/local/bin/codex) - code_mode_path=$(guest_exec readlink -f /usr/local/bin/codex-code-mode-host) - if [[ "$codex_path" =~ ^(/usr/local/lib/codex/releases/[0-9a-f]{64})/bin/codex$ ]] \ - && [[ "$code_mode_path" == "${BASH_REMATCH[1]}/bin/codex-code-mode-host" ]]; then - codex_release="${BASH_REMATCH[1]}" - pass "codex and code-mode host come from the same package ($context)" + if guest_exec test -x /usr/local/bin/codex; then + pass "codex binary exists at /usr/local/bin/codex" else - fail "codex and code-mode host come from the same package ($context)" \ - "codex=$codex_path host=$code_mode_path" + fail "codex binary exists at /usr/local/bin/codex" "stderr: $(guest_stderr)" return fi - if guest_exec test -x "$codex_release/codex-path/rg" \ - -a -x "$codex_release/codex-resources/bwrap" \ - -a -x "$codex_release/codex-resources/zsh/bin/zsh" \ - -a -f "$codex_release/codex-package.json"; then - pass "codex package runtime resources are installed ($context)" - else - fail "codex package runtime resources are installed ($context)" \ - "release=$codex_release stderr: $(guest_stderr)" - fi + check_native_codex - if guest_exec test ! -w "$codex_release/bin/codex" \ - -a ! -w "$codex_release/bin/codex-code-mode-host"; then - pass "codex package executables are not guest-writable ($context)" + if coop_exec /usr/local/bin/codex --version >/dev/null; then + pass "codex binary invocable via full path" else - fail "codex package executables are not guest-writable ($context)" \ - "release=$codex_release stderr: $(guest_stderr)" + fail "codex binary invocable via full path" "stderr: $(guest_stderr)" fi -} - -test_codex_bin_path() { - echo "" - echo "=== Phase: codex binary path ===" - - validate_codex_package "after provisioning" if guest_exec test -x /usr/local/bin/codex-yolo; then pass "codex-yolo shortcut exists" @@ -1498,6 +1466,44 @@ test_codex_sandbox_bypass() { fi } +seed_codex_update_config() { + # shellcheck disable=SC2016 # Keep the config and its snapshot inside the guest. + guest_exec sh -c ' + set -eu + umask 077 + mkdir -p "$HOME/.codex" + printf "\n[profiles.coop_update_test]\nmodel_reasoning_effort = \"low\"\n" \ + >> "$HOME/.codex/config.toml" + cp "$HOME/.codex/config.toml" "$HOME/.codex/coop-update-config.expected" + ' +} + +check_codex_config_preserved() { + # shellcheck disable=SC2016 # Compare guest files without copying config to the host. + if guest_exec sh -c 'cmp -s "$HOME/.codex/config.toml" "$HOME/.codex/coop-update-config.expected"'; then + pass "$1 preserves Codex config" + else + fail "$1 preserves Codex config" "could not confirm unchanged config.toml; stderr: $(guest_stderr)" + fi +} + +check_codex_self_update() { + local before="$1" after + if ! guest_exec codex update $after)" + else + fail "guest self-update changes the installed version" \ + "before: $before; after: $after; stderr: $(guest_stderr)" + fi +} + # `coop agent update` refreshes the in-guest agent binaries (issue #402). # `--check` is cheap and network-tolerant (the Codex latest-version lookup # degrades to "unknown" on failure, so the command still exits 0). The actual @@ -1519,65 +1525,9 @@ test_agent_update() { fi if [[ "$FULL" == "1" ]]; then - # Model the broken legacy/corrupt-package state from #442. The forced - # updater must rebuild an incomplete same-SHA release, not merely keep - # an already healthy host executable in place. - local installed_host installed_codex_version - local inactive_sha active_sha inactive_release active_release active_pid active_exe - inactive_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - active_sha="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - inactive_release="/usr/local/lib/codex/releases/$inactive_sha" - active_release="/usr/local/lib/codex/releases/$active_sha" - active_pid="" - - # Seed one collectable release and one whose executable is live. The - # updater should bound disk growth without breaking an in-flight Codex - # session that still needs files from its package directory. - # shellcheck disable=SC2016 # Variables expand in the guest's sh, not here. - if guest_exec sudo sh -c ' - set -eu - inactive_release=$1 - active_release=$2 - install -d -m 755 "$inactive_release" "$active_release/bin" - cp /bin/sleep "$active_release/bin/hold" - chmod 755 "$active_release/bin/hold" - nohup "$active_release/bin/hold" 300 /dev/null 2>&1 & - active_pid=$! - echo "$active_pid" >/tmp/coop-codex-active-release.pid - attempt=0 - while [ "$attempt" -lt 50 ]; do - executable=$(readlink -f "/proc/$active_pid/exe" 2>/dev/null || true) - [ "$executable" = "$active_release/bin/hold" ] && exit 0 - attempt=$((attempt + 1)) - sleep 0.1 - done - exit 1 - ' sh "$inactive_release" "$active_release"; then - active_pid=$(guest_exec cat /tmp/coop-codex-active-release.pid) \ - || active_pid="" - active_exe=$(guest_exec sudo readlink -f "/proc/$active_pid/exe") \ - || active_exe="" - if [[ "$active_exe" == "$active_release/bin/hold" ]]; then - pass "seeded inactive and live Codex releases for garbage collection" - else - fail "seeded inactive and live Codex releases for garbage collection" \ - "pid=${active_pid:-unknown} exe=${active_exe:-unknown} stderr: $(guest_stderr)" - fi - else - active_pid=$(guest_exec cat /tmp/coop-codex-active-release.pid) \ - || active_pid="" - fail "seeded inactive and live Codex releases for garbage collection" \ - "stderr: $(guest_stderr)" - fi - - installed_host=$(guest_exec readlink -f /usr/local/bin/codex-code-mode-host) - installed_codex_version=$(guest_exec codex --version) - if guest_exec sudo rm -f "$installed_host" \ - && guest_exec test ! -e /usr/local/bin/codex-code-mode-host; then - pass "removed code-mode host before updater repair test" - else - fail "removed code-mode host before updater repair test" \ - "host=$installed_host stderr: $(guest_stderr)" + if ! seed_codex_update_config; then + fail "prepare Codex config before update" "stderr: $(guest_stderr)" + return fi if coop agent update "$INSTANCE" --codex -y; then @@ -1596,49 +1546,36 @@ test_agent_update() { "got: $ver stderr: $(guest_stderr)" fi - local repaired_host repaired_codex_version - repaired_host=$(guest_exec readlink -f /usr/local/bin/codex-code-mode-host) - repaired_codex_version=$(guest_exec codex --version) - if guest_exec test -x /usr/local/bin/codex-code-mode-host \ - && { [[ "$repaired_codex_version" != "$installed_codex_version" ]] \ - || [[ "$repaired_host" == "$installed_host" ]]; }; then - pass "codex update repairs missing code-mode host" - else - fail "codex update repairs missing code-mode host" \ - "before=$installed_host ($installed_codex_version) after=$repaired_host ($repaired_codex_version) stderr: $(guest_stderr)" - fi - - validate_codex_package "after updater repair" + check_native_codex + check_codex_config_preserved "host update" - if guest_exec test ! -e "$inactive_release"; then - pass "codex update prunes an inactive release" - else - fail "codex update prunes an inactive release" \ - "release still exists: $inactive_release" - fi - active_exe="" - if [[ -n "$active_pid" ]]; then - active_exe=$(guest_exec sudo readlink -f "/proc/$active_pid/exe") \ - || active_exe="" - fi - if [[ "$active_exe" == "$active_release/bin/hold" ]] \ - && guest_exec test -d "$active_release" \ - && guest_exec sudo kill -0 "$active_pid"; then - pass "codex update preserves a release with a live executable" - else - fail "codex update preserves a release with a live executable" \ - "release=$active_release pid=${active_pid:-unknown} exe=${active_exe:-unknown} stderr: $(guest_stderr)" - fi - - if [[ -n "$active_pid" ]]; then - active_exe=$(guest_exec sudo readlink -f "/proc/$active_pid/exe") \ - || active_exe="" - if [[ "$active_exe" == "$active_release/bin/hold" ]]; then - guest_exec sudo kill "$active_pid" 2>/dev/null || true - fi - fi - guest_exec sudo rm -rf -- "$active_release" "$inactive_release" \ - /tmp/coop-codex-active-release.pid || true + # Seed a release that supports native self-update. Starting from latest + # would only exercise the up-to-date path, allowing a no-op to pass. + local older_version="0.153.0" before + # shellcheck disable=SC2016 # Installer cleanup runs inside the guest. + if guest_exec sh -c ' + set -eu + installer=$(mktemp) + trap '\''rm -f "$installer"'\'' EXIT + curl -fsSL --retry 3 --retry-all-errors \ + -o "$installer" https://chatgpt.com/codex/install.sh + CODEX_NON_INTERACTIVE=1 sh "$installer" --release "$1" + ' sh "$older_version" \ + && before=$(guest_exec codex --version) \ + && [[ "$before" == "codex-cli $older_version" ]] \ + && [[ "$before" != "$ver" ]]; then + pass "seed an older native Codex release ($before)" + check_codex_config_preserved "older-release installation" + check_codex_self_update "$before" + check_native_codex + check_codex_config_preserved "direct update" + else + fail "seed an older native Codex release" \ + "expected: codex-cli $older_version; latest: $ver; stderr: $(guest_stderr)" + fi + + # shellcheck disable=SC2016 # Expand HOME in the guest. + guest_exec sh -c 'rm -f "$HOME/.codex/coop-update-config.expected"' else skip "agent update --codex" "use --full; downloads the release in-guest" fi @@ -4468,7 +4405,11 @@ test_custom_profiles() { cat > "$cfg_file" <<'CFGEOF' [profiles.test-custom] apt_packages = ["cowsay"] -post_install = "echo 'custom-profile-marker' > /etc/custom-profile-installed" +post_install = ''' +echo 'custom-profile-marker' > /etc/custom-profile-installed +printf '#!/bin/sh\necho codex-cli 9.9.9-profile\n' > /usr/local/bin/codex +chmod 0755 /usr/local/bin/codex +''' CFGEOF # Build an image with the custom profile @@ -4500,6 +4441,34 @@ CFGEOF GUEST_INSTANCE="$inst_name" local marker marker=$(guest_exec cat /etc/custom-profile-installed) || marker="" + if [[ "$(guest_exec /usr/local/bin/codex --version)" == "codex-cli 9.9.9-profile" ]]; then + pass "custom profile's Codex is not replaced by the native installer" + else + fail "custom profile's Codex is not replaced by the native installer" \ + "stderr: $(guest_stderr)" + fi + + # This image skipped the native installer, so it exercises migration from + # a system command without depending on the native package's cache layout. + # shellcheck disable=SC2016 # Inspect the guest user's launcher. + if guest_exec sh -c 'test ! -e "$HOME/.local/bin/codex"'; then + pass "custom image has no native Codex launcher before migration" + else + fail "custom image has no native Codex launcher before migration" \ + "stderr: $(guest_stderr)" + fi + if seed_codex_update_config; then + if coop agent update "$inst_name" --codex -y; then + pass "agent update migrates the profile's system Codex installation" + else + fail "agent update migrates the profile's system Codex installation" \ + "stderr: $HARNESS_ERR" + fi + check_native_codex + check_codex_config_preserved "migration" + else + fail "prepare Codex config before migration" "stderr: $(guest_stderr)" + fi unset GUEST_INSTANCE if echo "$marker" | grep -q "custom-profile-marker"; then @@ -6771,6 +6740,14 @@ test_guest_user_alt() { "not installed in this image (no Claude profile)" fi + check_native_codex + if coop agent update "$inst_name" --codex -y; then + pass "agent update --codex uses the configured guest user" + else + fail "agent update --codex uses the configured guest user" "stderr: $HARNESS_ERR" + fi + check_native_codex + # The alt user must be in sudo + docker groups so the lifecycle # parity with `ubuntu` actually holds. local groups_out diff --git a/tests/test-integration-probes.py b/tests/test-integration-probes.py index f156365..67e70e0 100644 --- a/tests/test-integration-probes.py +++ b/tests/test-integration-probes.py @@ -28,6 +28,112 @@ def shell(script, **env): class ProbeTests(unittest.TestCase): + def test_codex_installer_checks_its_compatibility_link(self): + installer = (Path(__file__).parent.parent / "scripts/guest/codex.sh").read_text() + for download_status, install_status, launch_status in [ + (0, 0, 0), (7, 0, 0), (0, 9, 0), (0, 0, 11), + ]: + with self.subTest(download=download_status, install=install_status, + launch=launch_status), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + native = root / "guest/.local/bin/codex" + system_bin = root / "bin" + system_bin.mkdir() + binary = root / "codex" + binary.write_text('#!/bin/sh\necho codex-cli 1.2.3\nexit "$LAUNCH_STATUS"\n') + upstream = root / "install.sh" + upstream.write_text(''' + set -eu + test "$INSTALL_STATUS" = 0 || exit "$INSTALL_STATUS" + mkdir -p "$(dirname "$NATIVE_BIN")" + cp "$FIXTURE_BINARY" "$NATIVE_BIN" + chmod +x "$NATIVE_BIN" + ''') + fragment = installer.replace("/home/${GUEST_USER}", str(root / "guest")) + fragment = fragment.replace("/usr/local/bin", str(system_bin)) + result = shell(''' + curl() { + test "$DOWNLOAD_STATUS" = 0 || return "$DOWNLOAD_STATUS" + while [[ "$1" != -o ]]; do shift; done + cp "$UPSTREAM_INSTALLER" "$2" + } + su() { + [[ "$1" == - && "$2" == ubuntu && "$3" == -c ]] || return 99 + bash -c "$4" + } + mv() { shift; command mv -f "$@"; } + ''' + fragment, GUEST_USER="ubuntu", COOP_FORCE_INSTALL="1", + DOWNLOAD_STATUS=str(download_status), INSTALL_STATUS=str(install_status), + LAUNCH_STATUS=str(launch_status), NATIVE_BIN=str(native), + FIXTURE_BINARY=str(binary), UPSTREAM_INSTALLER=str(upstream)) + self.assertEqual(result.returncode == 0, + not (download_status or install_status or launch_status), + result.stdout + result.stderr) + link = system_bin / "codex" + if download_status or install_status: + self.assertFalse(link.is_symlink()) + else: + self.assertTrue(link.samefile(native)) + self.assertIn("codex-cli 1.2.3", result.stdout) + self.assertFalse(list(system_bin.glob("codex.new.*"))) + + def test_codex_self_update_requires_a_version_change(self): + for update_status, version_status, after, succeeds in [ + (0, 0, "codex-cli 0.154.0", True), + (0, 0, "codex-cli 0.153.0", False), + (1, 0, "codex-cli 0.154.0", False), + (0, 1, "codex-cli 0.154.0", False), + (0, 0, "", False), + ]: + with self.subTest(update=update_status, version=version_status, after=after): + result = shell(functions("check_codex_self_update") + ''' + current="codex-cli 0.153.0" + update_called=0 + guest_exec() { + case "$*" in + "codex update") + update_called=1 + current="$AFTER" + return "$UPDATE_STATUS" ;; + "codex --version") + printf '%s\\n' "$current" + return "$VERSION_STATUS" ;; + *) return 99 ;; + esac + } + guest_stderr() { echo fixture-error; } + pass() { echo "PASS $*"; } + fail() { echo "FAIL $*"; } + check_codex_self_update "$current" + test "$update_called" = 1 + ''', UPDATE_STATUS=str(update_status), VERSION_STATUS=str(version_status), + AFTER=after) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.startswith("PASS"), succeeds, result.stdout) + + def test_codex_config_preservation_checks_actual_contents(self): + fragment = functions("seed_codex_update_config", "check_codex_config_preserved") + fragment = fragment.replace("$HOME/", "$FIXTURE_HOME/") + for mutation in ("unchanged", "changed", "deleted", "missing-snapshot"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + result = shell(fragment + ''' + set -eu + guest_exec() { "$@"; } + guest_stderr() { echo fixture-error; } + pass() { echo "PASS $*"; } + fail() { echo "FAIL $*"; } + seed_codex_update_config + case "$MUTATION" in + changed) echo '# changed' >> "$FIXTURE_HOME/.codex/config.toml" ;; + deleted) rm "$FIXTURE_HOME/.codex/config.toml" ;; + missing-snapshot) rm "$FIXTURE_HOME/.codex/coop-update-config.expected" ;; + esac + check_codex_config_preserved update + ''', FIXTURE_HOME=directory, MUTATION=mutation) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.startswith("PASS"), mutation == "unchanged", + result.stdout) + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux guest provisioning") def test_fcnet_mask_replaces_existing_unit(self): setup = (Path(__file__).parent.parent / "scripts/guest/guest-config.sh").read_text()