diff --git a/Cargo.lock b/Cargo.lock index aff3472a0..4e0c5a18b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,7 +560,7 @@ dependencies = [ [[package]] name = "codex-update-manager" -version = "0.9.7" +version = "0.10.0" dependencies = [ "anyhow", "chrono", diff --git a/docs/launcher-performance.md b/docs/launcher-performance.md index 0575c656d..8d93bd439 100644 --- a/docs/launcher-performance.md +++ b/docs/launcher-performance.md @@ -111,9 +111,16 @@ runs with stdout/stderr detached (`>/dev/null 2>&1`), which cut the caller pipe — an orphaned `sleep` holding an inherited fd blocks command substitution until it exits. -The default preflight remains asynchronous; `CODEX_SYNC_CLI_PREFLIGHT=1` still -opts into the existing synchronous check while preserving fail-soft behavior for -a CLI that is not known broken. A detected npm-managed CLI missing +The default update/version preflight remains asynchronous, but every generated +launcher first runs its bundled synchronous, execution-free trust check. This +applies to native packages with or without the updater, AppImage, Nix, and +user-local installs, without depending on the version of any system updater. A +managed standalone CLI that fails this check blocks startup before any CLI +version probe; a successful check pins launch to the canonical verified release +binary so replacement of its visible symlink cannot redirect execution. +`CODEX_SYNC_CLI_PREFLIGHT=1` still opts into the full synchronous check while +preserving fail-soft behavior for a CLI that is not known broken. A detected +npm-managed CLI missing `@openai/codex-linux-x64` or `@openai/codex-linux-arm64` is the blocking exception: Electron cannot use that CLI, so the launcher waits for one repair attempt. The updater's initial and post-repair CLI version probes are each diff --git a/docs/updater.md b/docs/updater.md index f1a60e5fd..e35b6c3a7 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -20,6 +20,73 @@ installs continue to update through npm, while official standalone installs under `~/.codex/packages/standalone` are updated with the official standalone installer instead of being replaced through npm. +The updater scopes permission hardening to the official standalone installer +process. New managed releases use the caller's existing umask plus the +group/world write restrictions from `0022`; stricter policies such as `0027` +and `0077` remain intact, and the launcher, Electron, app-server, hooks, and +unrelated child processes keep the caller's original mask. + +Before executing a managed standalone CLI, the updater verifies that its tree +and canonical parent chain are owned by the current user or root and are not +group/world-writable (apart from root-owned sticky directories such as +`/tmp`). Every generated launcher performs the same trust-only check through a +bundled helper before any CLI version probe, including AppImage and native +packages built without the updater. The helper executes the returned canonical +release binary rather than a replaceable visible symlink and does not depend on +an installed updater version. An unsafe tree is rejected without +executing it, changing its modes, or deleting it; updater state records a +failed preflight with clean-reinstall guidance. + +To recover, stop any active updater or Codex installer, remove the rejected +`~/.codex/packages/standalone` tree, and run: + +```bash +codex-update-manager recover-standalone-cli --print-path +``` + +If the standalone installer link belongs in a non-default directory, add +`--install-dir /absolute/path/to/bin`. If the recorded standalone home is not +the default `~/.codex`, also add `--codex-home /absolute/path/to/codex-home`. +Recovery refuses to overwrite any +existing standalone tree. It downloads the official installer and runs only +that child with the caller's umask plus the `0022` write restrictions, so the +flow remains safe even when the desktop session uses `umask 0002`. Before the +download, recovery removes group/world write access from existing +current-user-owned directories below `$HOME` along both installer paths (for +example `.codex`, `packages`, `.local`, and `bin`) and rejects symlinks, +untrusted ownership, or unsafe ancestors it cannot safely narrow. Automatic +standalone updates reject an unsafe visible-command directory before +downloading or spawning the installer. Both update and recovery resolve the +installer shell, downloader, and child commands only from root-controlled +system tool directories; they never reuse programs already present in a +formerly writable user directory. Do not run the official installer directly +for this recovery: it would inherit the ambient mask. Removing write bits from +the rejected standalone tree or its command directory is not sufficient because +their contents may already have been modified. + +AppImage, Nix, and native packages built without the updater do not provide the +recovery command. On those formats, either temporarily install an updater-enabled +native package and use the command above, or remove the rejected standalone tree +and reinstall the CLI from a verified package channel into a fresh path whose +files and canonical ancestors are owned by the current user or root and are not +group/world-writable. Do not reuse the rejected tree, and do not rerun the +standalone installer under an ambient `0002` mask. + +An npm CLI whose canonical executable or ancestors are already group/world- +writable is also rejected because its pathname cannot be pinned safely against +replacement. Do not reinstall into that existing prefix. Remove the rejected +Codex package tree, then use a fresh dedicated prefix such as +`(umask 0022; npm i -g --include=optional --prefix ~/.codex-cli-npm +@openai/codex)`; the launcher and updater both discover that prefix. + +After a managed standalone tree is first detected, launcher and updater trust +checks record its home in `~/.codex-standalone-provenance`, outside the managed +tree. That durable record keeps standalone provenance active even if the tree or +visible command has already been replaced. To switch installation channels +intentionally, remove both the standalone tree and this provenance file; +otherwise an external replacement is rejected rather than silently reclassified +as npm- or system-managed. + System-package-managed CLI installs are reused but not mutated through npm or the standalone installer flow. On Arch-like hosts, when the resolved CLI lives under a system bin directory and `pacman -Qo` confirms package ownership, the diff --git a/install.sh b/install.sh index 03e8f20b2..b3bc1865d 100755 --- a/install.sh +++ b/install.sh @@ -225,6 +225,7 @@ SCRIPT chmod +x "$INSTALL_DIR/start.sh" mkdir -p "$INSTALL_DIR/.codex-linux" + cp "$SCRIPT_DIR/launcher/cli-launch-path.py" "$INSTALL_DIR/.codex-linux/cli-launch-path.py" cp "$SCRIPT_DIR/launcher/webview-server.py" "$INSTALL_DIR/.codex-linux/webview-server.py" local linux_icon_source="$LINUX_ICON_SOURCE" [ -f "$linux_icon_source" ] || linux_icon_source="$ICON_SOURCE" diff --git a/launcher/cli-launch-path.py b/launcher/cli-launch-path.py new file mode 100644 index 000000000..6bbd46ea6 --- /dev/null +++ b/launcher/cli-launch-path.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Resolve a Codex CLI path without executing an untrusted standalone tree.""" + +from __future__ import annotations + +import os +import shutil +import stat +import sys +import tempfile +from pathlib import Path +from typing import Optional + + +class TrustError(RuntimeError): + pass + + +PROVENANCE_FILE = ".codex-standalone-provenance" + + +def standalone_home_from_path(path: Path) -> Optional[Path]: + parts = path.parts + for index in range(len(parts) - 2): + if parts[index : index + 2] != ("packages", "standalone"): + continue + if parts[index + 2] not in ("current", "releases"): + continue + if index == 0: + return None + return Path(*parts[:index]) + return None + + +def unresolved_symlink_target(path: Path) -> Optional[Path]: + try: + target = Path(os.readlink(path)) + except OSError: + return None + if not target.is_absolute(): + target = path.parent / target + return Path(os.path.abspath(target)) + + +def path_is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def trusted_owner(uid: int) -> bool: + return uid in (os.geteuid(), 0) + + +def validate_parent_chain(path: Path, subject: str) -> None: + parent = path.parent + while True: + metadata = parent.lstat() + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise TrustError( + f"{subject} ancestor {parent} is not a trusted directory" + ) + if not trusted_owner(metadata.st_uid): + raise TrustError( + f"{subject} ancestor {parent} is owned by untrusted uid {metadata.st_uid}" + ) + writable = metadata.st_mode & 0o022 + root_owned_sticky = ( + metadata.st_uid == 0 + and metadata.st_mode & stat.S_ISVTX + and metadata.st_mode & 0o002 + ) + if writable and not root_owned_sticky: + raise TrustError( + f"{subject} ancestor {parent} is group/world-writable and therefore untrusted" + ) + if parent.parent == parent: + break + parent = parent.parent + + +def validate_cli_target(lexical_path: Path, canonical_path: Path) -> Path: + canonical_path = validate_cli_identity(lexical_path, canonical_path) + validate_parent_chain(lexical_path, "Selected Codex CLI") + return canonical_path + + +def validate_cli_identity(lexical_path: Path, canonical_path: Path) -> Path: + entry_metadata = lexical_path.lstat() + if not trusted_owner(entry_metadata.st_uid): + raise TrustError( + f"Selected Codex CLI entry {lexical_path} is owned by untrusted uid {entry_metadata.st_uid}" + ) + target_metadata = canonical_path.stat() + if not stat.S_ISREG(target_metadata.st_mode) or not os.access(canonical_path, os.X_OK): + raise TrustError(f"Selected Codex CLI target {canonical_path} is not an executable file") + if not trusted_owner(target_metadata.st_uid): + raise TrustError( + f"Selected Codex CLI target {canonical_path} is owned by untrusted uid {target_metadata.st_uid}" + ) + if target_metadata.st_mode & 0o022: + raise TrustError( + f"Selected Codex CLI target {canonical_path} is group/world-writable and therefore untrusted" + ) + validate_parent_chain(canonical_path, "Selected Codex CLI target") + return canonical_path + + +def validate_standalone_tree(standalone_root: Path) -> Path: + root_metadata = standalone_root.lstat() + if not stat.S_ISDIR(root_metadata.st_mode) or stat.S_ISLNK(root_metadata.st_mode): + raise TrustError( + f"Managed standalone Codex CLI root {standalone_root} is not a trusted directory" + ) + + canonical_root = standalone_root.resolve(strict=True) + validate_parent_chain(canonical_root, "Managed standalone Codex CLI") + + pending = [standalone_root] + while pending: + path = pending.pop() + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode): + try: + target = path.resolve(strict=True) + except OSError as error: + raise TrustError( + f"Managed standalone Codex CLI contains a broken symlink at {path}" + ) from error + if not path_is_within(target, canonical_root): + raise TrustError( + f"Managed standalone Codex CLI contains an external symlink at {path}" + ) + continue + + if not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode)): + raise TrustError( + f"Managed standalone Codex CLI contains an unsupported file type at {path}" + ) + if not trusted_owner(metadata.st_uid): + raise TrustError( + f"Managed standalone Codex CLI path {path} is owned by untrusted uid {metadata.st_uid}" + ) + if metadata.st_mode & 0o022: + raise TrustError( + f"Managed standalone Codex CLI path {path} is group/world-writable and therefore untrusted" + ) + if stat.S_ISDIR(metadata.st_mode): + pending.extend(path.iterdir()) + + return canonical_root + + +def existing_default_standalone_home() -> Optional[Path]: + raw_codex_home = os.environ.get("CODEX_HOME") + if raw_codex_home: + codex_home = Path(raw_codex_home) + else: + raw_home = os.environ.get("HOME") + if not raw_home: + return None + codex_home = Path(raw_home) / ".codex" + try: + (codex_home / "packages" / "standalone").lstat() + except OSError: + return None + return codex_home + + +def provenance_path() -> Optional[Path]: + raw_home = os.environ.get("HOME") + if not raw_home: + return None + try: + canonical_home = Path(raw_home).resolve(strict=True) + except OSError as error: + raise TrustError(f"Failed to resolve HOME for standalone CLI provenance: {error}") from error + return canonical_home / PROVENANCE_FILE + + +def read_standalone_provenance() -> Optional[Path]: + path = provenance_path() + if path is None: + return None + try: + metadata = path.lstat() + except FileNotFoundError: + return None + if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise TrustError(f"Standalone Codex CLI provenance {path} is not a regular file") + if not trusted_owner(metadata.st_uid) or metadata.st_mode & 0o022: + raise TrustError(f"Standalone Codex CLI provenance {path} is not trusted") + validate_parent_chain(path, "Standalone Codex CLI provenance") + value = path.read_text(encoding="utf-8").strip() + codex_home = Path(value) + if not value or not codex_home.is_absolute(): + raise TrustError(f"Standalone Codex CLI provenance {path} is invalid") + return codex_home + + +def record_standalone_provenance(codex_home: Path, lexical_cli: Path) -> None: + path = provenance_path() + if path is None: + return + entry_metadata = lexical_cli.lstat() + if not trusted_owner(entry_metadata.st_uid): + raise TrustError( + f"Selected Codex CLI entry {lexical_cli} is owned by untrusted uid {entry_metadata.st_uid}" + ) + validate_parent_chain(path, "Standalone Codex CLI provenance") + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=path.parent, prefix=f".{PROVENANCE_FILE}.", delete=False + ) as handle: + temp_path = Path(handle.name) + os.chmod(temp_path, 0o600) + handle.write(f"{codex_home}\n") + handle.flush() + os.fsync(handle.fileno()) + try: + os.replace(temp_path, path) + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + temp_path.unlink() + except FileNotFoundError: + pass + + +def resolve_cli_launch_path(raw_path: str) -> Path: + if os.sep not in raw_path: + discovered = shutil.which(raw_path) + if discovered is None: + raise TrustError(f"Codex CLI command {raw_path!r} was not found in PATH") + selected_path = Path(discovered) + else: + selected_path = Path(raw_path) + lexical_path = Path(os.path.abspath(selected_path)) + try: + canonical_cli = selected_path.resolve(strict=True) + except OSError as error: + raise TrustError(f"Failed to resolve Codex CLI path {selected_path}: {error}") from error + candidates = [canonical_cli, lexical_path] + raw_target = unresolved_symlink_target(lexical_path) + if raw_target is not None: + candidates.append(raw_target) + + detected_home = next( + (home for candidate in candidates if (home := standalone_home_from_path(candidate))), + None, + ) + if detected_home is None: + detected_home = existing_default_standalone_home() + codex_home = read_standalone_provenance() + if codex_home is None: + if detected_home is None: + return validate_cli_identity(lexical_path, canonical_cli) + codex_home = detected_home + elif detected_home is not None and detected_home != codex_home: + raise TrustError( + f"Selected Codex CLI provenance {detected_home} conflicts with recorded standalone home {codex_home}" + ) + + def validate_selection() -> Path: + canonical_root = validate_standalone_tree(codex_home / "packages" / "standalone") + if not path_is_within(canonical_cli, canonical_root): + raise TrustError( + f"Managed standalone Codex CLI path {selected_path} resolves outside its trusted root" + ) + return validate_cli_target(lexical_path, canonical_cli) + + launch_path = validate_selection() + if read_standalone_provenance() is None: + record_standalone_provenance(codex_home, lexical_path) + launch_path = validate_selection() + return launch_path + + +def main() -> int: + if len(sys.argv) != 2 or not sys.argv[1]: + print(f"usage: {Path(sys.argv[0]).name} CLI_PATH", file=sys.stderr) + return 64 + try: + print(resolve_cli_launch_path(sys.argv[1])) + except (OSError, TrustError) as error: + print(f"Codex CLI trust check failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 34ba98b4a..15c4761e2 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1497,6 +1497,23 @@ run_cli_preflight_background() { ) & } +run_cli_launch_path_helper() { + python3 "$SCRIPT_DIR/.codex-linux/cli-launch-path.py" "$1" +} + +verify_cli_launch_path() { + local verified_path="" + if ! verified_path="$(run_cli_launch_path_helper "$CODEX_CLI_PATH")"; then + return 1 + fi + if [ -z "$verified_path" ]; then + return 1 + fi + + CODEX_CLI_PATH="$verified_path" + export CODEX_CLI_PATH +} + is_interactive_terminal() { [ "$LAUNCHER_INTERACTIVE_TERMINAL" -eq 1 ] && [ -r /dev/tty ] && [ -w /dev/tty ] } @@ -1639,6 +1656,7 @@ find_codex_cli() { for candidate in \ "$(cached_codex_cli_path || true)" \ "$HOME/.bun/bin/codex" \ + "$HOME/.codex-cli-npm/bin/codex" \ "$HOME/.npm-global/bin/codex" \ "${XDG_CONFIG_HOME:-$HOME/.config}/nvm/versions/node/current/bin/codex" \ "${XDG_CONFIG_HOME:-$HOME/.config}/nvm/versions/node"/*/bin/codex \ @@ -4180,21 +4198,29 @@ if needs_cold_start && [ -z "$CODEX_CLI_PATH" ]; then if is_interactive_terminal; then if prompt_install_missing_cli; then if ! run_cli_preflight 1; then - notify_error "Codex CLI installation was attempted but did not succeed. Retry by reopening the app, or install it manually with: npm i -g --include=optional @openai/codex or npm i -g --include=optional --prefix ~/.local @openai/codex" + notify_error "Codex CLI installation was attempted but did not succeed. Retry by reopening the app, or install into a fresh safe prefix: (umask 0022; npm i -g --include=optional --prefix ~/.codex-cli-npm @openai/codex)" exit 1 fi fi elif ! run_gui_cli_prompt; then - notify_error "Codex CLI is required but is not installed. Reopen the app to retry the automatic install flow, or install it manually with: npm i -g --include=optional @openai/codex or npm i -g --include=optional --prefix ~/.local @openai/codex" + notify_error "Codex CLI is required but is not installed. Reopen the app to retry the automatic install flow, or install into a fresh safe prefix: (umask 0022; npm i -g --include=optional --prefix ~/.codex-cli-npm @openai/codex)" exit 1 fi fi if needs_cold_start && [ -z "$CODEX_CLI_PATH" ]; then - notify_error "Codex CLI is required but is not installed. Reopen the app to retry the automatic install flow, or install it manually with: npm i -g --include=optional @openai/codex or npm i -g --include=optional --prefix ~/.local @openai/codex" + notify_error "Codex CLI is required but is not installed. Reopen the app to retry the automatic install flow, or install into a fresh safe prefix: (umask 0022; npm i -g --include=optional --prefix ~/.codex-cli-npm @openai/codex)" exit 1 fi +if [ -n "$CODEX_CLI_PATH" ]; then + if ! verify_cli_launch_path; then + notify_error "The selected Codex CLI failed its execution-free trust check and will not be executed. If it is a rejected managed standalone install, stop active Codex installers and remove its standalone tree. Updater-enabled native packages can then run: codex-update-manager recover-standalone-cli --print-path. With AppImage, Nix, or updaterless packages, reinstall from a verified package channel into a fresh path owned by you or root with no group/world-writable file or parent directory; do not reuse the rejected tree. To switch away from standalone intentionally, also remove ~/.codex-standalone-provenance." + exit 1 + fi + log_phase "cli_launch_path_verified" +fi + if needs_cold_start; then cli_repair_required=0 if codex_cli_missing_optional_dependency "$CODEX_CLI_PATH"; then diff --git a/scripts/ci/container-entrypoint.sh b/scripts/ci/container-entrypoint.sh index 0687ca5f7..d0b389f29 100755 --- a/scripts/ci/container-entrypoint.sh +++ b/scripts/ci/container-entrypoint.sh @@ -274,12 +274,14 @@ run_deb_job() { assert_contains_file /tmp/deb-contents.txt './usr/lib/systemd/user/codex-update-manager.service' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/install.sh' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/launcher/webview-server.py' + assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/launcher/cli-launch-path.py' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/scripts/lib/upstream-dmg-intel.js' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/scripts/lib/patch-browser-client-iab-socket-scope.js' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/scripts/lib/upstream-dmg-acceptance.js' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/scripts/lib/candidate-promotion.py' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/update-builder/scripts/validate-upstream-dmg.js' assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/.codex-linux/codex-packaged-runtime.sh' + assert_contains_file /tmp/deb-contents.txt './opt/codex-desktop/.codex-linux/cli-launch-path.py' rm -rf dist CARGO_TARGET_DIR="$target_dir" \ @@ -300,6 +302,7 @@ run_deb_job() { assert_not_contains_file /tmp/deb-no-updater-contents.txt './usr/share/polkit-1/actions/com.github.ilysenko.codex-desktop-linux.update.policy' assert_not_contains_file /tmp/deb-no-updater-contents.txt './opt/codex-desktop/update-builder/' assert_contains_file /tmp/deb-no-updater-contents.txt './opt/codex-desktop/.codex-linux/codex-packaged-runtime.sh' + assert_contains_file /tmp/deb-no-updater-contents.txt './opt/codex-desktop/.codex-linux/cli-launch-path.py' assert_contains_file /tmp/deb-no-updater-contents.txt './opt/codex-desktop/.codex-linux/codex-no-updater-transition-cleanup.sh' assert_contains_file /tmp/deb-no-updater-payload/opt/codex-desktop/.codex-linux/codex-no-updater-transition-cleanup.sh 'codex_no_updater_cleanup_user_enablement_links' assert_contains_file /tmp/deb-no-updater-payload/opt/codex-desktop/.codex-linux/codex-no-updater-transition-cleanup.sh 'default.target.wants' @@ -334,12 +337,14 @@ run_rpm_job() { assert_contains_file /tmp/rpm-contents.txt '/usr/lib/systemd/user/codex-update-manager.service' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/install.sh' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/launcher/webview-server.py' + assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/launcher/cli-launch-path.py' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/scripts/lib/upstream-dmg-intel.js' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/scripts/lib/patch-browser-client-iab-socket-scope.js' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/scripts/lib/upstream-dmg-acceptance.js' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/scripts/lib/candidate-promotion.py' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/update-builder/scripts/validate-upstream-dmg.js' assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/.codex-linux/codex-packaged-runtime.sh' + assert_contains_file /tmp/rpm-contents.txt '/opt/codex-desktop/.codex-linux/cli-launch-path.py' rm -rf dist CARGO_TARGET_DIR="$target_dir" \ @@ -356,6 +361,7 @@ run_rpm_job() { assert_not_contains_file /tmp/rpm-no-updater-contents.txt '/usr/share/polkit-1/actions/com.github.ilysenko.codex-desktop-linux.update.policy' assert_not_contains_file /tmp/rpm-no-updater-contents.txt '/opt/codex-desktop/update-builder/' assert_contains_file /tmp/rpm-no-updater-contents.txt '/opt/codex-desktop/.codex-linux/codex-packaged-runtime.sh' + assert_contains_file /tmp/rpm-no-updater-contents.txt '/opt/codex-desktop/.codex-linux/cli-launch-path.py' assert_contains_file /tmp/rpm-no-updater-contents.txt '/opt/codex-desktop/.codex-linux/codex-no-updater-transition-cleanup.sh' assert_contains_file /tmp/rpm-no-updater-scripts.txt 'codex_no_updater_cleanup_update_manager_service' assert_not_contains_file /tmp/rpm-no-updater-scripts.txt 'update-builder' @@ -388,12 +394,14 @@ run_pacman_job() { assert_contains_file /tmp/pacman-contents.txt 'usr/lib/systemd/user/codex-update-manager.service' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/install.sh' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/launcher/webview-server.py' + assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/launcher/cli-launch-path.py' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/scripts/lib/upstream-dmg-intel.js' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/scripts/lib/patch-browser-client-iab-socket-scope.js' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/scripts/lib/upstream-dmg-acceptance.js' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/scripts/lib/candidate-promotion.py' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/update-builder/scripts/validate-upstream-dmg.js' assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/.codex-linux/codex-packaged-runtime.sh' + assert_contains_file /tmp/pacman-contents.txt 'opt/codex-desktop/.codex-linux/cli-launch-path.py' rm -rf dist CARGO_TARGET_DIR="$target_dir" \ @@ -411,6 +419,7 @@ run_pacman_job() { assert_not_contains_file /tmp/pacman-no-updater-contents.txt 'usr/share/polkit-1/actions/com.github.ilysenko.codex-desktop-linux.update.policy' assert_not_contains_file /tmp/pacman-no-updater-contents.txt 'opt/codex-desktop/update-builder/' assert_contains_file /tmp/pacman-no-updater-contents.txt 'opt/codex-desktop/.codex-linux/codex-packaged-runtime.sh' + assert_contains_file /tmp/pacman-no-updater-contents.txt 'opt/codex-desktop/.codex-linux/cli-launch-path.py' assert_contains_file /tmp/pacman-no-updater-contents.txt 'opt/codex-desktop/.codex-linux/codex-no-updater-transition-cleanup.sh' assert_contains_file /tmp/pacman-no-updater-cleanup.txt 'codex_no_updater_cleanup_user_enablement_links' assert_contains_file /tmp/pacman-no-updater-cleanup.txt 'default.target.wants' diff --git a/scripts/lib/package-common.sh b/scripts/lib/package-common.sh index 471599efd..64f257e73 100755 --- a/scripts/lib/package-common.sh +++ b/scripts/lib/package-common.sh @@ -804,6 +804,7 @@ stage_update_builder_bundle() { cp "$REPO_DIR/install.sh" "$update_builder_root/install.sh" cp "$REPO_DIR/CHANGELOG.md" "$update_builder_root/CHANGELOG.md" cp "$REPO_DIR/launcher/start.sh.template" "$update_builder_root/launcher/start.sh.template" + cp "$REPO_DIR/launcher/cli-launch-path.py" "$update_builder_root/launcher/cli-launch-path.py" cp "$REPO_DIR/launcher/webview-server.py" "$update_builder_root/launcher/webview-server.py" cp "$REPO_DIR/Cargo.toml" "$update_builder_root/Cargo.toml" cp "$REPO_DIR/Cargo.lock" "$update_builder_root/Cargo.lock" diff --git a/tests/fixtures/create-packaged-app-fixture.sh b/tests/fixtures/create-packaged-app-fixture.sh index 43e1cb306..c35fb43a1 100755 --- a/tests/fixtures/create-packaged-app-fixture.sh +++ b/tests/fixtures/create-packaged-app-fixture.sh @@ -2,11 +2,13 @@ set -Eeuo pipefail app_dir="${1:-codex-app}" +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -mkdir -p "$app_dir/content/webview" "$app_dir/resources/node-runtime/bin" +mkdir -p "$app_dir/.codex-linux" "$app_dir/content/webview" "$app_dir/resources/node-runtime/bin" printf '%s\n' '#!/usr/bin/env bash' 'echo "codex desktop fixture"' > "$app_dir/start.sh" chmod +x "$app_dir/start.sh" +cp "$repo_dir/launcher/cli-launch-path.py" "$app_dir/.codex-linux/cli-launch-path.py" printf '%s\n' '