From d03bf33a55b1790c4039d3350309f53e1204923e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 25 May 2026 04:10:13 +0000 Subject: [PATCH] Expand codex app-server script --- README.md | 4 +- scripts/start-codex-app-server | 493 +++++++++++++++++++++++++++++---- 2 files changed, 443 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index efdd390..ad2ad96 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Docker Compose runs only the Go Telegram bot. Codex runs on the host through `co 2. Start the host Codex app-server: ```sh - scripts/start-codex-app-server + scripts/start-codex-app-server start ``` - The script starts Codex detached, writes `run/codex-app-server.pid`, logs to `run/codex-app-server.log`, and is idempotent if the socket is already live. + The script supports `start`, `stop`, `status`, and `check-updates [-y]`. `start` launches Codex detached, writes `run/codex-app-server.pid`, logs to `run/codex-app-server.log`, and is idempotent if the socket is already live. `check-updates` compares the local `codex` binary with the latest OpenAI Codex GitHub release; with `-y`, it downloads the matching platform archive, verifies the release digest when available, replaces the configured `CODEX_BIN`, and restarts the app-server if it was running. If the upgraded server fails to start, the script restores the previous binary and starts it again. 3. Add at least one Telegram user and workspace: diff --git a/scripts/start-codex-app-server b/scripts/start-codex-app-server index 9da2839..3612992 100755 --- a/scripts/start-codex-app-server +++ b/scripts/start-codex-app-server @@ -7,6 +7,8 @@ RUN_DIR="$ROOT/run" PID_FILE="$RUN_DIR/codex-app-server.pid" LOG_FILE="$RUN_DIR/codex-app-server.log" STDIN_FIFO="$RUN_DIR/codex-app-server.stdin" +CODEX_RELEASE_REPO="${CODEX_RELEASE_REPO:-openai/codex}" +INSTALL_PREFIX=() read_env_value() { local key="$1" @@ -21,74 +23,461 @@ HOST_CODEX_SOCKET="${HOST_CODEX_SOCKET:-$RUN_DIR/codex.sock}" mkdir -p "$RUN_DIR" chmod 700 "$RUN_DIR" -if [[ -f "$PID_FILE" ]]; then - old_pid="$(tr -cd '0-9' < "$PID_FILE" || true)" +usage() { + cat < [options] + +Commands: + start Start codex app-server if it is not already running. + stop Stop codex app-server and remove stale runtime files. + status Print whether codex app-server is running. + check-updates [-y] Check GitHub releases and optionally install the latest Codex binary. + +Environment: + CODEX_BIN Codex executable to replace. Defaults to the codex found on PATH. + CODEX_RELEASE_REPO GitHub repo for releases. Defaults to openai/codex. +USAGE +} + +pid_from_file() { + tr -cd '0-9' < "$PID_FILE" 2>/dev/null || true +} + +server_pid() { + local pid + pid="$(pid_from_file)" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + printf '%s\n' "$pid" + fi +} + +is_running() { + [[ -n "$(server_pid)" ]] +} + +remove_socket_if_safe() { + if [[ -e "$HOST_CODEX_SOCKET" ]]; then + if [[ -S "$HOST_CODEX_SOCKET" ]]; then + rm -f "$HOST_CODEX_SOCKET" + else + echo "socket path exists and is not a Unix socket: $HOST_CODEX_SOCKET" >&2 + return 1 + fi + fi +} + +show_log_excerpt() { + sed -n '1,120p' "$LOG_FILE" >&2 || true +} + +start_server() { + local old_pid pid start_codex_bin + old_pid="$(pid_from_file)" if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then if [[ -S "$HOST_CODEX_SOCKET" ]]; then echo "codex app-server already running: pid=$old_pid socket=$HOST_CODEX_SOCKET" - exit 0 + return 0 fi echo "pid $old_pid is running but socket is missing; refusing to start a second app-server" >&2 - exit 1 + return 1 fi rm -f "$PID_FILE" -fi + remove_socket_if_safe -if [[ -e "$HOST_CODEX_SOCKET" ]]; then - if [[ -S "$HOST_CODEX_SOCKET" ]]; then - rm -f "$HOST_CODEX_SOCKET" - else - echo "socket path exists and is not a Unix socket: $HOST_CODEX_SOCKET" >&2 - exit 1 + rm -f "$STDIN_FIFO" + mkfifo "$STDIN_FIFO" + chmod 600 "$STDIN_FIFO" + + : > "$LOG_FILE" + # Codex app-server currently exits if detached with stdin closed. A detached + # wrapper keeps a private FIFO writer open and then runs Codex on the host. + start_codex_bin="$(codex_bin)" + setsid -f bash -c ' + echo "$$" > "$3" + tail -f /dev/null > "$1" & + writer=$! + trap "kill $writer 2>/dev/null || true" EXIT + "$4" app-server --listen "$2" < "$1" + ' codex-app-server "$STDIN_FIFO" "unix://$HOST_CODEX_SOCKET" "$PID_FILE" "$start_codex_bin" >> "$LOG_FILE" 2>&1 + + for _ in $(seq 1 50); do + [[ -f "$PID_FILE" ]] && break + sleep 0.1 + done + + pid="$(pid_from_file)" + if [[ -z "$pid" ]]; then + echo "codex app-server did not write a pid file; log follows:" >&2 + show_log_excerpt + return 1 fi -fi -rm -f "$STDIN_FIFO" -mkfifo "$STDIN_FIFO" -chmod 600 "$STDIN_FIFO" + for _ in $(seq 1 100); do + if [[ -S "$HOST_CODEX_SOCKET" ]]; then + sleep 0.5 + if kill -0 "$pid" 2>/dev/null; then + echo "codex app-server started: pid=$pid socket=$HOST_CODEX_SOCKET log=$LOG_FILE" + return 0 + fi + fi + if ! kill -0 "$pid" 2>/dev/null; then + echo "codex app-server exited before staying ready; log follows:" >&2 + show_log_excerpt + rm -f "$PID_FILE" + return 1 + fi + sleep 0.1 + done -: > "$LOG_FILE" -# Codex app-server currently exits if detached with stdin closed. A detached -# wrapper keeps a private FIFO writer open and then runs Codex on the host. -setsid -f bash -c ' - echo "$$" > "$3" - tail -f /dev/null > "$1" & - writer=$! - trap "kill $writer 2>/dev/null || true" EXIT - codex app-server --listen "$2" < "$1" -' codex-app-server "$STDIN_FIFO" "unix://$HOST_CODEX_SOCKET" "$PID_FILE" >> "$LOG_FILE" 2>&1 + echo "codex app-server did not create socket within 10 seconds; log follows:" >&2 + show_log_excerpt + return 1 +} -for _ in $(seq 1 50); do +stop_server() { + local pid + pid="$(server_pid)" + if [[ -z "$pid" ]]; then + rm -f "$PID_FILE" "$STDIN_FIFO" + remove_socket_if_safe + echo "codex app-server is not running" + return 0 + fi + + echo "stopping codex app-server: pid=$pid" + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$PID_FILE" "$STDIN_FIFO" + remove_socket_if_safe + echo "codex app-server stopped" + return 0 + fi + sleep 0.1 + done + + echo "codex app-server did not stop after 5 seconds; killing pid=$pid" >&2 + kill -KILL "$pid" 2>/dev/null || true + for _ in $(seq 1 20); do + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$PID_FILE" "$STDIN_FIFO" + remove_socket_if_safe + echo "codex app-server stopped" + return 0 + fi + sleep 0.1 + done + echo "failed to stop codex app-server pid=$pid" >&2 + return 1 +} + +status_server() { + local pid + pid="$(server_pid)" + if [[ -n "$pid" ]]; then + if [[ -S "$HOST_CODEX_SOCKET" ]]; then + echo "codex app-server running: pid=$pid socket=$HOST_CODEX_SOCKET log=$LOG_FILE" + return 0 + fi + echo "codex app-server pid=$pid is running but socket is missing: $HOST_CODEX_SOCKET" >&2 + return 2 + fi if [[ -f "$PID_FILE" ]]; then - break + echo "codex app-server not running; stale pid file: $PID_FILE" >&2 + return 1 fi - sleep 0.1 -done + echo "codex app-server not running" + return 1 +} -pid="$(tr -cd '0-9' < "$PID_FILE" 2>/dev/null || true)" -if [[ -z "$pid" ]]; then - echo "codex app-server did not write a pid file; log follows:" >&2 - sed -n '1,120p' "$LOG_FILE" >&2 || true - exit 1 -fi +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + return 1 + fi +} -for _ in $(seq 1 100); do - if [[ -S "$HOST_CODEX_SOCKET" ]]; then - sleep 0.5 - if kill -0 "$pid" 2>/dev/null; then - echo "codex app-server started: pid=$pid socket=$HOST_CODEX_SOCKET log=$LOG_FILE" - exit 0 +codex_bin() { + if [[ -n "${CODEX_BIN:-}" ]]; then + printf '%s\n' "$CODEX_BIN" + return 0 + fi + command -v codex +} + +codex_version_from() { + local bin="$1" line + line="$($bin --version 2>/dev/null || true)" + printf '%s\n' "$line" | sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1 +} + +release_target() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + case "$os:$arch" in + Linux:x86_64|Linux:amd64) echo "x86_64-unknown-linux-musl" ;; + Linux:aarch64|Linux:arm64) echo "aarch64-unknown-linux-musl" ;; + Darwin:x86_64|Darwin:amd64) echo "x86_64-apple-darwin" ;; + Darwin:aarch64|Darwin:arm64) echo "aarch64-apple-darwin" ;; + *) echo "unsupported platform for Codex binary release: $os $arch" >&2; return 1 ;; + esac +} + +version_gt() { + python3 - "$1" "$2" <<'PY' +import sys + +def parse(v): + return tuple(int(p) for p in v.split('.')[:3]) + +sys.exit(0 if parse(sys.argv[1]) > parse(sys.argv[2]) else 1) +PY +} + +latest_release_info() { + local target="$1" json_file="$2" + python3 - "$target" "$json_file" <<'PY' +import json +import sys + +target, path = sys.argv[1], sys.argv[2] +asset_name = f"codex-{target}.tar.gz" +with open(path, "r", encoding="utf-8") as f: + release = json.load(f) +tag = release.get("tag_name", "") +version = tag +if version.startswith("rust-v"): + version = version[6:] +elif version.startswith("v"): + version = version[1:] +for asset in release.get("assets", []): + if asset.get("name") == asset_name: + print(version) + print(tag) + print(asset.get("browser_download_url", "")) + print(asset.get("digest", "")) + raise SystemExit(0) +print(f"release {tag or ''} has no asset named {asset_name}", file=sys.stderr) +raise SystemExit(1) +PY +} + +verify_digest() { + local file="$1" digest="$2" expected + if [[ -z "$digest" ]]; then + echo "release asset has no digest; skipping checksum verification" >&2 + return 0 + fi + if [[ "$digest" != sha256:* ]]; then + echo "unsupported release digest format: $digest" >&2 + return 1 + fi + expected="${digest#sha256:}" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$expected" "$file" | sha256sum -c - >/dev/null + elif command -v shasum >/dev/null 2>&1; then + local actual + actual="$(shasum -a 256 "$file" | awk '{print $1}')" + [[ "$actual" == "$expected" ]] + else + echo "missing sha256sum or shasum for checksum verification" >&2 + return 1 + fi +} + +extract_codex_binary() { + local archive="$1" dest="$2" found + mkdir -p "$dest/extract" + tar -xzf "$archive" -C "$dest/extract" + found="$(find "$dest/extract" -type f -name codex -print | head -n 1)" + if [[ -z "$found" ]]; then + echo "downloaded archive does not contain a codex binary" >&2 + return 1 + fi + chmod +x "$found" + printf '%s\n' "$found" +} + +run_install() { + if [[ "${#INSTALL_PREFIX[@]}" -gt 0 ]]; then + "${INSTALL_PREFIX[@]}" "$@" + else + "$@" + fi +} + +choose_install_prefix() { + local bin="$1" dir + dir="$(dirname "$bin")" + INSTALL_PREFIX=() + if [[ -w "$dir" && ( ! -e "$bin" || -w "$bin" ) ]]; then + return 0 + fi + if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + INSTALL_PREFIX=(sudo) + return 0 + fi + echo "cannot replace $bin without write permission" >&2 + echo "Run with suitable privileges or set CODEX_BIN to a user-writable Codex binary path." >&2 + return 1 +} + +install_candidate() { + local candidate="$1" bin="$2" backup="$3" tmp_new="$bin.new.$$" + choose_install_prefix "$bin" + run_install cp -p "$bin" "$backup" + run_install install -m 0755 "$candidate" "$tmp_new" + run_install mv -f "$tmp_new" "$bin" +} + +restore_backup() { + local bin="$1" backup="$2" tmp_failed="$bin.failed.$$" + if [[ ! -e "$backup" ]]; then + echo "backup missing; cannot restore $bin" >&2 + return 1 + fi + choose_install_prefix "$bin" + if [[ -e "$bin" ]]; then + run_install mv "$bin" "$tmp_failed" || true + fi + run_install mv "$backup" "$bin" +} + +confirm_upgrade() { + local local_version="$1" latest_version="$2" bin="$3" + if [[ "${ASSUME_YES:-0}" == "1" ]]; then + return 0 + fi + if [[ ! -t 0 ]]; then + echo "Codex $latest_version is available for $bin; rerun with check-updates -y to upgrade." >&2 + return 2 + fi + local reply + read -r -p "Upgrade Codex $local_version -> $latest_version at $bin? [y/N] " reply + [[ "$reply" == "y" || "$reply" == "Y" || "$reply" == "yes" || "$reply" == "YES" ]] +} + +check_updates() { + ASSUME_YES=0 + while [[ $# -gt 0 ]]; do + case "$1" in + -y|--yes) ASSUME_YES=1 ;; + -h|--help) usage; return 0 ;; + *) echo "unknown check-updates option: $1" >&2; usage; return 2 ;; + esac + shift + done + + require_cmd curl + require_cmd tar + require_cmd python3 + + local bin local_version target json latest_version latest_tag download_url digest archive tmp candidate candidate_version was_running backup + bin="$(codex_bin)" + if [[ -z "$bin" ]]; then + echo "codex executable not found; set CODEX_BIN" >&2 + return 1 + fi + if [[ "$bin" != /* ]]; then + echo "CODEX_BIN must be an absolute path: $bin" >&2 + return 1 + fi + local_version="$(codex_version_from "$bin")" + if [[ -z "$local_version" ]]; then + echo "could not determine local Codex version from $bin" >&2 + return 1 + fi + + target="$(release_target)" + tmp="$(mktemp -d "$RUN_DIR/codex-update.XXXXXX")" + trap "rm -rf '$tmp'" EXIT + json="$tmp/latest.json" + curl -fsSL "https://api.github.com/repos/$CODEX_RELEASE_REPO/releases/latest" -o "$json" + mapfile -t release_info < <(latest_release_info "$target" "$json") + latest_version="${release_info[0]:-}" + latest_tag="${release_info[1]:-}" + download_url="${release_info[2]:-}" + digest="${release_info[3]:-}" + if [[ -z "$latest_version" || -z "$download_url" ]]; then + echo "could not determine latest Codex release for $target" >&2 + return 1 + fi + + if ! version_gt "$latest_version" "$local_version"; then + echo "Codex is already current: $local_version (latest $latest_version)" + return 0 + fi + echo "Codex update available: $local_version -> $latest_version ($latest_tag)" + confirm_upgrade "$local_version" "$latest_version" "$bin" + + archive="$tmp/codex-$target.tar.gz" + curl -fL "$download_url" -o "$archive" + verify_digest "$archive" "$digest" + candidate="$(extract_codex_binary "$archive" "$tmp")" + candidate_version="$(codex_version_from "$candidate")" + if [[ "$candidate_version" != "$latest_version" ]]; then + echo "downloaded Codex version $candidate_version does not match release $latest_version" >&2 + return 1 + fi + + was_running=0 + if is_running; then + was_running=1 + fi + backup="$bin.bak.$(date -u +%Y%m%d%H%M%S)" + + if [[ "$was_running" == "1" ]]; then + stop_server + fi + + if ! install_candidate "$candidate" "$bin" "$backup"; then + if [[ "$was_running" == "1" ]]; then + start_server || true + fi + return 1 + fi + + if [[ "$was_running" == "1" ]]; then + if ! start_server; then + echo "new Codex failed to start; restoring previous binary" >&2 + restore_backup "$bin" "$backup" || true + start_server || true + return 1 fi fi - if ! kill -0 "$pid" 2>/dev/null; then - echo "codex app-server exited before staying ready; log follows:" >&2 - sed -n '1,120p' "$LOG_FILE" >&2 || true - rm -f "$PID_FILE" - exit 1 - fi - sleep 0.1 -done -echo "codex app-server did not create socket within 10 seconds; log follows:" >&2 -sed -n '1,120p' "$LOG_FILE" >&2 || true -exit 1 + echo "Codex upgraded: $local_version -> $latest_version" + echo "backup: $backup" +} + +cmd="${1:-start}" +case "$cmd" in + start) + shift || true + if [[ $# -ne 0 ]]; then usage; exit 2; fi + start_server + ;; + stop) + shift || true + if [[ $# -ne 0 ]]; then usage; exit 2; fi + stop_server + ;; + status) + shift || true + if [[ $# -ne 0 ]]; then usage; exit 2; fi + status_server + ;; + check-updates) + shift || true + check_updates "$@" + ;; + -h|--help|help) + usage + ;; + *) + echo "unknown command: $cmd" >&2 + usage >&2 + exit 2 + ;; +esac