diff --git a/scripts/codex_lab_package/README.md b/scripts/codex_lab_package/README.md index 95890a50f61..fa8f664737e 100644 --- a/scripts/codex_lab_package/README.md +++ b/scripts/codex_lab_package/README.md @@ -4,25 +4,26 @@ This helper builds a macOS `Codex Lab.app` launcher bundle. The bundle does not contain or modify OpenAI's signed desktop app. Instead, it embeds a Codex Lab CLI binary and binds its source commit, version, and SHA-256 digest. The official app is bound to the persistent engine by setting `CODEX_HOME` and -`CODEX_LAB_HOME` to the same Lab home, enabling -`CODEX_APP_SERVER_USE_LOCAL_DAEMON=1`, and explicitly setting -`CODEX_CLI_PATH` and `CODEX_APP_SERVER_FORCE_CLI` to empty values. Current -official clients use a non-empty CLI path or `CODEX_APP_SERVER_FORCE_CLI=1` to -select stdio instead of the local daemon. +`CODEX_LAB_HOME` to the same Lab home, setting +`CODEX_APP_SERVER_WS_URL=ws://127.0.0.1:4766/rpc`, and explicitly setting +`CODEX_CLI_PATH`, `CODEX_APP_SERVER_FORCE_CLI`, and +`CODEX_APP_SERVER_USE_LOCAL_DAEMON` to empty values. Current official clients +use a non-empty CLI path or `CODEX_APP_SERVER_FORCE_CLI=1` to select stdio. The launcher accepts only intact `com.openai.codex` bundles signed by OpenAI team `2DC432GLL2`. After an optional build-time override, it checks system and user `ChatGPT.app` installs, then legacy `Codex.app` installs. It never patches, re-signs, or redistributes the official bundle. If that app is already running, the launcher fails closed; quit it before launching `Codex Lab.app` so the new -process inherits the persistent-daemon environment. The launcher also fails -closed when the managed daemon is unavailable, preventing silent fallback to a -bundled stdio app-server. The embedded and managed engines must report the same -bounded source/build provenance. Start the matching managed daemon first with: +process inherits the websocket environment. The launcher also fails closed +unless launchd service `dev.everycode.codex-lab.app-server.v1` is running the +validated supervisor runner, the exact managed engine command, and the pinned +loopback listener. This prevents silent fallback to a bundled stdio app-server. +The embedded and managed engines must report the same bounded source/build +provenance. Inspect the installed service with: ```shell -export CODEX_LAB_HOME="${CODEX_LAB_HOME:-$HOME/.codex-lab}" -"$CODEX_LAB_HOME/packages/standalone/current/codex" app-server daemon start +launchctl print "gui/$(id -u)/dev.everycode.codex-lab.app-server.v1" ``` Example: @@ -63,8 +64,8 @@ python3 scripts/codex_lab_package/live_smoke.py \ ``` The check launches a fresh GUI instance and emits bounded JSON only after the -GUI is running beside the managed persistent daemon. The embedded and managed -CLI builds must have matching fixed source/build provenance. +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, @@ -78,6 +79,12 @@ 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. + Use `scripts/install_codex_lab.py` to install or manually update Codex Lab from a published release manifest: diff --git a/scripts/codex_lab_package/layout.py b/scripts/codex_lab_package/layout.py index d9f06c57fb5..9302d4c90ad 100644 --- a/scripts/codex_lab_package/layout.py +++ b/scripts/codex_lab_package/layout.py @@ -12,6 +12,12 @@ DEFAULT_BUNDLE_IDENTIFIER = "dev.everycode.codex-lab" +APP_SERVER_LABEL = "dev.everycode.codex-lab.app-server.v1" +APP_SERVER_LISTEN_HOST = "127.0.0.1" +APP_SERVER_LISTEN_PORT = 4766 +APP_SERVER_LISTEN_URL = f"ws://{APP_SERVER_LISTEN_HOST}:{APP_SERVER_LISTEN_PORT}" +APP_SERVER_WEBSOCKET_URL = f"{APP_SERVER_LISTEN_URL}/rpc" +APP_SERVER_RUNNER_RELATIVE_PATH = Path("supervisor/v1/codex-lab-app-server") MAX_PROVENANCE_BYTES = 4096 OFFICIAL_APP_BUNDLE_IDENTIFIER = "com.openai.codex" OFFICIAL_APP_TEAM_IDENTIFIER = "2DC432GLL2" @@ -143,9 +149,13 @@ def _launcher_script( expected_source_commit: str | None, expected_cli_version: str, codesign_path: Path = Path("/usr/bin/codesign"), + id_path: Path = Path("/usr/bin/id"), + launchctl_path: Path = Path("/bin/launchctl"), lsappinfo_path: Path = Path("/usr/bin/lsappinfo"), + lsof_path: Path = Path("/usr/sbin/lsof"), open_path: Path = Path("/usr/bin/open"), plutil_path: Path = Path("/usr/bin/plutil"), + ps_path: Path = Path("/bin/ps"), shasum_path: Path = Path("/usr/bin/shasum"), ) -> str: embedded_cli_name = embedded_cli_path.name @@ -160,17 +170,27 @@ def _launcher_script( EXPECTED_CLI_VERSION={_shell_quote(expected_cli_version)} OFFICIAL_BUNDLE_IDENTIFIER={_shell_quote(OFFICIAL_APP_BUNDLE_IDENTIFIER)} OFFICIAL_TEAM_IDENTIFIER={_shell_quote(OFFICIAL_APP_TEAM_IDENTIFIER)} +SUPERVISOR_LABEL={_shell_quote(APP_SERVER_LABEL)} +SUPERVISOR_RUNNER_RELATIVE_PATH={_shell_quote(str(APP_SERVER_RUNNER_RELATIVE_PATH))} +LISTEN_HOST={_shell_quote(APP_SERVER_LISTEN_HOST)} +LISTEN_PORT={APP_SERVER_LISTEN_PORT} +LISTEN_URL={_shell_quote(APP_SERVER_LISTEN_URL)} +WEBSOCKET_URL={_shell_quote(APP_SERVER_WEBSOCKET_URL)} CODESIGN={_shell_quote(str(codesign_path))} +ID={_shell_quote(str(id_path))} +LAUNCHCTL={_shell_quote(str(launchctl_path))} LSAPPINFO={_shell_quote(str(lsappinfo_path))} +LSOF={_shell_quote(str(lsof_path))} OPEN={_shell_quote(str(open_path))} PLUTIL={_shell_quote(str(plutil_path))} +PS={_shell_quote(str(ps_path))} SHASUM={_shell_quote(str(shasum_path))} if [ -n "${{CODEX_LAB_HOME:-}}" ]; then LAB_HOME="$CODEX_LAB_HOME" elif [ -n "${{HOME:-}}" ]; then LAB_HOME="$HOME/.codex-lab" else - echo "HOME is unavailable; refusing to resolve the Codex Lab daemon." >&2 + echo "HOME is unavailable; refusing to resolve the Codex Lab app-server." >&2 exit 1 fi home_chatgpt_app= @@ -255,7 +275,7 @@ def _launcher_script( fi if [ -n "$RUNNING_APP_INFO" ]; then echo "OpenAI coding desktop app is already running: $OFFICIAL_BUNDLE_IDENTIFIER" >&2 - echo "Quit it before launching Codex Lab so the persistent-daemon environment applies." >&2 + echo "Quit it before launching Codex Lab so the websocket environment applies." >&2 exit 1 fi @@ -381,42 +401,80 @@ def _launcher_script( /bin/rm -f "$MANAGED_PROVENANCE_FILE" trap - EXIT HUP INT TERM -DAEMON_STATUS_FILE=$(/usr/bin/mktemp "${{TMPDIR:-/tmp}}/codex-lab-daemon.XXXXXX") -trap '/bin/rm -f "$DAEMON_STATUS_FILE"' EXIT HUP INT TERM -if ! CODEX_LAB_HOME="$LAB_HOME" "$MANAGED_CLI" app-server daemon version >"$DAEMON_STATUS_FILE" 2>/dev/null; then - echo "Persistent Codex Lab daemon is unavailable under $LAB_HOME." >&2 - echo "Start the managed daemon before launching Codex Lab." >&2 +SUPERVISOR_RUNNER="$LAB_HOME/$SUPERVISOR_RUNNER_RELATIVE_PATH" +if [ ! -x "$SUPERVISOR_RUNNER" ]; then + echo "Codex Lab app-server supervisor is not executable: $SUPERVISOR_RUNNER" >&2 exit 1 fi -daemon_status=$("$PLUTIL" -extract status raw -o - "$DAEMON_STATUS_FILE" 2>/dev/null || true) -daemon_backend=$("$PLUTIL" -extract backend raw -o - "$DAEMON_STATUS_FILE" 2>/dev/null || true) -daemon_managed_codex_path=$("$PLUTIL" -extract managedCodexPath raw -o - "$DAEMON_STATUS_FILE" 2>/dev/null || true) -daemon_socket_path=$("$PLUTIL" -extract socketPath raw -o - "$DAEMON_STATUS_FILE" 2>/dev/null || true) -daemon_app_server_version=$("$PLUTIL" -extract appServerVersion raw -o - "$DAEMON_STATUS_FILE" 2>/dev/null || true) -if [ "$daemon_status" != "running" ]; then - echo "Persistent Codex Lab daemon is not running under $LAB_HOME." >&2 +if ! "$SUPERVISOR_RUNNER" check >/dev/null 2>&1; then + echo "Codex Lab app-server supervisor validation failed under $LAB_HOME." >&2 exit 1 fi -expected_daemon_socket="$LAB_HOME/app-server-control/app-server-control.sock" -if [ "$daemon_backend" != "pid" ] \ - || [ ! "$daemon_managed_codex_path" -ef "$MANAGED_CLI" ] \ - || [ "$daemon_socket_path" != "$expected_daemon_socket" ] \ - || [ "$daemon_app_server_version" != "$version" ]; then - echo "Persistent Codex Lab daemon does not match the managed engine under $LAB_HOME." >&2 + +if ! user_id=$("$ID" -u); then + echo "Could not resolve the current user for the Codex Lab app-server." >&2 + exit 1 +fi +case "$user_id" in ''|*[!0-9]*) + echo "Current user id is malformed; refusing to inspect the Codex Lab app-server." >&2 + exit 1 +;; esac +supervisor_domain="gui/$user_id/$SUPERVISOR_LABEL" +if ! supervisor_state=$("$LAUNCHCTL" print "$supervisor_domain" 2>/dev/null); then + echo "Codex Lab app-server supervisor is not loaded: $supervisor_domain" >&2 + exit 1 +fi +launchd_state=$(printf '%s\n' "$supervisor_state" | /usr/bin/awk '$1 == "state" && $2 == "=" {{ print $3; exit }}') +supervisor_program=$(printf '%s\n' "$supervisor_state" | /usr/bin/awk '$1 == "program" && $2 == "=" {{ sub(/^[^=]*=[[:space:]]*/, ""); print; exit }}') +server_pid=$(printf '%s\n' "$supervisor_state" | /usr/bin/awk '$1 == "pid" && $2 == "=" {{ gsub(/;$/, "", $3); print $3; exit }}') +if [ "$launchd_state" != "running" ]; then + echo "Codex Lab app-server supervisor is not running: $supervisor_domain" >&2 + exit 1 +fi +if [ -z "$supervisor_program" ] || [ ! "$supervisor_program" -ef "$SUPERVISOR_RUNNER" ]; then + echo "Codex Lab launchd service does not use the managed supervisor runner." >&2 + exit 1 +fi +case "$server_pid" in ''|*[!0-9]*) + echo "Codex Lab launchd service did not report a valid app-server pid." >&2 + exit 1 +;; esac +if [ "$server_pid" -le 0 ] || ! /bin/kill -0 "$server_pid" 2>/dev/null; then + echo "Codex Lab launchd app-server pid is not alive: $server_pid" >&2 + exit 1 +fi +server_executable=$("$LSOF" -a -p "$server_pid" -d txt -Fn 2>/dev/null \ + | /usr/bin/awk 'substr($0, 1, 1) == "n" {{ print substr($0, 2); exit }}') +if [ -z "$server_executable" ] || [ ! "$server_executable" -ef "$MANAGED_CLI" ]; then + echo "Codex Lab launchd app-server does not execute the managed engine." >&2 + exit 1 +fi +if ! server_command=$("$PS" -ww -p "$server_pid" -o command= 2>/dev/null); then + echo "Could not inspect the Codex Lab launchd app-server command." >&2 + exit 1 +fi +expected_server_command="$MANAGED_CLI app-server --remote-control --listen $LISTEN_URL" +if [ "$server_command" != "$expected_server_command" ]; then + echo "Codex Lab launchd app-server command does not match the pinned websocket service." >&2 + exit 1 +fi +if ! "$LSOF" -nP -a -p "$server_pid" \ + -iTCP@"$LISTEN_HOST":"$LISTEN_PORT" -sTCP:LISTEN -Fp 2>/dev/null \ + | /usr/bin/grep -qx "p$server_pid"; then + echo "Codex Lab launchd app-server does not own $LISTEN_URL." >&2 exit 1 fi -/bin/rm -f "$DAEMON_STATUS_FILE" -trap - EXIT HUP INT TERM echo "Selected OpenAI coding desktop app: $CODEX_APP" >&2 echo "Codex Lab CLI provenance: commit=$source_commit dirty=$dirty_state profile=$build_profile channel=$build_channel version=$version" >&2 -unset CODEX_CLI_PATH CODEX_APP_SERVER_FORCE_CLI +unset CODEX_CLI_PATH CODEX_APP_SERVER_FORCE_CLI CODEX_APP_SERVER_USE_LOCAL_DAEMON CODEX_APP_SERVER_WS_URL exec "$OPEN" -n \ --env "CODEX_CLI_PATH=" \ --env "CODEX_APP_SERVER_FORCE_CLI=" \ --env "CODEX_HOME=$LAB_HOME" \ --env "CODEX_LAB_HOME=$LAB_HOME" \ - --env "CODEX_APP_SERVER_USE_LOCAL_DAEMON=1" \ + --env "CODEX_APP_SERVER_USE_LOCAL_DAEMON=" \ + --env "CODEX_APP_SERVER_WS_URL=$WEBSOCKET_URL" \ "$CODEX_APP" """ diff --git a/scripts/codex_lab_package/live_smoke.py b/scripts/codex_lab_package/live_smoke.py index b73745b6116..07736ab2347 100644 --- a/scripts/codex_lab_package/live_smoke.py +++ b/scripts/codex_lab_package/live_smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prove that Codex Lab launches against its persistent local daemon.""" +"""Prove that Codex Lab launches against its supervised websocket app-server.""" import argparse import ctypes @@ -9,6 +9,7 @@ import os from pathlib import Path import re +import struct import subprocess import sys import time @@ -27,8 +28,14 @@ SOURCE_COMMIT_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") MANAGED_CLI_RELATIVE_PATH = Path("packages/standalone/current/codex") STABILITY_WINDOW_SECONDS = 5.0 +STDIO_STABILITY_WINDOW_SECONDS = STABILITY_WINDOW_SECONDS MIN_TIMEOUT_SECONDS = 10.0 MAX_DESKTOP_LOG_BYTES = 512 * 1024 +MAX_PROCESS_ARGUMENT_BYTES = 4 * 1024 * 1024 +APP_SERVER_WEBSOCKET_URL = "ws://127.0.0.1:4766/rpc" +CTL_KERN = 1 +KERN_ARGMAX = 8 +KERN_PROCARGS2 = 49 def main() -> None: @@ -87,15 +94,19 @@ def run_live_smoke(app_dir: Path, timeout_seconds: float) -> dict[str, Any]: selected_app = Path(selected_app_from_launcher_output(launch.stderr)).resolve() gui_executable = official_app_executable_path(selected_app) + bundled_cli_path = (selected_app / "Contents/Resources/codex").resolve() expected_environment = { - "CODEX_APP_SERVER_USE_LOCAL_DAEMON": "1", "CODEX_APP_SERVER_FORCE_CLI": "", + "CODEX_APP_SERVER_USE_LOCAL_DAEMON": "", + "CODEX_APP_SERVER_WS_URL": APP_SERVER_WEBSOCKET_URL, "CODEX_CLI_PATH": "", "CODEX_HOME": str(lab_home), "CODEX_LAB_HOME": str(lab_home), } stable_since = None + stdio_first_seen: dict[int, float] = {} while time.monotonic() < deadline: + now = time.monotonic() rows = read_process_rows() gui_pids = { pid @@ -107,29 +118,43 @@ def run_live_smoke(app_dir: Path, timeout_seconds: float) -> dict[str, Any]: for pid in gui_pids if process_has_environment(pid, expected_environment) ) - new_gui_app_servers = [ - pid + new_gui_app_servers = { + pid: command for pid, _ppid, command in rows if pid not in before_pids and is_serving_app_server_command(command) + and process_executable_path(pid) == bundled_cli_path and process_has_ancestor(pid, gui_pids, rows) - ] - if new_gui_app_servers: + } + stdio_first_seen = { + pid: stdio_first_seen.get(pid, now) for pid in new_gui_app_servers + } + persistent_stdio_servers = { + pid: new_gui_app_servers[pid] + for pid, first_seen in stdio_first_seen.items() + if now - first_seen >= STDIO_STABILITY_WINDOW_SECONDS + } + if persistent_stdio_servers: + details = "; ".join( + f"pid={pid} command={command[:512]}" + for pid, command in sorted(persistent_stdio_servers.items()) + ) raise RuntimeError( - "official app launched a bundled stdio app-server instead of the persistent daemon" + "official app launched a persistent bundled stdio app-server " + f"instead of the supervised websocket service: {details}" ) managed_servers = sorted(matching_app_server_pids(rows, managed_cli_path)) transport_proof = desktop_transport_proof(matching_gui_pids) if matching_gui_pids and managed_servers and transport_proof is not None: - stable_since = stable_since or time.monotonic() - if time.monotonic() - stable_since >= STABILITY_WINDOW_SECONDS: + stable_since = stable_since or now + if now - stable_since >= STABILITY_WINDOW_SECONDS: return { "appServerExecutablePath": str(managed_cli_path.resolve()), "appServerPid": managed_servers[0], **transport_proof, "guiPids": matching_gui_pids, "managedProvenance": managed_provenance, - "mode": "persistentLocalDaemon", + "mode": "supervisedWebsocket", "officialAppPath": str(selected_app), "provenance": provenance, "schemaVersion": 1, @@ -255,7 +280,7 @@ def validate_matching_build_provenance( def read_process_rows() -> list[tuple[int, int, str]]: output = subprocess.check_output( - ["/bin/ps", "-axo", "pid=,ppid=,command="], text=True + ["/bin/ps", "-ww", "-axo", "pid=,ppid=,command="], text=True ) rows = [] for line in output.splitlines(): @@ -309,32 +334,108 @@ def process_executable_path(pid: int) -> Path | None: def process_has_environment( pid: int, expected: dict[str, str], - reader: Callable[[int], str] | None = None, + reader: Callable[[int], dict[str, str]] | None = None, *, forbidden: set[str] | None = None, ) -> bool: environment_reader = reader or read_process_environment try: - process = environment_reader(pid) - except (OSError, subprocess.SubprocessError): + environment = environment_reader(pid) + except (OSError, ValueError, subprocess.SubprocessError): return False has_expected = all( - re.search(rf"(?:^|\s){re.escape(name)}={re.escape(value)}(?:\s|$)", process) + name in environment and environment[name] == value for name, value in expected.items() ) forbidden = forbidden or set() - has_forbidden = any( - re.search(rf"(?:^|\s){re.escape(name)}=", process) for name in forbidden - ) + has_forbidden = any(name in environment for name in forbidden) return has_expected and not has_forbidden -def read_process_environment(pid: int) -> str: - return subprocess.check_output( - ["/bin/ps", "eww", "-p", str(pid), "-o", "command="], text=True +def read_process_environment(pid: int) -> dict[str, str]: + libc = ctypes.CDLL(ctypes.util.find_library("c") or None, use_errno=True) + sysctl = libc.sysctl + sysctl.argtypes = [ + ctypes.POINTER(ctypes.c_int), + ctypes.c_uint, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), + ctypes.c_void_p, + ctypes.c_size_t, + ] + sysctl.restype = ctypes.c_int + argmax_mib = (ctypes.c_int * 2)(CTL_KERN, KERN_ARGMAX) + argmax = ctypes.c_int() + argmax_size = ctypes.c_size_t(ctypes.sizeof(argmax)) + if ( + sysctl( + argmax_mib, + len(argmax_mib), + ctypes.byref(argmax), + ctypes.byref(argmax_size), + None, + 0, + ) + != 0 + ): + _raise_process_environment_error(pid) + if argmax.value <= 0 or argmax.value > MAX_PROCESS_ARGUMENT_BYTES: + raise ValueError("kernel process argument limit is invalid") + + mib = (ctypes.c_int * 3)(CTL_KERN, KERN_PROCARGS2, pid) + size = ctypes.c_size_t(argmax.value) + buffer = ctypes.create_string_buffer(argmax.value) + if sysctl(mib, len(mib), buffer, ctypes.byref(size), None, 0) != 0: + _raise_process_environment_error(pid) + if size.value == 0 or size.value > argmax.value: + raise ValueError(f"process {pid} argument data has an invalid size") + return _parse_process_environment(buffer.raw[: size.value]) + + +def _raise_process_environment_error(pid: int) -> None: + error_number = ctypes.get_errno() + raise OSError( + error_number, + f"could not read environment for process {pid}: {os.strerror(error_number)}", ) +def _parse_process_environment(data: bytes) -> dict[str, str]: + integer_size = struct.calcsize("=i") + if len(data) < integer_size: + raise ValueError("process argument data is truncated") + argument_count = struct.unpack_from("=i", data)[0] + if argument_count < 0: + raise ValueError("process argument count is invalid") + cursor = integer_size + _executable, cursor = _read_process_argument(data, cursor) + while cursor < len(data) and data[cursor] == 0: + cursor += 1 + for _ in range(argument_count): + _argument, cursor = _read_process_argument(data, cursor) + while cursor < len(data) and data[cursor] == 0: + cursor += 1 + + environment: dict[str, str] = {} + while cursor < len(data) and data[cursor] != 0: + raw_entry, cursor = _read_process_argument(data, cursor) + entry = os.fsdecode(raw_entry) + if "=" not in entry: + raise ValueError("process environment contains a malformed entry") + name, value = entry.split("=", 1) + if not name: + raise ValueError("process environment contains an invalid variable name") + environment.setdefault(name, value) + return environment + + +def _read_process_argument(data: bytes, cursor: int) -> tuple[bytes, int]: + end = data.find(b"\0", cursor) + if end < 0: + raise ValueError("process argument data is not null terminated") + return data[cursor:end], end + 1 + + def official_app_executable_path(app_path: Path) -> Path: executable = subprocess.check_output( [ diff --git a/scripts/codex_lab_package/smoke.py b/scripts/codex_lab_package/smoke.py index dc02e70777c..013ee4888c9 100644 --- a/scripts/codex_lab_package/smoke.py +++ b/scripts/codex_lab_package/smoke.py @@ -71,17 +71,26 @@ def smoke_check( launcher = launcher_path.read_text(encoding="utf-8") _require_contains( - launcher, "unset CODEX_CLI_PATH CODEX_APP_SERVER_FORCE_CLI", launcher_path + launcher, + "unset CODEX_CLI_PATH CODEX_APP_SERVER_FORCE_CLI CODEX_APP_SERVER_USE_LOCAL_DAEMON CODEX_APP_SERVER_WS_URL", + launcher_path, ) _require_contains(launcher, '--env "CODEX_CLI_PATH="', launcher_path) _require_contains(launcher, '--env "CODEX_APP_SERVER_FORCE_CLI="', launcher_path) _require_not_contains(launcher, '--env "CODEX_CLI_PATH=$LAB_CLI"', launcher_path) _require_contains(launcher, "CODEX_HOME=$LAB_HOME", launcher_path) _require_contains(launcher, "CODEX_LAB_HOME=$LAB_HOME", launcher_path) - _require_contains(launcher, "CODEX_APP_SERVER_USE_LOCAL_DAEMON=1", launcher_path) - _require_contains(launcher, "app-server daemon version", launcher_path) - _require_contains(launcher, "managedCodexPath", launcher_path) - _require_contains(launcher, "appServerVersion", launcher_path) + _require_contains(launcher, "CODEX_APP_SERVER_USE_LOCAL_DAEMON=", launcher_path) + _require_contains(launcher, "CODEX_APP_SERVER_WS_URL=$WEBSOCKET_URL", launcher_path) + _require_contains(launcher, "ws://127.0.0.1:4766/rpc", launcher_path) + _require_contains(launcher, "dev.everycode.codex-lab.app-server.v1", launcher_path) + _require_contains(launcher, "launchctl", launcher_path) + _require_contains(launcher, "supervisor/v1/codex-lab-app-server", launcher_path) + _require_contains(launcher, "-sTCP:LISTEN", launcher_path) + _require_not_contains( + launcher, "CODEX_APP_SERVER_USE_LOCAL_DAEMON=1", launcher_path + ) + _require_not_contains(launcher, "app-server daemon version", launcher_path) _require_contains(launcher, "packages/standalone/current/codex", launcher_path) _require_contains( launcher, "Managed Codex Lab engine build does not match", launcher_path diff --git a/scripts/codex_lab_package/supervisor.py b/scripts/codex_lab_package/supervisor.py index 8c7d8aa898d..1b424030024 100644 --- a/scripts/codex_lab_package/supervisor.py +++ b/scripts/codex_lab_package/supervisor.py @@ -17,15 +17,19 @@ import time from typing import Any +from .layout import APP_SERVER_LABEL +from .layout import APP_SERVER_LISTEN_HOST +from .layout import APP_SERVER_LISTEN_PORT +from .layout import APP_SERVER_RUNNER_RELATIVE_PATH from .layout import MAX_PROVENANCE_BYTES from .live_smoke import process_executable_path from .live_smoke import read_cli_provenance -DEFAULT_LABEL = "dev.everycode.codex-lab.app-server.v1" +DEFAULT_LABEL = APP_SERVER_LABEL LEGACY_LABEL = "dev.everycode.codex-lab.daemon-supervisor" -DEFAULT_LISTEN_HOST = "127.0.0.1" -DEFAULT_LISTEN_PORT = 4766 +DEFAULT_LISTEN_HOST = APP_SERVER_LISTEN_HOST +DEFAULT_LISTEN_PORT = APP_SERVER_LISTEN_PORT MANAGED_CLI_RELATIVE_PATH = Path("packages/standalone/current/codex") @@ -61,11 +65,11 @@ def managed_cli(self) -> Path: @property def supervisor_dir(self) -> Path: - return self.lab_home / "supervisor/v1" + return self.lab_home / APP_SERVER_RUNNER_RELATIVE_PATH.parent @property def runner(self) -> Path: - return self.supervisor_dir / "codex-lab-app-server" + return self.lab_home / APP_SERVER_RUNNER_RELATIVE_PATH @property def plist(self) -> Path: diff --git a/scripts/codex_lab_package/test_layout.py b/scripts/codex_lab_package/test_layout.py index 40d50d920a6..41d18439138 100644 --- a/scripts/codex_lab_package/test_layout.py +++ b/scripts/codex_lab_package/test_layout.py @@ -225,15 +225,6 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N printf '{"schema_version":1,"version":"1.2.3","source_commit":"%s","dirty_state":"clean","build_profile":"release","build_channel":"lab","executable_path":"%s"}\\n' "__SOURCE_COMMIT__" "$executable_path" exit 0 fi -if [ "${1:-}" = app-server ] && [ "${2:-}" = daemon ] && [ "${3:-}" = version ]; then - if [ "${DAEMON_VERSION_FAILURE:-}" = 1 ]; then - exit 1 - fi - managed_codex_path=${DAEMON_MANAGED_PATH:-$0} - app_server_version=${DAEMON_APP_SERVER_VERSION:-1.2.3} - printf '{"status":"running","backend":"pid","managedCodexPath":"%s","managedCodexVersion":"0.0.0","socketPath":"%s/app-server-control/app-server-control.sock","cliVersion":"0.0.0","appServerVersion":"%s"}\\n' "$managed_codex_path" "$CODEX_LAB_HOME" "$app_server_version" - exit 0 -fi case " $* " in *" app-server "*) printf '%s\\n' "$0" > "$CHILD_LOG" ;; *) exit 2 ;; @@ -288,6 +279,46 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N path = sys.argv[-1] with open(path, "rb") as handle: print(hashlib.sha256(handle.read()).hexdigest(), path) +""", + encoding="utf-8", + ) + fake_id = root / "id" + fake_id.write_text("#!/bin/sh\nprintf '501\\n'\n", encoding="utf-8") + fake_launchctl = root / "launchctl" + fake_launchctl.write_text( + """#!/bin/sh +[ "${SUPERVISOR_LAUNCHCTL_FAILURE:-}" != 1 ] || exit 1 +cat < N *) shift ;; esac done -printf 'cli=%s\\nforce_cli=%s\\ncodex_home=%s\\nlab_home=%s\\nlocal_daemon=%s\\nargs=%s\\n' \\ +printf 'cli=%s\\nforce_cli=%s\\ncodex_home=%s\\nlab_home=%s\\nlocal_daemon=%s\\nwebsocket=%s\\nargs=%s\\n' \\ "${CODEX_CLI_PATH:-}" "${CODEX_APP_SERVER_FORCE_CLI:-}" \\ "$CODEX_HOME" "$CODEX_LAB_HOME" "$CODEX_APP_SERVER_USE_LOCAL_DAEMON" \\ - "$args" > "$OPEN_LOG" + "$CODEX_APP_SERVER_WS_URL" "$args" > "$OPEN_LOG" """, encoding="utf-8", ) for executable in ( fake_plutil, fake_codesign, + fake_id, + fake_launchctl, fake_lsappinfo, + fake_lsof, + fake_ps, fake_shasum, fake_open, ): @@ -331,9 +366,13 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N expected_source_commit=source_commit, expected_cli_version="1.2.3", codesign_path=fake_codesign, + id_path=fake_id, + launchctl_path=fake_launchctl, lsappinfo_path=fake_lsappinfo, + lsof_path=fake_lsof, open_path=fake_open, plutil_path=fake_plutil, + ps_path=fake_ps, shasum_path=fake_shasum, ), encoding="utf-8", @@ -343,8 +382,9 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N **os.environ, "CODEX_APP_SERVER_FORCE_CLI": "1", "CODEX_CLI_PATH": "/tmp/force-stdio", - "CODEX_LAB_HOME": str(root / "codex-lab-home"), + "CODEX_LAB_HOME": str(root / "Codex Lab Home"), "OPEN_LOG": str(open_log), + "SUPERVISOR_PID": str(os.getpid()), } managed_cli = ( Path(environment["CODEX_LAB_HOME"]) @@ -353,9 +393,23 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N managed_cli.parent.mkdir(parents=True) managed_cli.write_bytes(embedded_cli.read_bytes()) os.chmod(managed_cli, 0o755) - managed_cli_alias = root / "managed-codex-alias" - managed_cli_alias.symlink_to(managed_cli) - environment["DAEMON_MANAGED_PATH"] = str(managed_cli_alias) + supervisor_runner = ( + Path(environment["CODEX_LAB_HOME"]) + / "supervisor/v1/codex-lab-app-server" + ) + supervisor_runner.parent.mkdir(parents=True) + supervisor_runner.write_text( + """#!/bin/sh +if [ "${1:-}" = check ] && [ "${SUPERVISOR_CHECK_FAILURE:-}" != 1 ]; then + exit 0 +fi +exit 1 +""", + encoding="utf-8", + ) + os.chmod(supervisor_runner, 0o755) + environment["MANAGED_CLI"] = str(managed_cli) + environment["SUPERVISOR_RUNNER"] = str(supervisor_runner) completed = subprocess.run( [str(launcher)], @@ -374,7 +428,8 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N self.assertIn("force_cli=\n", open_contents) self.assertIn(f"codex_home={environment['CODEX_LAB_HOME']}", open_contents) self.assertIn(f"lab_home={environment['CODEX_LAB_HOME']}", open_contents) - self.assertIn("local_daemon=1", open_contents) + self.assertIn("local_daemon=\n", open_contents) + self.assertIn("websocket=ws://127.0.0.1:4766/rpc", open_contents) self.assertIn("--env CODEX_CLI_PATH=", open_contents) self.assertIn("--env CODEX_APP_SERVER_FORCE_CLI=", open_contents) self.assertIn( @@ -384,7 +439,11 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N f"--env CODEX_LAB_HOME={environment['CODEX_LAB_HOME']}", open_contents, ) - self.assertIn("--env CODEX_APP_SERVER_USE_LOCAL_DAEMON=1", open_contents) + self.assertIn("--env CODEX_APP_SERVER_USE_LOCAL_DAEMON=", open_contents) + self.assertIn( + "--env CODEX_APP_SERVER_WS_URL=ws://127.0.0.1:4766/rpc", + open_contents, + ) self.assertIn(str(official_app), open_contents) open_log.unlink() @@ -403,7 +462,7 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N self.assertFalse(open_log.exists()) environment.pop("PROVENANCE_EXECUTABLE_PATH") - environment["DAEMON_VERSION_FAILURE"] = "1" + environment["SUPERVISOR_CHECK_FAILURE"] = "1" completed = subprocess.run( [str(launcher)], check=False, @@ -412,13 +471,11 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N text=True, ) self.assertEqual(completed.returncode, 1) - self.assertIn( - "Persistent Codex Lab daemon is unavailable", completed.stderr - ) + self.assertIn("app-server supervisor validation failed", completed.stderr) self.assertFalse(open_log.exists()) - environment.pop("DAEMON_VERSION_FAILURE") - environment["DAEMON_APP_SERVER_VERSION"] = "9.9.9" + environment.pop("SUPERVISOR_CHECK_FAILURE") + environment["SUPERVISOR_STATE"] = "waiting" completed = subprocess.run( [str(launcher)], check=False, @@ -427,12 +484,23 @@ def test_launcher_executes_exact_cli_and_fails_closed_for_running_app(self) -> N text=True, ) self.assertEqual(completed.returncode, 1) - self.assertIn( - "Persistent Codex Lab daemon does not match", completed.stderr + self.assertIn("app-server supervisor is not running", completed.stderr) + self.assertFalse(open_log.exists()) + + environment.pop("SUPERVISOR_STATE") + environment["LISTENER_PID"] = "9876" + completed = subprocess.run( + [str(launcher)], + check=False, + env=environment, + stderr=subprocess.PIPE, + text=True, ) + self.assertEqual(completed.returncode, 1) + self.assertIn("does not own ws://127.0.0.1:4766", completed.stderr) self.assertFalse(open_log.exists()) - environment.pop("DAEMON_APP_SERVER_VERSION") + environment.pop("LISTENER_PID") managed_contents = managed_cli.read_text(encoding="utf-8") managed_cli.write_text( managed_contents.replace(source_commit, "b" * len(source_commit)), diff --git a/scripts/codex_lab_package/test_live_smoke.py b/scripts/codex_lab_package/test_live_smoke.py index 5b43f600e47..250302189ec 100644 --- a/scripts/codex_lab_package/test_live_smoke.py +++ b/scripts/codex_lab_package/test_live_smoke.py @@ -1,22 +1,55 @@ from pathlib import Path import math +import os +import struct +import subprocess import sys import tempfile +import time import unittest +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from codex_lab_package.live_smoke import matching_app_server_pids +from codex_lab_package.live_smoke import _parse_process_environment from codex_lab_package.live_smoke import desktop_transport_proof from codex_lab_package.live_smoke import is_serving_app_server_command from codex_lab_package.live_smoke import process_has_environment from codex_lab_package.live_smoke import process_has_ancestor +from codex_lab_package.live_smoke import read_process_environment +from codex_lab_package.live_smoke import read_process_rows from codex_lab_package.live_smoke import validate_cli_provenance from codex_lab_package.live_smoke import validate_matching_build_provenance from codex_lab_package.live_smoke import validate_timeout_seconds class LiveSmokeTest(unittest.TestCase): + @unittest.skipUnless(sys.platform == "darwin", "requires KERN_PROCARGS2") + def test_reads_real_child_environment_with_spaces(self) -> None: + process = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(5)"], + env={**os.environ, "CODEX_LAB_ENV_PROBE": "value with spaces"}, + ) + try: + time.sleep(0.1) + self.assertEqual( + read_process_environment(process.pid)["CODEX_LAB_ENV_PROBE"], + "value with spaces", + ) + finally: + process.terminate() + process.wait(timeout=5) + + @patch("codex_lab_package.live_smoke.subprocess.check_output") + def test_process_rows_request_untruncated_commands(self, check_output) -> None: + check_output.return_value = "10 1 /tmp/codex app-server\n" + + self.assertEqual(read_process_rows(), [(10, 1, "/tmp/codex app-server")]) + check_output.assert_called_once_with( + ["/bin/ps", "-ww", "-axo", "pid=,ppid=,command="], text=True + ) + def test_timeout_and_desktop_transport_proof(self) -> None: validate_timeout_seconds(10.0) for timeout_seconds in (9.9, math.inf, math.nan): @@ -114,16 +147,18 @@ def test_process_and_launcher_proof_helpers(self) -> None: self.assertTrue(is_serving_app_server_command(rows[2][2])) self.assertFalse(is_serving_app_server_command(rows[3][2])) self.assertTrue(process_has_ancestor(21, {20}, rows)) - process = ( - "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT " - "CODEX_HOME=/tmp/lab CODEX_APP_SERVER_USE_LOCAL_DAEMON=1" - ) + process = { + "CODEX_APP_SERVER_USE_LOCAL_DAEMON": "", + "CODEX_APP_SERVER_WS_URL": "ws://127.0.0.1:4766/rpc", + "CODEX_HOME": "/tmp/Codex Lab", + } self.assertTrue( process_has_environment( 20, { - "CODEX_APP_SERVER_USE_LOCAL_DAEMON": "1", - "CODEX_HOME": "/tmp/lab", + "CODEX_APP_SERVER_USE_LOCAL_DAEMON": "", + "CODEX_APP_SERVER_WS_URL": "ws://127.0.0.1:4766/rpc", + "CODEX_HOME": "/tmp/Codex Lab", }, lambda _pid: process, ) @@ -138,7 +173,7 @@ def test_process_and_launcher_proof_helpers(self) -> None: self.assertTrue( process_has_environment( 20, - {"CODEX_HOME": "/tmp/lab"}, + {"CODEX_HOME": "/tmp/Codex Lab"}, lambda _pid: process, forbidden={"CODEX_CLI_PATH"}, ) @@ -146,8 +181,20 @@ def test_process_and_launcher_proof_helpers(self) -> None: self.assertFalse( process_has_environment( 20, - {"CODEX_HOME": "/tmp/lab"}, - lambda _pid: process + " CODEX_CLI_PATH=/tmp/codex", + {"CODEX_HOME": "/tmp/Codex Lab"}, + lambda _pid: {**process, "CODEX_CLI_PATH": "/tmp/codex"}, forbidden={"CODEX_CLI_PATH"}, ) ) + + argument_data = ( + struct.pack("=i", 2) + + b"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT\0\0" + + b"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT\0--flag\0" + + b"CODEX_HOME=/tmp/Codex Lab\0CODEX_CLI_PATH=\0" + + b"CODEX_HOME=/tmp/ignored\0\0" + ) + self.assertEqual( + _parse_process_environment(argument_data), + {"CODEX_HOME": "/tmp/Codex Lab", "CODEX_CLI_PATH": ""}, + )