From 165fc483059e3bb55612bfe1d59e1865d8c1e41b Mon Sep 17 00:00:00 2001 From: editnori Date: Sat, 11 Jul 2026 03:25:51 -0500 Subject: [PATCH 1/5] Add subscription-backed GPT lanes for Claude Code --- CHANGELOG.md | 8 + README.md | 102 +++++- bin/claude-gpt | 485 +++++++++++++++++++++++++ bin/codex-auth | 1 + install.sh | 95 +++++ lib/codex-auth/claude-gpt.sh | 111 ++++++ lib/codex-auth/usage.sh | 4 +- tests/run.sh | 548 ++++++++++++++++++++++++++++- tui/pyproject.toml | 2 +- tui/src/codex_auth_tui/__init__.py | 2 +- tui/uv.lock | 2 +- 11 files changed, 1353 insertions(+), 7 deletions(-) create mode 100755 bin/claude-gpt create mode 100644 lib/codex-auth/claude-gpt.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2b4e8..75b8bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.2.0 - 2026-07-11 + +- Added `claude-gpt`, an opt-in Claude Code harness backed by saved ChatGPT/Codex subscription auth. +- Kept refresh tokens under `codex-auth` ownership by giving each local proxy an ephemeral access-only lease pinned to one account identity. +- Kept proxy routing child-only, forwarded Claude Code arguments unchanged, and avoided switching the active Codex profile or rewriting cswap configuration. +- Mapped Claude Code's Opus, Sonnet, and Haiku tiers to the `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` lanes, each overridable by a launcher flag or `CLAUDE_GPT_*` variable. +- Added clear GPT labels and `effort,xhigh_effort,max_effort` capability declarations to every lane, including one custom `/model` option, `GPT-5.6 Sol Ultra Fast` (`gpt-5.6-sol-fast`); `claude-gpt --effort` sets only the starting level, `/effort` stays dynamic, and the launcher never pins `CCP_CODEX_EFFORT`. + ## 0.1.1 - 2026-07-10 - Added `codex-auth reauth ` and a clickable, keyboard-accessible TUI sign-in flow. diff --git a/README.md b/README.md index 094252a..1d15836 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ All captures use the real Textual app with an in-memory synthetic backend. - [`uv`](https://docs.astral.sh/uv/) for the isolated Textual environment. - `crontab` is optional; without it, run `codex-auth maintain` after a direct curl update. - Building the patched Codex generation also needs a Rust/Cargo toolchain and normal native build dependencies. +- Running GPT inside Claude Code also needs Claude Code and [`claude-code-proxy` 0.1.10](https://github.com/raine/claude-code-proxy/releases/tag/v0.1.10). ## Install @@ -54,6 +55,7 @@ That installs: - `codex-auth`, the profile manager and rolling runner - `codex-auth-tui`, the full-screen watcher in a private project `.venv` +- `claude-gpt`, an opt-in Claude Code launcher backed by a saved ChatGPT/Codex subscription profile - `codex`, an optional shim that runs `codex-auth auto --quiet --no-background` and then starts the real Codex binary - patched-Codex selection: when a matching generation exists, the shim uses it with in-process rolling auth enabled; when Codex updates, the shim immediately runs stock Codex and starts one detached patch build - a marked, idempotent cron entry that runs `codex-auth maintain --quiet` once per minute without replacing existing cron jobs @@ -66,8 +68,105 @@ If you only want the manager and not the `codex` shim: ./install.sh ``` +`claude-code-proxy` is a separate third-party dependency. `install.sh` downloads +the pinned 0.1.10 release artifact, verifies its published SHA-256 checksum, and +installs it beside `claude-gpt`. The launcher prefers that sibling binary and +refuses a different version by default. Set +`CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY=0` only if you do not want GPT-in-Claude +support or will install the exact proxy version yourself. + ## Usage +### Run GPT in the Claude Code harness + +Start normal Claude Code with the currently active saved Codex profile: + +```bash +claude-gpt +``` + +Pin a different saved profile without switching the active Codex profile: + +```bash +claude-gpt --profile work +``` + +Claude Code arguments are forwarded unchanged. `--bare` is opt-in; without it, +your normal Claude Code hooks, plugins, skills, and project instructions still +load: + +```bash +claude-gpt --profile work --model gpt-5.6-sol -- -p "summarize this repository" +claude-gpt --bare +``` + +### Model lanes and effort + +[`/model` in Claude Code](https://code.claude.com/docs/en/model-config) switches +lanes without restarting the launcher. Each Claude tier maps to one GPT model: + +- Opus / deep → `gpt-5.6-sol` (also the starting model) +- Sonnet / balanced → `gpt-5.6-terra` +- Haiku / light / background → `gpt-5.6-luna` + +The picker labels those lanes `GPT-5.6 Sol Deep`, `GPT-5.6 Terra Balanced`, +and `GPT-5.6 Luna Light`. It also carries one custom option, +`GPT-5.6 Sol Ultra Fast` +(`gpt-5.6-sol-fast`), which routes Sol through the proxy's priority service +tier. Every lane advertises `effort,xhigh_effort,max_effort`, so `/effort` +offers low through max and the proxy translates the chosen level per turn. The +launcher never sets `CCP_CODEX_EFFORT`, so `/effort` stays live. Start at a +specific level with `claude-gpt --effort high`; `/effort` can still change it +inside the session. + +Sol Ultra Fast uses the selected subscription profile's separate Codex +fast-mode quota. If that quota is unavailable, the backend can return `usage +limit reached`; switch `/model` back to `GPT-5.6 Sol Deep`. The launcher does +not retry a partially started tool turn on another lane because that could +duplicate tool calls. + +Override a lane by flag or environment variable: + +```bash +claude-gpt --sonnet-model gpt-5.6-terra --haiku-model gpt-5.6-luna +CLAUDE_GPT_OPUS_MODEL=gpt-5.6-sol CLAUDE_GPT_FAST_MODEL= claude-gpt +``` + +Flags: `--model`, `--opus-model`, `--sonnet-model`, `--haiku-model` +(`--small-model` is its alias), and `--fast-model`. Environment overrides: +`CLAUDE_GPT_MODEL`, `CLAUDE_GPT_OPUS_MODEL`, `CLAUDE_GPT_SONNET_MODEL`, +`CLAUDE_GPT_SMALL_MODEL`, and `CLAUDE_GPT_FAST_MODEL` with its +`CLAUDE_GPT_FAST_MODEL_NAME`, `CLAUDE_GPT_FAST_MODEL_DESCRIPTION`, and +`CLAUDE_GPT_FAST_MODEL_CAPABILITIES` companions. Set `CLAUDE_GPT_FAST_MODEL=` +(empty) to drop the custom option. + +`CLAUDE_GPT_MODEL_CAPABILITIES` controls the capability declaration shared by +the Opus, Sonnet, and Haiku lanes. Their picker labels can be overridden with +`CLAUDE_GPT_OPUS_MODEL_NAME`, `CLAUDE_GPT_SONNET_MODEL_NAME`, and +`CLAUDE_GPT_HAIKU_MODEL_NAME` (plus matching `_DESCRIPTION` variables). + +This path uses the selected profile's ChatGPT/Codex subscription, not +`OPENAI_API_KEY`. `codex-auth` remains the only refresh owner. The proxy receives +an access-only lease in a private temporary directory, never the refresh token; +renewals must retain the same hashed account identity, and the proxy, lease, and +temporary files are removed when Claude Code exits. Ordinary `claude`, `codex`, +and `cswap` configuration is not rewritten by the launcher. Claude Code still +runs its normal harness and may update its own history or credential metadata as +it would on an ordinary launch. The launcher never switches the active Codex +profile; if the selected profile is already active, official Codex may rotate +that profile's credential in place during renewal so the live login does not go +stale. + +The proxy binds a kernel-selected loopback port for one Claude Code process. Its +HTTP routes do not have their own local authorization layer, so this assumes +other processes running as your local user are trusted while the session is +open. + +This is an experimental compatibility route implemented by a third-party +Anthropic-to-Codex protocol translator. Anthropic does not support using +non-Claude models in Claude Code, so keep normal `claude` available as the +[supported path](https://code.claude.com/docs/en/llm-gateway). + Open the persistent account monitor without changing auth: ```bash @@ -240,7 +339,8 @@ crontab -l 2>/dev/null \ | awk '$0 == "# BEGIN codex-auth maintain" {skip=1; next} $0 == "# END codex-auth maintain" {skip=0; next} !skip' \ | crontab - curl -fsSL https://chatgpt.com/codex/install.sh | sh -rm -f ~/.local/bin/codex-auth ~/.local/bin/codex-auth-tui ~/.local/bin/codex-real +rm -f ~/.local/bin/codex-auth ~/.local/bin/codex-auth-tui ~/.local/bin/codex-real \ + ~/.local/bin/claude-gpt ~/.local/bin/claude-code-proxy rm -rf ~/.local/lib/codex-auth ``` diff --git a/bin/claude-gpt b/bin/claude-gpt new file mode 100755 index 0000000..61cfc42 --- /dev/null +++ b/bin/claude-gpt @@ -0,0 +1,485 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 + +CLAUDE_GPT_PROXY_VERSION="0.1.10" +# Dynamic model lanes. Claude Code keeps /model and /effort live; these only set +# which GPT model each Claude tier maps to. The proxy translates every id and the +# per-turn effort, so the launcher never pins a global effort. +CLAUDE_GPT_DEFAULT_MODEL="gpt-5.6-sol" +CLAUDE_GPT_DEFAULT_OPUS_MODEL="gpt-5.6-sol" +CLAUDE_GPT_DEFAULT_OPUS_MODEL_NAME="GPT-5.6 Sol Deep" +CLAUDE_GPT_DEFAULT_OPUS_MODEL_DESCRIPTION="Flagship quality lane for hard coding and reasoning" +CLAUDE_GPT_DEFAULT_SONNET_MODEL="gpt-5.6-terra" +CLAUDE_GPT_DEFAULT_SONNET_MODEL_NAME="GPT-5.6 Terra Balanced" +CLAUDE_GPT_DEFAULT_SONNET_MODEL_DESCRIPTION="Balanced intelligence and latency lane" +CLAUDE_GPT_DEFAULT_SMALL_MODEL="gpt-5.6-luna" +CLAUDE_GPT_DEFAULT_HAIKU_MODEL_NAME="GPT-5.6 Luna Light" +CLAUDE_GPT_DEFAULT_HAIKU_MODEL_DESCRIPTION="Lightweight lane for simple and background work" +CLAUDE_GPT_DEFAULT_FAST_MODEL="gpt-5.6-sol-fast" +CLAUDE_GPT_DEFAULT_FAST_MODEL_NAME="GPT-5.6 Sol Ultra Fast" +CLAUDE_GPT_DEFAULT_FAST_MODEL_DESCRIPTION="Sol priority lane; requires available fast-mode quota" +CLAUDE_GPT_DEFAULT_MODEL_CAPABILITIES="effort,xhigh_effort,max_effort" +CLAUDE_GPT_PROXY_LEASE_EXPIRES=4102444800000 + +die() { + printf 'claude-gpt: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: claude-gpt [launcher options] [--] [claude arguments...] + +Run the normal Claude Code harness with GPT backed by a saved ChatGPT/Codex +subscription profile. This does not use OPENAI_API_KEY or change the active +codex-auth profile. + +Launcher options: + --profile NAME use a saved profile (default: current active profile) + --model MODEL starting model (default: gpt-5.6-sol) + --opus-model MODEL Opus deep lane (default: gpt-5.6-sol) + --sonnet-model MODEL Sonnet balanced lane (default: gpt-5.6-terra) + --haiku-model MODEL Haiku light/background lane (default: gpt-5.6-luna) + --small-model MODEL alias of --haiku-model + --fast-model MODEL custom "GPT-5.6 Sol Ultra Fast" picker option + (default: gpt-5.6-sol-fast; set CLAUDE_GPT_FAST_MODEL= + to drop it) + --effort LEVEL starting effort: low, medium, high, xhigh, max, or auto + (still changeable with /effort) + --bare pass --bare to Claude Code + -h, --help show this help + +/model in Claude Code switches between the Opus, Sonnet, and Haiku lanes plus +the one custom Sol Ultra Fast option; /effort still picks the reasoning level +per turn. The launcher advertises effort support but never forces a level. + +Use -- before a Claude Code argument that has the same name as a launcher +option. Every other argument is forwarded unchanged. +EOF +} + +resolve_command() { + local override="$1" + local name="$2" + local sibling="${3:-}" + + if [[ -n "$override" ]]; then + [[ -x "$override" ]] || die "$name is not executable: $override" + printf '%s\n' "$override" + elif [[ -n "$sibling" && -x "$sibling" ]]; then + printf '%s\n' "$sibling" + elif command -v "$name" >/dev/null 2>&1; then + command -v "$name" + else + die "$name was not found" + fi +} + +metadata_field() { + local metadata="$1" + local field="$2" + jq -er --arg field "$field" '.[$field] | select(. != null and . != "")' <<<"$metadata" +} + +process_alive() { + local pid="${1:-}" + local state="" + [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null || return 1 + if [[ -r "/proc/$pid/stat" ]]; then + state="$(awk '{print $3}' "/proc/$pid/stat" 2>/dev/null || true)" + [[ "$state" != "Z" ]] || return 1 + fi +} + +wait_for_exit() { + local pid="$1" + local attempts="$2" + local attempt + for (( attempt=0; attempt/dev/null 2>&1; then + while IFS= read -r child; do + [[ "$child" =~ ^[0-9]+$ && "$child" != "$pid" ]] || continue + signal_process_tree "$child" "$signal" + done < <(pgrep -P "$pid" 2>/dev/null || true) + fi + kill "-$signal" "$pid" 2>/dev/null || true +} + +main() { + local profile="--active" + local model="${CLAUDE_GPT_MODEL:-$CLAUDE_GPT_DEFAULT_MODEL}" + local opus_model="${CLAUDE_GPT_OPUS_MODEL:-$CLAUDE_GPT_DEFAULT_OPUS_MODEL}" + local opus_model_name="${CLAUDE_GPT_OPUS_MODEL_NAME:-$CLAUDE_GPT_DEFAULT_OPUS_MODEL_NAME}" + local opus_model_description="${CLAUDE_GPT_OPUS_MODEL_DESCRIPTION:-$CLAUDE_GPT_DEFAULT_OPUS_MODEL_DESCRIPTION}" + local sonnet_model="${CLAUDE_GPT_SONNET_MODEL:-$CLAUDE_GPT_DEFAULT_SONNET_MODEL}" + local sonnet_model_name="${CLAUDE_GPT_SONNET_MODEL_NAME:-$CLAUDE_GPT_DEFAULT_SONNET_MODEL_NAME}" + local sonnet_model_description="${CLAUDE_GPT_SONNET_MODEL_DESCRIPTION:-$CLAUDE_GPT_DEFAULT_SONNET_MODEL_DESCRIPTION}" + local small_model="${CLAUDE_GPT_SMALL_MODEL:-$CLAUDE_GPT_DEFAULT_SMALL_MODEL}" + local haiku_model_name="${CLAUDE_GPT_HAIKU_MODEL_NAME:-$CLAUDE_GPT_DEFAULT_HAIKU_MODEL_NAME}" + local haiku_model_description="${CLAUDE_GPT_HAIKU_MODEL_DESCRIPTION:-$CLAUDE_GPT_DEFAULT_HAIKU_MODEL_DESCRIPTION}" + local model_capabilities="${CLAUDE_GPT_MODEL_CAPABILITIES:-$CLAUDE_GPT_DEFAULT_MODEL_CAPABILITIES}" + # ${VAR-default}, not ${VAR:-default}: an exported CLAUDE_GPT_FAST_MODEL= drops + # the single custom picker option; leaving it unset keeps the default. + local fast_model="${CLAUDE_GPT_FAST_MODEL-$CLAUDE_GPT_DEFAULT_FAST_MODEL}" + local fast_model_name="${CLAUDE_GPT_FAST_MODEL_NAME:-$CLAUDE_GPT_DEFAULT_FAST_MODEL_NAME}" + local fast_model_description="${CLAUDE_GPT_FAST_MODEL_DESCRIPTION:-$CLAUDE_GPT_DEFAULT_FAST_MODEL_DESCRIPTION}" + local fast_model_capabilities="${CLAUDE_GPT_FAST_MODEL_CAPABILITIES:-$model_capabilities}" + local effort="" bare=0 + local script_dir codex_auth proxy_bin claude_bin curl_bin + local codex_home session_dir config_dir auth_file state_dir proxy_log proxy_state_log stop_file + local metadata initial_expires expires profile_name identity now requested_port port proxy_version + local renew_margin_secs parent_death_code + local proxy_pid="" lease_pid="" claude_pid="" wrapper_pid="$BASHPID" status=0 + local -a claude_args=() + + while (( $# > 0 )); do + case "$1" in + --profile) + [[ $# -ge 2 && -n "$2" ]] || die "--profile needs a name" + profile="$2" + shift 2 + ;; + --model) + [[ $# -ge 2 && -n "$2" ]] || die "--model needs a model id" + model="$2" + shift 2 + ;; + --opus-model) + [[ $# -ge 2 && -n "$2" ]] || die "--opus-model needs a model id" + opus_model="$2" + shift 2 + ;; + --sonnet-model) + [[ $# -ge 2 && -n "$2" ]] || die "--sonnet-model needs a model id" + sonnet_model="$2" + shift 2 + ;; + --haiku-model|--small-model) + [[ $# -ge 2 && -n "$2" ]] || die "$1 needs a model id" + small_model="$2" + shift 2 + ;; + --fast-model) + [[ $# -ge 2 && -n "$2" ]] || die "--fast-model needs a model id" + fast_model="$2" + shift 2 + ;; + --effort) + [[ $# -ge 2 && -n "$2" ]] || die "--effort needs a level" + case "$2" in + low|medium|high|xhigh|max|auto) effort="$2" ;; + *) die "--effort must be low, medium, high, xhigh, max, or auto" ;; + esac + shift 2 + ;; + --bare) + bare=1 + shift + ;; + -h|--help) + usage + return 0 + ;; + --) + shift + claude_args+=("$@") + break + ;; + *) + claude_args+=("$@") + break + ;; + esac + done + + command -v jq >/dev/null 2>&1 || die "jq is required" + command -v python3 >/dev/null 2>&1 || die "python3 is required for child-process containment" + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + codex_auth="$(resolve_command "${CODEX_AUTH_BIN:-}" codex-auth "$script_dir/codex-auth")" + proxy_bin="$(resolve_command "${CLAUDE_GPT_PROXY_BIN:-}" claude-code-proxy "$script_dir/claude-code-proxy")" + claude_bin="$(resolve_command "${CLAUDE_GPT_CLAUDE_BIN:-}" claude)" + curl_bin="$(resolve_command "${CLAUDE_GPT_CURL_BIN:-}" curl)" + + proxy_version="$("$proxy_bin" --version 2>/dev/null || true)" + if [[ "$proxy_version" != "claude-code-proxy $CLAUDE_GPT_PROXY_VERSION" \ + && "${CLAUDE_GPT_ALLOW_PROXY_VERSION_MISMATCH:-0}" != "1" ]]; then + die "expected claude-code-proxy $CLAUDE_GPT_PROXY_VERSION, found ${proxy_version:-unknown}" + fi + + codex_home="${CODEX_HOME:-$HOME/.codex}" + mkdir -p "$codex_home/.tmp" + chmod 700 "$codex_home/.tmp" + session_dir="$(mktemp -d "$codex_home/.tmp/claude-gpt.XXXXXX")" + config_dir="$session_dir/config" + auth_file="$config_dir/codex/auth.json" + state_dir="$session_dir/state" + proxy_log="$session_dir/proxy.log" + proxy_state_log="$state_dir/claude-code-proxy/proxy.log" + stop_file="$session_dir/stop" + mkdir -p "${auth_file%/*}" "$state_dir" + chmod 700 "$session_dir" "$config_dir" "${auth_file%/*}" "$state_dir" + + cleanup() { + trap - EXIT HUP INT TERM + touch "$stop_file" 2>/dev/null || true + process_alive "$claude_pid" && signal_process_tree "$claude_pid" TERM || true + process_alive "$proxy_pid" && signal_process_tree "$proxy_pid" TERM || true + if process_alive "$lease_pid" && ! wait_for_exit "$lease_pid" 200; then + signal_process_tree "$lease_pid" TERM + wait_for_exit "$lease_pid" 20 || signal_process_tree "$lease_pid" KILL + fi + if process_alive "$claude_pid" && ! wait_for_exit "$claude_pid" 30; then + signal_process_tree "$claude_pid" KILL + fi + if process_alive "$proxy_pid" && ! wait_for_exit "$proxy_pid" 30; then + signal_process_tree "$proxy_pid" KILL + fi + [[ -z "$lease_pid" ]] || wait "$lease_pid" 2>/dev/null || true + [[ -z "$claude_pid" ]] || wait "$claude_pid" 2>/dev/null || true + [[ -z "$proxy_pid" ]] || wait "$proxy_pid" 2>/dev/null || true + rm -rf "$session_dir" + } + on_signal() { + local signal_status="$1" + cleanup + exit "$signal_status" + } + trap cleanup EXIT + trap 'on_signal 129' HUP + trap 'on_signal 130' INT + trap 'on_signal 143' TERM + + metadata="$("$codex_auth" claude-gpt-export "$profile" "$auth_file")" \ + || die "could not pin the selected subscription profile" + profile_name="$(metadata_field "$metadata" profile)" \ + || die "auth export did not return a profile" + identity="$(metadata_field "$metadata" account_identity)" \ + || die "auth export did not return a stable account identity" + initial_expires="$(metadata_field "$metadata" expires)" \ + || die "auth export did not return the access-token expiry" + + # Opening Codex app-server against an isolated copy makes official Codex own + # any refresh-token rotation. codex-auth safely merges the result back into + # this one saved profile without switching the active profile name. + "$codex_auth" refresh --quiet --fast "$profile_name" >/dev/null + metadata="$("$codex_auth" claude-gpt-export "$profile_name" "$auth_file" "$identity")" \ + || die "could not renew the selected subscription profile" + expires="$(metadata_field "$metadata" expires)" \ + || die "renewed auth export did not return an expiry" + now="$(date +%s)" + if (( expires / 1000 <= now + 90 )); then + die "the selected access token is about to expire; run: codex-auth reauth $profile_name" + fi + if (( initial_expires / 1000 <= now + 300 && expires <= initial_expires )); then + die "Codex could not refresh the selected access token; run: codex-auth reauth $profile_name" + fi + [[ "$(jq -r '.refresh' "$auth_file")" == "" ]] \ + || die "refusing to start: proxy auth unexpectedly contains a refresh token" + [[ "$(jq -r '.expires' "$auth_file")" == "$CLAUDE_GPT_PROXY_LEASE_EXPIRES" ]] \ + || die "refusing to start: proxy auth is not lease managed" + + requested_port="${CLAUDE_GPT_PORT:-0}" + if [[ -n "${CLAUDE_GPT_PORT:-}" ]]; then + [[ "$requested_port" =~ ^[0-9]+$ && "$requested_port" -ge 1024 && "$requested_port" -le 65535 ]] \ + || die "CLAUDE_GPT_PORT must be between 1024 and 65535" + fi + port="$requested_port" + parent_death_code=$'import ctypes, os, signal, sys\nexpected = int(sys.argv[1])\nlibc = ctypes.CDLL(None, use_errno=True)\nif libc.prctl(1, signal.SIGTERM) != 0:\n raise OSError(ctypes.get_errno(), "prctl(PR_SET_PDEATHSIG) failed")\nif os.getppid() != expected:\n raise SystemExit(125)\nos.execvpe(sys.argv[2], sys.argv[2:], os.environ)' + env \ + -u CCP_ALIAS_PROVIDER \ + -u CCP_CODEX_BASE_URL \ + -u CCP_CODEX_EFFORT \ + -u CCP_CODEX_MODEL \ + -u CCP_CODEX_SERVICE_TIER \ + -u CCP_CODEX_TRANSPORT \ + -u CCP_LOG_STDERR \ + -u CCP_LOG_VERBOSE \ + -u CCP_TRAFFIC_LOG \ + CCP_CONFIG_DIR="$config_dir" \ + XDG_STATE_HOME="$state_dir" \ + python3 -c "$parent_death_code" "$wrapper_pid" \ + "$proxy_bin" serve --port "$requested_port" --no-monitor >"$proxy_log" 2>&1 & + proxy_pid=$! + + local health_ready=0 attempt parsed_port + for (( attempt=0; attempt<100; attempt++ )); do + if [[ "$requested_port" == "0" && -s "$proxy_state_log" ]]; then + parsed_port="$(jq -r 'select(.msg == "server listening") | .fields.port // empty' "$proxy_state_log" 2>/dev/null | tail -n 1)" + if [[ "$parsed_port" =~ ^[0-9]+$ && "$parsed_port" -ge 1024 && "$parsed_port" -le 65535 ]]; then + port="$parsed_port" + fi + fi + if [[ "$port" != "0" ]] && process_alive "$proxy_pid" \ + && "$curl_bin" --noproxy '*' -fsS --max-time 1 "http://127.0.0.1:$port/healthz" >/dev/null 2>&1 \ + && process_alive "$proxy_pid" + then + sleep 0.05 + process_alive "$proxy_pid" || break + "$curl_bin" --noproxy '*' -fsS --max-time 1 "http://127.0.0.1:$port/healthz" >/dev/null 2>&1 || continue + health_ready=1 + break + fi + process_alive "$proxy_pid" || break + sleep 0.05 + done + if (( ! health_ready )); then + tail -n 20 "$proxy_log" >&2 2>/dev/null || true + tail -n 20 "$proxy_state_log" >&2 2>/dev/null || true + die "local proxy did not become ready on 127.0.0.1:$port" + fi + + renew_lease() { + local current_expires="$expires" next_metadata next_expires target sleep_for lease_now + renew_margin_secs="${CLAUDE_GPT_RENEW_MARGIN_SECS:-290}" + [[ "$renew_margin_secs" =~ ^[0-9]+$ && "$renew_margin_secs" -ge 1 ]] || renew_margin_secs=290 + while [[ ! -e "$stop_file" ]] \ + && process_alive "$wrapper_pid" \ + && process_alive "$claude_pid" \ + && process_alive "$proxy_pid" + do + lease_now="$(date +%s)" + # Official Codex refreshes access tokens inside its final five minutes. + # Enter that window with ten seconds of headroom, then retry briefly if a + # clock boundary leaves the token unchanged. + target=$(( current_expires / 1000 - renew_margin_secs )) + if (( lease_now < target )); then + sleep_for=$(( target - lease_now )) + (( sleep_for > 1 )) && sleep_for=1 + sleep "$sleep_for" + continue + fi + + if "$codex_auth" refresh --quiet --fast "$profile_name" >/dev/null 2>&1 \ + && next_metadata="$("$codex_auth" claude-gpt-export "$profile_name" "$auth_file" "$identity" 2>/dev/null)" \ + && next_expires="$(metadata_field "$next_metadata" expires 2>/dev/null)" + then + if (( next_expires > current_expires )); then + current_expires="$next_expires" + continue + fi + fi + + lease_now="$(date +%s)" + if (( lease_now >= current_expires / 1000 - 60 )); then + printf 'claude-gpt: subscription lease could not be renewed for profile %s\n' "$profile_name" >&2 + process_alive "$claude_pid" && kill -TERM "$claude_pid" 2>/dev/null || true + process_alive "$wrapper_pid" && kill -TERM "$wrapper_pid" 2>/dev/null || true + return 1 + fi + sleep 15 + done + if ! process_alive "$wrapper_pid"; then + process_alive "$claude_pid" && signal_process_tree "$claude_pid" TERM || true + process_alive "$proxy_pid" && signal_process_tree "$proxy_pid" TERM || true + rm -rf "$session_dir" + elif process_alive "$claude_pid" && ! process_alive "$proxy_pid"; then + return 2 + fi + } + + if [[ -n "$effort" ]]; then + claude_args=(--effort "$effort" "${claude_args[@]}") + fi + if (( bare )); then + claude_args=(--bare "${claude_args[@]}") + fi + + # Map the Claude tiers to their GPT lanes. ANTHROPIC_MODEL is the starting + # model; /model switches between these lanes without restarting the launcher. + local -a claude_env=( + -u ANTHROPIC_API_KEY + -u ANTHROPIC_BEDROCK_BASE_URL + -u ANTHROPIC_VERTEX_BASE_URL + -u CLAUDE_CODE_USE_BEDROCK + -u CLAUDE_CODE_USE_FOUNDRY + -u CLAUDE_CODE_USE_VERTEX + ANTHROPIC_BASE_URL="http://127.0.0.1:$port" + ANTHROPIC_AUTH_TOKEN=unused + ANTHROPIC_MODEL="$model" + ANTHROPIC_DEFAULT_OPUS_MODEL="$opus_model" + ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="$opus_model_name" + ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="$opus_model_description" + ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES="$model_capabilities" + ANTHROPIC_DEFAULT_SONNET_MODEL="$sonnet_model" + ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="$sonnet_model_name" + ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="$sonnet_model_description" + ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES="$model_capabilities" + ANTHROPIC_DEFAULT_HAIKU_MODEL="$small_model" + ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="$haiku_model_name" + ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="$haiku_model_description" + ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES="$model_capabilities" + ANTHROPIC_SMALL_FAST_MODEL="$small_model" + CLAUDE_CODE_AUTO_COMPACT_WINDOW="${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-372000}" + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 + CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 + ) + # One bounded custom picker option. Its SUPPORTED_CAPABILITIES only advertise + # the effort levels the proxy already translates (effort/xhigh_effort/max_effort); + # the launcher still leaves CCP_CODEX_EFFORT unset so /effort stays dynamic. + if [[ -n "$fast_model" ]]; then + claude_env+=( + ANTHROPIC_CUSTOM_MODEL_OPTION="$fast_model" + ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="$fast_model_name" + ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="$fast_model_description" + ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES="$fast_model_capabilities" + ) + fi + + env "${claude_env[@]}" \ + python3 -c "$parent_death_code" "$wrapper_pid" \ + "$claude_bin" "${claude_args[@]}" <&0 & + claude_pid=$! + renew_lease & + lease_pid=$! + + local finished_pid="" child_status=0 + set +e + wait -n -p finished_pid "$claude_pid" "$proxy_pid" "$lease_pid" + child_status=$? + set -e + case "$finished_pid" in + "$claude_pid") + status="$child_status" + claude_pid="" + ;; + "$proxy_pid") + proxy_pid="" + printf 'claude-gpt: local proxy stopped before Claude Code exited\n' >&2 + status=1 + ;; + "$lease_pid") + lease_pid="" + if process_alive "$claude_pid"; then + printf 'claude-gpt: subscription lease stopped before Claude Code exited\n' >&2 + status=1 + else + set +e + wait "$claude_pid" + status=$? + set -e + claude_pid="" + fi + ;; + *) + printf 'claude-gpt: child supervision failed\n' >&2 + status=1 + ;; + esac + cleanup + return "$status" +} + +main "$@" diff --git a/bin/codex-auth b/bin/codex-auth index 0e948f9..e549f7f 100755 --- a/bin/codex-auth +++ b/bin/codex-auth @@ -313,6 +313,7 @@ main() { refresh) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; cmd_refresh "$@" ;; reset|use-reset) source_codex_auth_libs "${CODEX_AUTH_USAGE_LIB_FILES[@]}" profiles.sh; require_arg_count_between "$#" 2 2 "usage: codex-auth reset --yes"; cmd_reset "$@" ;; doctor) source_codex_auth_libs doctor.sh; cmd_doctor "$@" ;; + claude-gpt-export) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}" claude-gpt.sh; require_arg_count_between "$#" 2 3 "usage: codex-auth claude-gpt-export [expected-identity]"; cmd_claude_gpt_export "$@" ;; export) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 2 2 "usage: codex-auth export "; cmd_export "$@" ;; remove|rm) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; require_arg_count_between "$#" 1 2 "usage: codex-auth remove --yes"; cmd_remove "$@" ;; paths) source_codex_auth_libs "${CODEX_AUTH_PROFILE_LIB_FILES[@]}"; cmd_paths ;; diff --git a/install.sh b/install.sh index f05e30e..7a0ff47 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,7 @@ codex_home="${CODEX_HOME:-$HOME/.codex}" standalone_root="$codex_home/packages/standalone" tui_source="$repo_dir/tui" tui_dir="$libdir/tui" +claude_gpt_proxy_version="0.1.10" mkdir -p "$bindir" "$libdir" if [[ ! -f "$tui_source/pyproject.toml" || ! -f "$tui_source/uv.lock" || ! -d "$tui_source/src/codex_auth_tui" ]]; then @@ -64,8 +65,98 @@ if [[ "${CODEX_AUTH_TUI_SKIP_BOOTSTRAP:-0}" != "1" ]]; then fi fi +install_claude_gpt_proxy() { + local platform archive_name checksum_name release_base download_dir + local archive checksum extracted staged_binary actual_version + + [[ "${CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY:-1}" != "0" ]] || return 0 + if [[ -x "$bindir/claude-code-proxy" ]] \ + && [[ "$("$bindir/claude-code-proxy" --version 2>/dev/null || true)" == "claude-code-proxy $claude_gpt_proxy_version" ]]; then + return 0 + fi + + case "$(uname -s):$(uname -m)" in + Linux:x86_64|Linux:amd64) platform="linux-amd64" ;; + Linux:aarch64|Linux:arm64) platform="linux-arm64" ;; + *) + printf 'unsupported platform for claude-code-proxy: %s %s\n' "$(uname -s)" "$(uname -m)" >&2 + printf 'set CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY=0 to install codex-auth without claude-gpt support\n' >&2 + return 1 + ;; + esac + + archive_name="claude-code-proxy-$platform.tar.gz" + checksum_name="claude-code-proxy-$platform.sha256" + release_base="https://github.com/raine/claude-code-proxy/releases/download/v$claude_gpt_proxy_version" + download_dir="$install_stage/claude-code-proxy" + archive="$download_dir/$archive_name" + checksum="$download_dir/$checksum_name" + mkdir -p "$download_dir" + + if [[ -n "${CODEX_AUTH_CLAUDE_GPT_PROXY_ARCHIVE:-}" ]]; then + [[ -f "$CODEX_AUTH_CLAUDE_GPT_PROXY_ARCHIVE" ]] || { + printf 'claude-code-proxy archive not found: %s\n' "$CODEX_AUTH_CLAUDE_GPT_PROXY_ARCHIVE" >&2 + return 1 + } + cp "$CODEX_AUTH_CLAUDE_GPT_PROXY_ARCHIVE" "$archive" + elif command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-connrefused --connect-timeout 10 --max-time 120 \ + "$release_base/$archive_name" -o "$archive" + elif command -v wget >/dev/null 2>&1; then + wget --tries=3 --timeout=120 -qO "$archive" "$release_base/$archive_name" + else + printf 'curl or wget is required to install claude-code-proxy\n' >&2 + return 1 + fi + + if [[ -n "${CODEX_AUTH_CLAUDE_GPT_PROXY_CHECKSUM:-}" ]]; then + [[ -f "$CODEX_AUTH_CLAUDE_GPT_PROXY_CHECKSUM" ]] || { + printf 'claude-code-proxy checksum not found: %s\n' "$CODEX_AUTH_CLAUDE_GPT_PROXY_CHECKSUM" >&2 + return 1 + } + cp "$CODEX_AUTH_CLAUDE_GPT_PROXY_CHECKSUM" "$checksum" + elif command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-connrefused --connect-timeout 10 --max-time 30 \ + "$release_base/$checksum_name" -o "$checksum" + elif command -v wget >/dev/null 2>&1; then + wget --tries=3 --timeout=30 -qO "$checksum" "$release_base/$checksum_name" + fi + + if command -v sha256sum >/dev/null 2>&1; then + (cd "$download_dir" && sha256sum -c "$checksum_name" >/dev/null) + elif command -v shasum >/dev/null 2>&1; then + (cd "$download_dir" && shasum -a 256 -c "$checksum_name" >/dev/null) + else + printf 'sha256sum or shasum is required to verify claude-code-proxy\n' >&2 + return 1 + fi + + tar -xzf "$archive" -C "$download_dir" + extracted="$download_dir/claude-code-proxy" + [[ -f "$extracted" ]] || { + printf 'verified claude-code-proxy archive did not contain its binary\n' >&2 + return 1 + } + chmod 0755 "$extracted" + actual_version="$("$extracted" --version 2>/dev/null || true)" + [[ "$actual_version" == "claude-code-proxy $claude_gpt_proxy_version" ]] || { + printf 'verified proxy reported an unexpected version: %s\n' "${actual_version:-unknown}" >&2 + return 1 + } + staged_binary="$(mktemp "$bindir/.claude-code-proxy.XXXXXX")" + if ! install -m 0755 "$extracted" "$staged_binary" \ + || ! mv -f "$staged_binary" "$bindir/claude-code-proxy" + then + rm -f "$staged_binary" + return 1 + fi +} + +install_claude_gpt_proxy + install -m 0755 "$repo_dir/bin/codex-auth" "$bindir/codex-auth" install -m 0755 "$repo_dir/bin/codex-auth-tui" "$bindir/codex-auth-tui" +install -m 0755 "$repo_dir/bin/claude-gpt" "$bindir/claude-gpt" install -m 0755 "$repo_dir/bin/codex" "$libdir/codex-shim" install -m 0644 "$repo_dir/lib/codex-auth/"*.sh "$libdir/" shopt -s nullglob @@ -249,6 +340,10 @@ fi printf 'installed %s\n' "$bindir/codex-auth" printf 'installed %s\n' "$bindir/codex-auth-tui" +printf 'installed %s\n' "$bindir/claude-gpt" +if [[ -x "$bindir/claude-code-proxy" ]]; then + printf 'installed %s\n' "$bindir/claude-code-proxy" +fi if [[ "${1:-}" == "--wrap-codex" ]]; then printf 'installed %s\n' "$bindir/codex" if [[ -x "$bindir/codex-real" ]]; then diff --git a/lib/codex-auth/claude-gpt.sh b/lib/codex-auth/claude-gpt.sh new file mode 100644 index 0000000..2e726ba --- /dev/null +++ b/lib/codex-auth/claude-gpt.sh @@ -0,0 +1,111 @@ +# shellcheck shell=bash + +claude_gpt_proxy_auth_json() { + local source="$1" + local proxy_expires_ms="$2" + + jq -e --argjson proxy_expires_ms "$proxy_expires_ms" ' + def jwt_payload: + split(".") as $parts + | if ($parts | length) < 2 then null + else ($parts[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson?) + end; + . as $root + | ($root.tokens.access_token // "") as $access + | ($access | jwt_payload) as $access_claims + | (($root.tokens.id_token // $access) | jwt_payload) as $identity_claims + | ($identity_claims["https://api.openai.com/auth"] // {}) as $identity_auth + | ($identity_auth.chatgpt_account_id + // $root.tokens.account_id + // $access_claims.chatgpt_account_id + // $access_claims["https://api.openai.com/auth.chatgpt_account_id"] + // "") as $account + | ($access_claims.exp // 0) as $exp + | select(($access | type) == "string" and ($access | length) > 0) + | select(($account | type) == "string" and ($account | length) > 0) + | select(($exp | type) == "number" and $exp > 0) + | { + access: $access, + refresh: "", + # This is a private, session-scoped lease file. A deliberately distant + # proxy expiry prevents claude-code-proxy from entering its OAuth refresh + # path. The real JWT expiry is returned separately below and renewed only + # by codex-auth, which remains the sole owner of the refresh token. + expires: $proxy_expires_ms, + accountId: $account, + realExpires: ($exp * 1000 | floor) + } + ' "$source" +} + +cmd_claude_gpt_export() ( + local requested_name="${1:-}" + local dest="${2:-}" + local expected_identity="${3:-}" + [[ -n "$requested_name" && -n "$dest" ]] \ + || die "usage: codex-auth claude-gpt-export [expected-identity]" + command -v jq >/dev/null 2>&1 || die "jq is required for Claude GPT auth export" + + ensure_dirs + sync_active_profile_from_live + acquire_mutation_lock + + local name="$requested_name" + if [[ "$name" == "--active" ]]; then + name="$(resolve_active_profile_for_auth "$AUTH_FILE" || true)" + [[ -n "$name" ]] || die "no saved ChatGPT profile is currently active" + else + require_name "$name" + fi + + local source kind identity revision fingerprint parent tmp proxy_expires_ms expires_ms + source="$(profile_path "$name")" + [[ -f "$source" ]] || die "profile not found: $name" + require_auth_file "$source" + kind="$(auth_file_kind "$source" || true)" + [[ "$kind" == "chatgpt" ]] || die "Claude GPT only supports ChatGPT profiles" + identity="$(auth_file_account_identity "$source" || true)" + [[ -n "$identity" ]] || die "saved profile has no stable account identity: $name" + if [[ -n "$expected_identity" && "$identity" != "$expected_identity" ]]; then + die "saved profile identity changed during Claude GPT session: $name" + fi + revision="$(auth_file_revision "$source" || true)" + fingerprint="$(credential_fingerprint "$source" || true)" + [[ -n "$revision" && -n "$fingerprint" ]] || die "could not snapshot profile: $name" + + parent="${dest%/*}" + [[ "$parent" != "$dest" ]] || parent="." + if [[ ! -d "$parent" ]]; then + mkdir -p "$parent" + chmod 700 "$parent" + fi + tmp="$(mktemp "$parent/.claude-gpt-auth.XXXXXX")" + proxy_expires_ms=4102444800000 + if ! claude_gpt_proxy_auth_json "$source" "$proxy_expires_ms" > "$tmp" \ + || ! chmod 600 "$tmp" \ + || ! mv -f "$tmp" "$dest" + then + rm -f "$tmp" + die "could not create private Claude GPT auth export" + fi + expires_ms="$(jq -r '.realExpires' "$dest")" + tmp="$(mktemp "$parent/.claude-gpt-auth.XXXXXX")" + if ! jq 'del(.realExpires)' "$dest" > "$tmp" \ + || ! chmod 600 "$tmp" \ + || ! mv -f "$tmp" "$dest" + then + rm -f "$tmp" + rm -f "$dest" + die "could not seal private Claude GPT auth export" + fi + + jq -cn \ + --arg profile "$name" \ + --arg account_identity "$identity" \ + --arg profile_revision "$revision" \ + --arg credential_fingerprint "$fingerprint" \ + --argjson expires "$expires_ms" \ + '{profile: $profile, account_identity: $account_identity, + profile_revision: $profile_revision, + credential_fingerprint: $credential_fingerprint, expires: $expires}' +) diff --git a/lib/codex-auth/usage.sh b/lib/codex-auth/usage.sh index 2f631af..c054899 100644 --- a/lib/codex-auth/usage.sh +++ b/lib/codex-auth/usage.sh @@ -478,7 +478,7 @@ usage_json_from_home() { rate_pid="$CODEX_RATE_PID" start="$(now_epoch)" - if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.1"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.2.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" printf '%s\n' '{"error":{"message":"refresh unavailable"}}' return 0 @@ -557,7 +557,7 @@ reset_credit_json_from_home() { rate_pid="$CODEX_RESET_PID" start="$(now_epoch)" - if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.1.1"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.2.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" printf '%s\n' '{"error":{"message":"reset unavailable"}}' return 0 diff --git a/tests/run.sh b/tests/run.sh index 3346295..e75531a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -5,6 +5,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" unset CODEX_AUTH_RUNNER CODEX_AUTH_CODEX_BIN export CODEX_AUTH_TUI_SKIP_BOOTSTRAP=1 export CODEX_AUTH_INSTALL_MAINTAIN_CRON=0 +export CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY=0 fail() { printf 'not ok - %s\n' "$*" >&2 @@ -137,6 +138,156 @@ write_chatgpt_auth() { chmod 0600 "$path" } +write_expiring_chatgpt_auth() { + local path="$1" + local refresh_token="$2" + local account_id="$3" + local expires_epoch="$4" + local user_id="user-$account_id" + local payload access_token + + mkdir -p "$(dirname "$path")" + payload="$(jq -cn \ + --arg account "$account_id" \ + --arg user "$user_id" \ + --argjson exp "$expires_epoch" \ + '{sub:$user,exp:$exp,"https://api.openai.com/auth":{chatgpt_account_id:$account,chatgpt_user_id:$user}}' \ + | base64 -w0 | tr '+/' '-_' | tr -d '=')" + access_token="eyJhbGciOiJub25lIn0.$payload." + jq -cn \ + --arg refresh "$refresh_token" \ + --arg access "$access_token" \ + --arg account "$account_id" \ + '{auth_mode:"chatgpt",tokens:{refresh_token:$refresh,access_token:$access,account_id:$account,id_token:$access}}' \ + > "$path" + chmod 0600 "$path" +} + +write_fake_claude_gpt_auth() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'auth-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" +case "${1:-}" in + claude-gpt-export) + destination="$3" + expected_identity="${4:-}" + [[ -z "$expected_identity" || "$expected_identity" == "identity-pinned" ]] || exit 65 + mkdir -p "${destination%/*}" + chmod 700 "${destination%/*}" + jq -cn \ + --arg access "access-only-test-token" \ + '{access:$access,refresh:"",expires:4102444800000,accountId:"acct-work"}' \ + > "$destination" + chmod 600 "$destination" + expires=$(( $(date +%s) * 1000 + 3600000 )) + jq -cn --arg profile work --arg identity identity-pinned --argjson expires "$expires" \ + '{profile:$profile,account_identity:$identity,profile_revision:"revision",credential_fingerprint:"fingerprint",expires:$expires}' + ;; + refresh) + [[ "${2:-}" == "--quiet" && "${3:-}" == "--fast" && "${4:-}" == "work" ]] || exit 66 + ;; + *) + exit 64 + ;; +esac +EOF + chmod 0755 "$path" +} + +write_fake_claude_gpt_proxy() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "--version" ]]; then + printf 'claude-code-proxy 0.1.10\n' + exit 0 +fi +printf 'proxy-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" +printf 'proxy-config=<%s>\n' "$CCP_CONFIG_DIR" >> "$CODEX_TEST_LOG" +printf 'proxy-state=<%s>\n' "$XDG_STATE_HOME" >> "$CODEX_TEST_LOG" +printf 'proxy-base=<%s>\n' "${CCP_CODEX_BASE_URL-unset}" >> "$CODEX_TEST_LOG" +printf 'proxy-traffic=<%s>\n' "${CCP_TRAFFIC_LOG-unset}" >> "$CODEX_TEST_LOG" +printf 'proxy-effort=<%s>\n' "${CCP_CODEX_EFFORT-unset}" >> "$CODEX_TEST_LOG" +jq -e '.refresh == "" and .expires == 4102444800000 and (.access | length > 0)' \ + "$CCP_CONFIG_DIR/codex/auth.json" >/dev/null +requested_port="" +previous="" +for argument in "$@"; do + if [[ "$previous" == "--port" ]]; then + requested_port="$argument" + break + fi + previous="$argument" +done +if [[ "$requested_port" == "0" ]]; then + mkdir -p "$XDG_STATE_HOME/claude-code-proxy" + jq -cn '{fields:{port:18892},level:"info",msg:"server listening",service:"server"}' \ + > "$XDG_STATE_HOME/claude-code-proxy/proxy.log" +fi +printf '%s\n' "$BASHPID" > "$CODEX_TEST_PROXY_PID" +if [[ "${CODEX_TEST_PROXY_EXIT_EARLY:-0}" == "1" ]]; then + sleep 0.2 + exit 42 +fi +trap 'exit 0' TERM INT HUP +while true; do sleep 1; done +EOF + chmod 0755 "$path" +} + +write_fake_claude_gpt_curl() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +printf 'curl-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" +exit 0 +EOF + chmod 0755 "$path" +} + +write_fake_claude_harness() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'claude-base=<%s>\n' "$ANTHROPIC_BASE_URL" >> "$CODEX_TEST_LOG" +printf 'claude-token=<%s>\n' "$ANTHROPIC_AUTH_TOKEN" >> "$CODEX_TEST_LOG" +printf 'claude-model=<%s>\n' "$ANTHROPIC_MODEL" >> "$CODEX_TEST_LOG" +printf 'claude-opus=<%s>\n' "${ANTHROPIC_DEFAULT_OPUS_MODEL-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-opus-name=<%s>\n' "${ANTHROPIC_DEFAULT_OPUS_MODEL_NAME-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-opus-caps=<%s>\n' "${ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-sonnet=<%s>\n' "${ANTHROPIC_DEFAULT_SONNET_MODEL-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-sonnet-name=<%s>\n' "${ANTHROPIC_DEFAULT_SONNET_MODEL_NAME-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-sonnet-caps=<%s>\n' "${ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-small=<%s>\n' "$ANTHROPIC_SMALL_FAST_MODEL" >> "$CODEX_TEST_LOG" +printf 'claude-haiku=<%s>\n' "$ANTHROPIC_DEFAULT_HAIKU_MODEL" >> "$CODEX_TEST_LOG" +printf 'claude-haiku-name=<%s>\n' "${ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-haiku-caps=<%s>\n' "${ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-custom=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-custom-name=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION_NAME-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-custom-desc=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-custom-caps=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-api-key=<%s>\n' "${ANTHROPIC_API_KEY-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-nonstream=<%s>\n' "$CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK" >> "$CODEX_TEST_LOG" +printf 'claude-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" +if [[ -n "${CODEX_TEST_CLAUDE_PID:-}" ]]; then + printf '%s\n' "$BASHPID" > "$CODEX_TEST_CLAUDE_PID" +fi +if [[ "${CODEX_TEST_CLAUDE_SLEEP:-0}" != "0" ]]; then + trap 'exit 0' TERM INT HUP + sleep "$CODEX_TEST_CLAUDE_SLEEP" +fi +EOF + chmod 0755 "$path" +} + write_reauth_codex() { local path="$1" mkdir -p "$(dirname "$path")" @@ -826,7 +977,9 @@ test_install_installs_codex_auth_libs() { [[ -r "$prefix/lib/codex-auth/core.sh" ]] || fail "core lib was not installed" [[ -r "$prefix/lib/codex-auth/usage.sh" ]] || fail "usage lib was not installed" [[ -r "$prefix/lib/codex-auth/rolling-auth-v2.patch" ]] || fail "rolling auth source patch was not installed" + [[ -r "$prefix/lib/codex-auth/claude-gpt.sh" ]] || fail "Claude GPT auth exporter was not installed" [[ -x "$prefix/bin/codex-auth-tui" ]] || fail "TUI launcher was not installed" + [[ -x "$prefix/bin/claude-gpt" ]] || fail "Claude GPT launcher was not installed" [[ -r "$prefix/lib/codex-auth/tui/pyproject.toml" ]] || fail "TUI project was not installed" [[ -r "$prefix/lib/codex-auth/tui/src/codex_auth_tui/__init__.py" ]] || fail "TUI package was not installed" [[ ! -d "$prefix/lib/codex-auth/tui/.venv" ]] || fail "TUI bootstrap opt-out created a venv" @@ -836,6 +989,32 @@ test_install_installs_codex_auth_libs() { assert_contains 'profiles' "$output" } +test_install_fetches_verified_claude_gpt_proxy() { + local tmp prefix payload archive checksum + tmp="$(mktemp -d)" + prefix="$tmp/prefix" + payload="$tmp/payload" + archive="$tmp/claude-code-proxy-linux-amd64.tar.gz" + checksum="$tmp/claude-code-proxy-linux-amd64.sha256" + mkdir -p "$payload" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "claude-code-proxy 0.1.10\\n"' > "$payload/claude-code-proxy" + chmod 0755 "$payload/claude-code-proxy" + tar -czf "$archive" -C "$payload" claude-code-proxy + (cd "$tmp" && sha256sum "${archive##*/}" > "${checksum##*/}") + + CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY=1 \ + CODEX_AUTH_CLAUDE_GPT_PROXY_ARCHIVE="$archive" \ + CODEX_AUTH_CLAUDE_GPT_PROXY_CHECKSUM="$checksum" \ + PREFIX="$prefix" \ + "$REPO_ROOT/install.sh" >/dev/null + + [[ -x "$prefix/bin/claude-code-proxy" ]] || fail "verified Claude GPT proxy was not installed" + [[ "$("$prefix/bin/claude-code-proxy" --version)" == "claude-code-proxy 0.1.10" ]] \ + || fail "installed Claude GPT proxy reported the wrong version" +} + test_install_bootstraps_tui_with_private_uv_project() { local tmp prefix fake_bin uv_log tmp="$(mktemp -d)" @@ -2199,6 +2378,363 @@ test_doctor_refuses_kill_without_yes() { assert_contains 'refusing to kill sidecars without --yes' "$output" } +test_claude_gpt_export_is_access_only_and_identity_pinned() { + local tmp home source destination metadata expected_access expected_identity expires now sentinel + tmp="$(mktemp -d)" + home="$tmp/home" + source="$home/auth-profiles/work.json" + destination="$tmp/private/codex/auth.json" + metadata="$tmp/metadata.json" + expires=$(( $(date +%s) + 3600 )) + mkdir -p "$home/auth-profiles" + write_expiring_chatgpt_auth "$source" refresh-secret acct-work "$expires" + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" use work >/dev/null + + CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" \ + claude-gpt-export --active "$destination" > "$metadata" + + expected_access="$(jq -r '.tokens.access_token' "$source")" + expected_identity="$(jq -r '.account_identity' "$metadata")" + [[ -n "$expected_identity" ]] || fail "Claude GPT export omitted the stable identity" + [[ "$(jq -r '.access' "$destination")" == "$expected_access" ]] || fail "Claude GPT export changed the access token" + [[ "$(jq -r '.refresh' "$destination")" == "" ]] || fail "Claude GPT export leaked the refresh token" + [[ "$(jq -r '.expires' "$destination")" == "4102444800000" ]] || fail "Claude GPT export did not disable proxy refresh" + [[ "$(jq -r '.accountId' "$destination")" == "acct-work" ]] || fail "Claude GPT export omitted the raw account id" + [[ "$(jq -r 'has("realExpires")' "$destination")" == "false" ]] || fail "Claude GPT export leaked internal lease metadata" + now="$(date +%s)" + (( $(jq -r '.expires' "$metadata") / 1000 >= now + 3500 )) || fail "Claude GPT metadata omitted the real JWT expiry" + [[ "$(stat -c '%a' "$destination")" == "600" ]] || fail "Claude GPT access lease was not private" + assert_not_contains 'refresh-secret' "$metadata" + assert_not_contains "$expected_access" "$metadata" + + sentinel="$tmp/sentinel.json" + printf '%s\n' '{"kept":true}' > "$sentinel" + if CODEX_HOME="$home" "$REPO_ROOT/bin/codex-auth" \ + claude-gpt-export work "$sentinel" wrong-identity >"$tmp/wrong.out" 2>"$tmp/wrong.err"; then + fail "Claude GPT export accepted a changed account identity" + fi + [[ "$(jq -r '.kept' "$sentinel")" == "true" ]] || fail "failed identity pin changed the destination" + assert_contains 'identity changed' "$tmp/wrong.err" +} + +test_claude_gpt_launcher_is_ephemeral_and_preserves_harness_args() { + local tmp home log proxy_pid_file + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + printf '%s\n' '{"profile":"untouched"}' > "$home/active-profile.json" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18888 \ + CCP_CODEX_BASE_URL=https://wrong.example.invalid \ + CCP_TRAFFIC_LOG=1 \ + ANTHROPIC_API_KEY=must-not-leak \ + "$REPO_ROOT/bin/claude-gpt" \ + --profile work \ + --model gpt-5.6-sol \ + --small-model gpt-5.6-luna \ + --bare -- -p 'hello with spaces' --allowedTools Read + + [[ "$(jq -r '.profile' "$home/active-profile.json")" == "untouched" ]] || fail "Claude GPT launcher switched the active profile" + assert_contains 'auth-arg=' "$log" + assert_not_contains 'auth-arg=' "$log" + assert_contains 'proxy-arg=' "$log" + assert_contains 'proxy-arg=<--port>' "$log" + assert_contains 'proxy-arg=<18888>' "$log" + assert_contains 'proxy-arg=<--no-monitor>' "$log" + assert_contains 'proxy-base=' "$log" + assert_contains 'proxy-traffic=' "$log" + assert_contains 'claude-base=' "$log" + assert_contains 'claude-token=' "$log" + assert_contains 'claude-model=' "$log" + assert_contains 'claude-small=' "$log" + assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-api-key=' "$log" + assert_contains 'claude-nonstream=<1>' "$log" + assert_contains 'claude-arg=<--bare>' "$log" + assert_contains 'claude-arg=<-p>' "$log" + assert_contains 'claude-arg=' "$log" + assert_contains 'claude-arg=<--allowedTools>' "$log" + assert_contains 'claude-arg=' "$log" + if find "$home/.tmp" -maxdepth 1 -type d -name 'claude-gpt.*' | grep -q .; then + fail "Claude GPT launcher left a subscription lease on disk" + fi + if [[ -s "$proxy_pid_file" ]] && kill -0 "$(cat "$proxy_pid_file")" 2>/dev/null; then + fail "Claude GPT launcher left its proxy running" + fi +} + +test_claude_gpt_maps_model_lanes_and_advertises_effort() { + local tmp home log proxy_pid_file + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18890 \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p lanes + + assert_contains 'claude-model=' "$log" + assert_contains 'claude-opus=' "$log" + assert_contains 'claude-opus-name=' "$log" + assert_contains 'claude-opus-caps=' "$log" + assert_contains 'claude-sonnet=' "$log" + assert_contains 'claude-sonnet-name=' "$log" + assert_contains 'claude-sonnet-caps=' "$log" + assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-haiku-name=' "$log" + assert_contains 'claude-haiku-caps=' "$log" + assert_contains 'claude-small=' "$log" + assert_contains 'claude-custom=' "$log" + assert_contains 'claude-custom-name=' "$log" + assert_contains 'claude-custom-desc=' "$log" + assert_contains 'claude-custom-caps=' "$log" + # /effort stays dynamic: the launcher must never pin a proxy effort level. + assert_contains 'proxy-effort=' "$log" +} + +test_claude_gpt_lane_flags_and_env_override_models() { + local tmp home log proxy_pid_file + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18891 \ + CLAUDE_GPT_OPUS_MODEL=gpt-env-opus \ + CLAUDE_GPT_FAST_MODEL_NAME='Env Fast Name' \ + CLAUDE_GPT_FAST_MODEL_CAPABILITIES=effort \ + "$REPO_ROOT/bin/claude-gpt" \ + --profile work \ + --model gpt-flag-main \ + --sonnet-model gpt-flag-sonnet \ + --haiku-model gpt-flag-haiku \ + --fast-model gpt-flag-fast \ + --effort high \ + -- -p overrides + + assert_contains 'claude-model=' "$log" + assert_contains 'claude-opus=' "$log" + assert_contains 'claude-sonnet=' "$log" + assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-small=' "$log" + assert_contains 'claude-custom=' "$log" + assert_contains 'claude-custom-name=' "$log" + assert_contains 'claude-custom-caps=' "$log" + assert_contains 'claude-arg=<--effort>' "$log" + assert_contains 'claude-arg=' "$log" +} + +test_claude_gpt_custom_option_can_be_dropped() { + local tmp home log proxy_pid_file + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18893 \ + CLAUDE_GPT_FAST_MODEL= \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p no-custom + + assert_contains 'claude-opus=' "$log" + assert_contains 'claude-sonnet=' "$log" + assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-custom=' "$log" + assert_contains 'claude-custom-name=' "$log" + assert_contains 'claude-custom-caps=' "$log" +} + +test_claude_gpt_launcher_reads_kernel_selected_port() { + local tmp home log proxy_pid_file + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p dynamic-port + + assert_contains 'proxy-arg=<0>' "$log" + assert_contains 'claude-base=' "$log" +} + +test_claude_gpt_launcher_renews_while_harness_runs() { + local tmp home log proxy_pid_file refresh_count + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_TEST_CLAUDE_SLEEP=2 \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18889 \ + CLAUDE_GPT_RENEW_MARGIN_SECS=3599 \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p renew + + refresh_count="$(grep -Fc 'auth-arg=' "$log")" + (( refresh_count >= 2 )) || fail "Claude GPT lease did not renew while the harness was running" + assert_contains 'auth-arg=' "$log" +} + +test_claude_gpt_launcher_fails_closed_when_proxy_exits() { + local tmp home log proxy_pid_file claude_pid_file status + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + claude_pid_file="$tmp/claude.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + set +e + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_TEST_PROXY_EXIT_EARLY=1 \ + CODEX_TEST_CLAUDE_PID="$claude_pid_file" \ + CODEX_TEST_CLAUDE_SLEEP=20 \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18890 \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p proxy-failure \ + >"$tmp/out" 2>"$tmp/err" + status=$? + set -e + + [[ "$status" != "0" ]] || fail "Claude GPT launcher ignored a dead proxy" + assert_contains 'local proxy stopped before Claude Code exited' "$tmp/err" + if [[ -s "$claude_pid_file" ]] && kill -0 "$(cat "$claude_pid_file")" 2>/dev/null; then + fail "Claude GPT launcher left Claude running after proxy failure" + fi +} + +test_claude_gpt_children_die_with_killed_wrapper() { + local tmp home log proxy_pid_file claude_pid_file wrapper_pid attempt proxy_pid claude_pid + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + claude_pid_file="$tmp/claude.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_TEST_CLAUDE_PID="$claude_pid_file" \ + CODEX_TEST_CLAUDE_SLEEP=30 \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18891 \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p parent-death \ + >"$tmp/out" 2>"$tmp/err" & + wrapper_pid=$! + + for (( attempt=0; attempt<100; attempt++ )); do + [[ -s "$proxy_pid_file" && -s "$claude_pid_file" ]] && break + kill -0 "$wrapper_pid" 2>/dev/null || break + sleep 0.05 + done + [[ -s "$proxy_pid_file" && -s "$claude_pid_file" ]] || fail "Claude GPT parent-death test did not start children" + proxy_pid="$(cat "$proxy_pid_file")" + claude_pid="$(cat "$claude_pid_file")" + kill -KILL "$wrapper_pid" + wait "$wrapper_pid" 2>/dev/null || true + + for (( attempt=0; attempt<60; attempt++ )); do + if ! kill -0 "$proxy_pid" 2>/dev/null && ! kill -0 "$claude_pid" 2>/dev/null; then + break + fi + sleep 0.05 + done + if kill -0 "$proxy_pid" 2>/dev/null; then + fail "proxy survived a killed Claude GPT wrapper" + fi + if kill -0 "$claude_pid" 2>/dev/null; then + fail "Claude survived a killed Claude GPT wrapper" + fi +} + main() { local test_name for test_name in \ @@ -2226,6 +2762,7 @@ main() { test_install_recovers_real_from_path \ test_install_recovers_real_from_home_bun \ test_install_installs_codex_auth_libs \ + test_install_fetches_verified_claude_gpt_proxy \ test_install_bootstraps_tui_with_private_uv_project \ test_failed_tui_bootstrap_keeps_previous_install \ test_watch_and_tui_forward_exact_args \ @@ -2271,7 +2808,16 @@ main() { test_parallel_refresh_preserves_one_complete_generation \ test_rolling_hook_keeps_inline_auto_cached_by_default \ test_doctor_reports_legacy_sidecars \ - test_doctor_refuses_kill_without_yes + test_doctor_refuses_kill_without_yes \ + test_claude_gpt_export_is_access_only_and_identity_pinned \ + test_claude_gpt_launcher_is_ephemeral_and_preserves_harness_args \ + test_claude_gpt_maps_model_lanes_and_advertises_effort \ + test_claude_gpt_lane_flags_and_env_override_models \ + test_claude_gpt_custom_option_can_be_dropped \ + test_claude_gpt_launcher_reads_kernel_selected_port \ + test_claude_gpt_launcher_renews_while_harness_runs \ + test_claude_gpt_launcher_fails_closed_when_proxy_exits \ + test_claude_gpt_children_die_with_killed_wrapper do "$test_name" printf 'ok - %s\n' "$test_name" diff --git a/tui/pyproject.toml b/tui/pyproject.toml index e353f6c..3c957d6 100644 --- a/tui/pyproject.toml +++ b/tui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-auth-tui" -version = "0.1.1" +version = "0.2.0" description = "Textual monitor and auto-switch view for codex-rolling-auth" readme = "README.md" requires-python = ">=3.12" diff --git a/tui/src/codex_auth_tui/__init__.py b/tui/src/codex_auth_tui/__init__.py index b83b6d8..823f28f 100644 --- a/tui/src/codex_auth_tui/__init__.py +++ b/tui/src/codex_auth_tui/__init__.py @@ -8,4 +8,4 @@ switching, profile capture, and confirmed earned-reset redemption. """ -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/tui/uv.lock b/tui/uv.lock index 7e0e4b4..aaa1e90 100644 --- a/tui/uv.lock +++ b/tui/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "codex-auth-tui" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "textual" }, From 1d6c94950921edd6e7720d9f151dc38b119f37fb Mon Sep 17 00:00:00 2001 From: editnori Date: Sat, 11 Jul 2026 04:12:51 -0500 Subject: [PATCH 2/5] Add Claude GPT ultracode effort mode --- CHANGELOG.md | 1 + README.md | 14 +++++++++++--- bin/claude-gpt | 9 ++++++--- tests/run.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b8bbd..0fd7204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Kept proxy routing child-only, forwarded Claude Code arguments unchanged, and avoided switching the active Codex profile or rewriting cswap configuration. - Mapped Claude Code's Opus, Sonnet, and Haiku tiers to the `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` lanes, each overridable by a launcher flag or `CLAUDE_GPT_*` variable. - Added clear GPT labels and `effort,xhigh_effort,max_effort` capability declarations to every lane, including one custom `/model` option, `GPT-5.6 Sol Ultra Fast` (`gpt-5.6-sol-fast`); `claude-gpt --effort` sets only the starting level, `/effort` stays dynamic, and the launcher never pins `CCP_CODEX_EFFORT`. +- Added `claude-gpt --effort ultracode` and its `ultra` alias for Claude Code's xhigh-plus-dynamic-workflow mode, and documented the pinned proxy's current `max`-to-`xhigh` limitation separately from Ultra Fast service tier. ## 0.1.1 - 2026-07-10 diff --git a/README.md b/README.md index 1d15836..6e2e641 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,19 @@ and `GPT-5.6 Luna Light`. It also carries one custom option, `GPT-5.6 Sol Ultra Fast` (`gpt-5.6-sol-fast`), which routes Sol through the proxy's priority service tier. Every lane advertises `effort,xhigh_effort,max_effort`, so `/effort` -offers low through max and the proxy translates the chosen level per turn. The -launcher never sets `CCP_CODEX_EFFORT`, so `/effort` stays live. Start at a -specific level with `claude-gpt --effort high`; `/effort` can still change it +offers low through max plus Claude Code's `ultracode` mode when dynamic +workflows are available. `ultracode` means xhigh effort plus Claude's workflow +orchestration; it is not another Sol reasoning-effort value. Start it with +`claude-gpt --effort ultracode` (or the `ultra` alias). The launcher never sets +`CCP_CODEX_EFFORT`, so `/effort` stays live and can still change the level inside the session. +GPT-5.6 Sol's highest direct reasoning effort is `max`. The pinned upstream +proxy 0.1.10 predates that Sol behavior and maps Claude's `max` to `xhigh`; +`ultracode` still works as designed because its underlying effort is xhigh. +Until the proxy's max-effort correction ships, do not treat its `max` picker +entry as true Sol `reasoning.effort: "max"`. + Sol Ultra Fast uses the selected subscription profile's separate Codex fast-mode quota. If that quota is unavailable, the backend can return `usage limit reached`; switch `/model` back to `GPT-5.6 Sol Deep`. The launcher does diff --git a/bin/claude-gpt b/bin/claude-gpt index 61cfc42..58138ae 100755 --- a/bin/claude-gpt +++ b/bin/claude-gpt @@ -46,8 +46,10 @@ Launcher options: --fast-model MODEL custom "GPT-5.6 Sol Ultra Fast" picker option (default: gpt-5.6-sol-fast; set CLAUDE_GPT_FAST_MODEL= to drop it) - --effort LEVEL starting effort: low, medium, high, xhigh, max, or auto - (still changeable with /effort) + --effort LEVEL starting effort: low, medium, high, xhigh, max, + ultracode (or ultra), or auto + (still changeable with /effort; ultracode is xhigh plus + Claude's dynamic workflow orchestration) --bare pass --bare to Claude Code -h, --help show this help @@ -181,7 +183,8 @@ main() { [[ $# -ge 2 && -n "$2" ]] || die "--effort needs a level" case "$2" in low|medium|high|xhigh|max|auto) effort="$2" ;; - *) die "--effort must be low, medium, high, xhigh, max, or auto" ;; + ultra|ultracode) effort="ultracode" ;; + *) die "--effort must be low, medium, high, xhigh, max, ultracode (or ultra), or auto" ;; esac shift 2 ;; diff --git a/tests/run.sh b/tests/run.sh index e75531a..31c93b2 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2561,6 +2561,35 @@ test_claude_gpt_lane_flags_and_env_override_models() { assert_contains 'claude-arg=' "$log" } +test_claude_gpt_ultra_alias_starts_ultracode_workflows() { + local tmp home log proxy_pid_file + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18894 \ + "$REPO_ROOT/bin/claude-gpt" --profile work --effort ultra -- -p workflows + + assert_contains 'claude-arg=<--effort>' "$log" + assert_contains 'claude-arg=' "$log" + # Ultracode must not freeze the proxy at one effort; Claude owns its + # xhigh-plus-workflow orchestration for the session. + assert_contains 'proxy-effort=' "$log" +} + test_claude_gpt_custom_option_can_be_dropped() { local tmp home log proxy_pid_file tmp="$(mktemp -d)" @@ -2813,6 +2842,7 @@ main() { test_claude_gpt_launcher_is_ephemeral_and_preserves_harness_args \ test_claude_gpt_maps_model_lanes_and_advertises_effort \ test_claude_gpt_lane_flags_and_env_override_models \ + test_claude_gpt_ultra_alias_starts_ultracode_workflows \ test_claude_gpt_custom_option_can_be_dropped \ test_claude_gpt_launcher_reads_kernel_selected_port \ test_claude_gpt_launcher_renews_while_harness_runs \ From ca8333849e503bcd1919db42fbe023acafba5fb2 Mon Sep 17 00:00:00 2001 From: editnori Date: Sat, 11 Jul 2026 05:35:31 -0500 Subject: [PATCH 3/5] Fix Claude GPT compaction recovery --- CHANGELOG.md | 4 +++- README.md | 51 +++++++++++++++++++++++++++++++++-------- bin/claude-gpt | 47 +++++++++++++++++++++++++++++++------- install.sh | 4 ++-- tests/run.sh | 62 ++++++++++++++++++++++++++++++++------------------ 5 files changed, 125 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd7204..eb205d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ - Kept proxy routing child-only, forwarded Claude Code arguments unchanged, and avoided switching the active Codex profile or rewriting cswap configuration. - Mapped Claude Code's Opus, Sonnet, and Haiku tiers to the `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` lanes, each overridable by a launcher flag or `CLAUDE_GPT_*` variable. - Added clear GPT labels and `effort,xhigh_effort,max_effort` capability declarations to every lane, including one custom `/model` option, `GPT-5.6 Sol Ultra Fast` (`gpt-5.6-sol-fast`); `claude-gpt --effort` sets only the starting level, `/effort` stays dynamic, and the launcher never pins `CCP_CODEX_EFFORT`. -- Added `claude-gpt --effort ultracode` and its `ultra` alias for Claude Code's xhigh-plus-dynamic-workflow mode, and documented the pinned proxy's current `max`-to-`xhigh` limitation separately from Ultra Fast service tier. +- Added `claude-gpt --effort ultracode` and its `ultra` alias for Claude Code's xhigh-plus-dynamic-workflow mode, separate from the Ultra Fast service tier. +- Added the `[1m]` Claude-side model hint with an explicit 372K subscription ceiling, plus `--compact-window` and `CLAUDE_GPT_COMPACT_WINDOW` for safe earlier compaction. +- Pinned a verified `claude-code-proxy` compatibility build that preserves Sol `max` effort and maps upstream context-window overflow to terminal HTTP 413, allowing Claude Code to trim, compact, and retry oversized resumed sessions instead of looping on 502 errors. ## 0.1.1 - 2026-07-10 diff --git a/README.md b/README.md index 6e2e641..c606fab 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ All captures use the real Textual app with an in-memory synthetic backend. - [`uv`](https://docs.astral.sh/uv/) for the isolated Textual environment. - `crontab` is optional; without it, run `codex-auth maintain` after a direct curl update. - Building the patched Codex generation also needs a Rust/Cargo toolchain and normal native build dependencies. -- Running GPT inside Claude Code also needs Claude Code and [`claude-code-proxy` 0.1.10](https://github.com/raine/claude-code-proxy/releases/tag/v0.1.10). +- Running GPT inside Claude Code also needs Claude Code. The installer supplies + the pinned [`claude-code-proxy` compatibility build](https://github.com/editnori/claude-code-proxy/releases/tag/v0.1.10-codex-auth.1). ## Install @@ -68,10 +69,16 @@ If you only want the manager and not the `codex` shim: ./install.sh ``` -`claude-code-proxy` is a separate third-party dependency. `install.sh` downloads -the pinned 0.1.10 release artifact, verifies its published SHA-256 checksum, and -installs it beside `claude-gpt`. The launcher prefers that sibling binary and -refuses a different version by default. Set +`claude-code-proxy` is a separate MIT-licensed third-party dependency. +`install.sh` downloads the pinned `0.1.10-codex-auth.1` compatibility build, +verifies its published SHA-256 checksum, and installs it beside `claude-gpt`. +That build stays source-visible in the +[`editnori/claude-code-proxy` fork](https://github.com/editnori/claude-code-proxy) +and contains two focused fixes submitted upstream: true Sol `max` effort +([PR #28](https://github.com/raine/claude-code-proxy/pull/28)) and terminal +context-window responses that activate Claude compaction +([PR #29](https://github.com/raine/claude-code-proxy/pull/29)). The launcher +prefers the sibling binary and refuses a different version by default. Set `CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY=0` only if you do not want GPT-in-Claude support or will install the exact proxy version yourself. @@ -121,11 +128,9 @@ orchestration; it is not another Sol reasoning-effort value. Start it with `CCP_CODEX_EFFORT`, so `/effort` stays live and can still change the level inside the session. -GPT-5.6 Sol's highest direct reasoning effort is `max`. The pinned upstream -proxy 0.1.10 predates that Sol behavior and maps Claude's `max` to `xhigh`; -`ultracode` still works as designed because its underlying effort is xhigh. -Until the proxy's max-effort correction ships, do not treat its `max` picker -entry as true Sol `reasoning.effort: "max"`. +GPT-5.6 Sol's highest direct reasoning effort is `max`. The pinned compatibility +build preserves that value as true Sol `reasoning.effort: "max"`. `ultracode` +remains a separate Claude Code workflow mode whose underlying effort is xhigh. Sol Ultra Fast uses the selected subscription profile's separate Codex fast-mode quota. If that quota is unavailable, the backend can return `usage @@ -153,6 +158,32 @@ the Opus, Sonnet, and Haiku lanes. Their picker labels can be overridden with `CLAUDE_GPT_OPUS_MODEL_NAME`, `CLAUDE_GPT_SONNET_MODEL_NAME`, and `CLAUDE_GPT_HAIKU_MODEL_NAME` (plus matching `_DESCRIPTION` variables). +### Context and compaction + +The launcher gives Claude Code child-facing model ids such as +`gpt-5.6-sol[1m]`, then the proxy removes `[1m]` before sending the request to +Codex. The hint stops Claude from silently applying its 200K unknown-model cap. +An explicit 372K auto-compact ceiling still matches the Codex subscription +metadata, so the hint does not claim that the gateway accepts a 1M prompt. + +When a resumed Claude session is already over that limit, the compatibility +proxy returns the upstream context-window failure as terminal HTTP 413 +`request_too_large`. Claude Code recognizes that response, trims the oldest +message groups, compacts the session, and retries the interrupted turn. It no +longer retries the same oversized request as a 502 server failure. + +Lower the ceiling when you want earlier compaction: + +```bash +claude-gpt --compact-window 300000 +CLAUDE_GPT_COMPACT_WINDOW=300000 claude-gpt --continue +``` + +The accepted range is 100000-372000. The launcher also honors an existing +`CLAUDE_CODE_AUTO_COMPACT_WINDOW` when the launcher-specific variable and flag +are absent. A proxy already running in another terminal must be exited and +relaunched before a newly installed compatibility build takes effect. + This path uses the selected profile's ChatGPT/Codex subscription, not `OPENAI_API_KEY`. `codex-auth` remains the only refresh owner. The proxy receives an access-only lease in a private temporary directory, never the refresh token; diff --git a/bin/claude-gpt b/bin/claude-gpt index 58138ae..7195a98 100755 --- a/bin/claude-gpt +++ b/bin/claude-gpt @@ -3,7 +3,8 @@ set -euo pipefail umask 077 -CLAUDE_GPT_PROXY_VERSION="0.1.10" +CLAUDE_GPT_PROXY_VERSION="0.1.10-codex-auth.1" +CLAUDE_GPT_DEFAULT_COMPACT_WINDOW="372000" # Dynamic model lanes. Claude Code keeps /model and /effort live; these only set # which GPT model each Claude tier maps to. The proxy translates every id and the # per-turn effort, so the launcher never pins a global effort. @@ -46,6 +47,8 @@ Launcher options: --fast-model MODEL custom "GPT-5.6 Sol Ultra Fast" picker option (default: gpt-5.6-sol-fast; set CLAUDE_GPT_FAST_MODEL= to drop it) + --compact-window N auto-compact ceiling, 100000-372000 tokens + (default: 372000; env: CLAUDE_GPT_COMPACT_WINDOW) --effort LEVEL starting effort: low, medium, high, xhigh, max, ultracode (or ultra), or auto (still changeable with /effort; ultracode is xhigh plus @@ -85,6 +88,17 @@ metadata_field() { jq -er --arg field "$field" '.[$field] | select(. != null and . != "")' <<<"$metadata" } +claude_context_model() { + local model="$1" + if [[ -z "$model" || "${model,,}" == *'[1m]' ]]; then + printf '%s\n' "$model" + else + # Claude Code otherwise treats custom GPT ids as unknown 200K models. The + # proxy removes this local context hint before sending the id to Codex. + printf '%s[1m]\n' "$model" + fi +} + process_alive() { local pid="${1:-}" local state="" @@ -139,6 +153,7 @@ main() { local fast_model_name="${CLAUDE_GPT_FAST_MODEL_NAME:-$CLAUDE_GPT_DEFAULT_FAST_MODEL_NAME}" local fast_model_description="${CLAUDE_GPT_FAST_MODEL_DESCRIPTION:-$CLAUDE_GPT_DEFAULT_FAST_MODEL_DESCRIPTION}" local fast_model_capabilities="${CLAUDE_GPT_FAST_MODEL_CAPABILITIES:-$model_capabilities}" + local compact_window="${CLAUDE_GPT_COMPACT_WINDOW:-${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-$CLAUDE_GPT_DEFAULT_COMPACT_WINDOW}}" local effort="" bare=0 local script_dir codex_auth proxy_bin claude_bin curl_bin local codex_home session_dir config_dir auth_file state_dir proxy_log proxy_state_log stop_file @@ -179,6 +194,11 @@ main() { fast_model="$2" shift 2 ;; + --compact-window) + [[ $# -ge 2 && -n "$2" ]] || die "--compact-window needs a token count" + compact_window="$2" + shift 2 + ;; --effort) [[ $# -ge 2 && -n "$2" ]] || die "--effort needs a level" case "$2" in @@ -208,6 +228,10 @@ main() { esac done + [[ "$compact_window" =~ ^[1-9][0-9]{5}$ ]] \ + && (( compact_window >= 100000 && compact_window <= CLAUDE_GPT_DEFAULT_COMPACT_WINDOW )) \ + || die "--compact-window must be between 100000 and $CLAUDE_GPT_DEFAULT_COMPACT_WINDOW" + command -v jq >/dev/null 2>&1 || die "jq is required" command -v python3 >/dev/null 2>&1 || die "python3 is required for child-process containment" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -402,6 +426,13 @@ main() { # Map the Claude tiers to their GPT lanes. ANTHROPIC_MODEL is the starting # model; /model switches between these lanes without restarting the launcher. + local claude_model claude_opus_model claude_sonnet_model claude_small_model claude_fast_model + claude_model="$(claude_context_model "$model")" + claude_opus_model="$(claude_context_model "$opus_model")" + claude_sonnet_model="$(claude_context_model "$sonnet_model")" + claude_small_model="$(claude_context_model "$small_model")" + claude_fast_model="$(claude_context_model "$fast_model")" + local -a claude_env=( -u ANTHROPIC_API_KEY -u ANTHROPIC_BEDROCK_BASE_URL @@ -411,21 +442,21 @@ main() { -u CLAUDE_CODE_USE_VERTEX ANTHROPIC_BASE_URL="http://127.0.0.1:$port" ANTHROPIC_AUTH_TOKEN=unused - ANTHROPIC_MODEL="$model" - ANTHROPIC_DEFAULT_OPUS_MODEL="$opus_model" + ANTHROPIC_MODEL="$claude_model" + ANTHROPIC_DEFAULT_OPUS_MODEL="$claude_opus_model" ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="$opus_model_name" ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="$opus_model_description" ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES="$model_capabilities" - ANTHROPIC_DEFAULT_SONNET_MODEL="$sonnet_model" + ANTHROPIC_DEFAULT_SONNET_MODEL="$claude_sonnet_model" ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="$sonnet_model_name" ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="$sonnet_model_description" ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES="$model_capabilities" - ANTHROPIC_DEFAULT_HAIKU_MODEL="$small_model" + ANTHROPIC_DEFAULT_HAIKU_MODEL="$claude_small_model" ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="$haiku_model_name" ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="$haiku_model_description" ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES="$model_capabilities" - ANTHROPIC_SMALL_FAST_MODEL="$small_model" - CLAUDE_CODE_AUTO_COMPACT_WINDOW="${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-372000}" + ANTHROPIC_SMALL_FAST_MODEL="$claude_small_model" + CLAUDE_CODE_AUTO_COMPACT_WINDOW="$compact_window" CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 ) @@ -434,7 +465,7 @@ main() { # the launcher still leaves CCP_CODEX_EFFORT unset so /effort stays dynamic. if [[ -n "$fast_model" ]]; then claude_env+=( - ANTHROPIC_CUSTOM_MODEL_OPTION="$fast_model" + ANTHROPIC_CUSTOM_MODEL_OPTION="$claude_fast_model" ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="$fast_model_name" ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="$fast_model_description" ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES="$fast_model_capabilities" diff --git a/install.sh b/install.sh index 7a0ff47..7262741 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ codex_home="${CODEX_HOME:-$HOME/.codex}" standalone_root="$codex_home/packages/standalone" tui_source="$repo_dir/tui" tui_dir="$libdir/tui" -claude_gpt_proxy_version="0.1.10" +claude_gpt_proxy_version="0.1.10-codex-auth.1" mkdir -p "$bindir" "$libdir" if [[ ! -f "$tui_source/pyproject.toml" || ! -f "$tui_source/uv.lock" || ! -d "$tui_source/src/codex_auth_tui" ]]; then @@ -87,7 +87,7 @@ install_claude_gpt_proxy() { archive_name="claude-code-proxy-$platform.tar.gz" checksum_name="claude-code-proxy-$platform.sha256" - release_base="https://github.com/raine/claude-code-proxy/releases/download/v$claude_gpt_proxy_version" + release_base="https://github.com/editnori/claude-code-proxy/releases/download/v$claude_gpt_proxy_version" download_dir="$install_stage/claude-code-proxy" archive="$download_dir/$archive_name" checksum="$download_dir/$checksum_name" diff --git a/tests/run.sh b/tests/run.sh index 31c93b2..000a34b 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -204,7 +204,7 @@ write_fake_claude_gpt_proxy() { #!/usr/bin/env bash set -euo pipefail if [[ "${1:-}" == "--version" ]]; then - printf 'claude-code-proxy 0.1.10\n' + printf 'claude-code-proxy 0.1.10-codex-auth.1\n' exit 0 fi printf 'proxy-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" @@ -274,6 +274,7 @@ printf 'claude-custom=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION-unset}" >> "$CODE printf 'claude-custom-name=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION_NAME-unset}" >> "$CODEX_TEST_LOG" printf 'claude-custom-desc=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION-unset}" >> "$CODEX_TEST_LOG" printf 'claude-custom-caps=<%s>\n' "${ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES-unset}" >> "$CODEX_TEST_LOG" +printf 'claude-compact-window=<%s>\n' "${CLAUDE_CODE_AUTO_COMPACT_WINDOW-unset}" >> "$CODEX_TEST_LOG" printf 'claude-api-key=<%s>\n' "${ANTHROPIC_API_KEY-unset}" >> "$CODEX_TEST_LOG" printf 'claude-nonstream=<%s>\n' "$CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK" >> "$CODEX_TEST_LOG" printf 'claude-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" @@ -999,7 +1000,7 @@ test_install_fetches_verified_claude_gpt_proxy() { mkdir -p "$payload" printf '%s\n' \ '#!/usr/bin/env bash' \ - 'printf "claude-code-proxy 0.1.10\\n"' > "$payload/claude-code-proxy" + 'printf "claude-code-proxy 0.1.10-codex-auth.1\\n"' > "$payload/claude-code-proxy" chmod 0755 "$payload/claude-code-proxy" tar -czf "$archive" -C "$payload" claude-code-proxy (cd "$tmp" && sha256sum "${archive##*/}" > "${checksum##*/}") @@ -1011,7 +1012,7 @@ test_install_fetches_verified_claude_gpt_proxy() { "$REPO_ROOT/install.sh" >/dev/null [[ -x "$prefix/bin/claude-code-proxy" ]] || fail "verified Claude GPT proxy was not installed" - [[ "$("$prefix/bin/claude-code-proxy" --version)" == "claude-code-proxy 0.1.10" ]] \ + [[ "$("$prefix/bin/claude-code-proxy" --version)" == "claude-code-proxy 0.1.10-codex-auth.1" ]] \ || fail "installed Claude GPT proxy reported the wrong version" } @@ -2417,6 +2418,19 @@ test_claude_gpt_export_is_access_only_and_identity_pinned() { assert_contains 'identity changed' "$tmp/wrong.err" } +test_claude_gpt_rejects_unsafe_compact_windows() { + local tmp value + tmp="$(mktemp -d)" + for value in 99999 372001 invalid 18446744073709651616; do + if "$REPO_ROOT/bin/claude-gpt" --compact-window "$value" \ + >"$tmp/$value.out" 2>"$tmp/$value.err" + then + fail "Claude GPT accepted unsafe compact window: $value" + fi + assert_contains '--compact-window must be between 100000 and 372000' "$tmp/$value.err" + done +} + test_claude_gpt_launcher_is_ephemeral_and_preserves_harness_args() { local tmp home log proxy_pid_file tmp="$(mktemp -d)" @@ -2458,9 +2472,10 @@ test_claude_gpt_launcher_is_ephemeral_and_preserves_harness_args() { assert_contains 'proxy-traffic=' "$log" assert_contains 'claude-base=' "$log" assert_contains 'claude-token=' "$log" - assert_contains 'claude-model=' "$log" - assert_contains 'claude-small=' "$log" - assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-model=' "$log" + assert_contains 'claude-small=' "$log" + assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-compact-window=<372000>' "$log" assert_contains 'claude-api-key=' "$log" assert_contains 'claude-nonstream=<1>' "$log" assert_contains 'claude-arg=<--bare>' "$log" @@ -2498,18 +2513,18 @@ test_claude_gpt_maps_model_lanes_and_advertises_effort() { CLAUDE_GPT_PORT=18890 \ "$REPO_ROOT/bin/claude-gpt" --profile work -- -p lanes - assert_contains 'claude-model=' "$log" - assert_contains 'claude-opus=' "$log" + assert_contains 'claude-model=' "$log" + assert_contains 'claude-opus=' "$log" assert_contains 'claude-opus-name=' "$log" assert_contains 'claude-opus-caps=' "$log" - assert_contains 'claude-sonnet=' "$log" + assert_contains 'claude-sonnet=' "$log" assert_contains 'claude-sonnet-name=' "$log" assert_contains 'claude-sonnet-caps=' "$log" - assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-haiku=' "$log" assert_contains 'claude-haiku-name=' "$log" assert_contains 'claude-haiku-caps=' "$log" - assert_contains 'claude-small=' "$log" - assert_contains 'claude-custom=' "$log" + assert_contains 'claude-small=' "$log" + assert_contains 'claude-custom=' "$log" assert_contains 'claude-custom-name=' "$log" assert_contains 'claude-custom-desc=' "$log" assert_contains 'claude-custom-caps=' "$log" @@ -2542,21 +2557,23 @@ test_claude_gpt_lane_flags_and_env_override_models() { CLAUDE_GPT_FAST_MODEL_CAPABILITIES=effort \ "$REPO_ROOT/bin/claude-gpt" \ --profile work \ - --model gpt-flag-main \ + --model 'gpt-flag-main[1m]' \ --sonnet-model gpt-flag-sonnet \ --haiku-model gpt-flag-haiku \ --fast-model gpt-flag-fast \ + --compact-window 300000 \ --effort high \ -- -p overrides - assert_contains 'claude-model=' "$log" - assert_contains 'claude-opus=' "$log" - assert_contains 'claude-sonnet=' "$log" - assert_contains 'claude-haiku=' "$log" - assert_contains 'claude-small=' "$log" - assert_contains 'claude-custom=' "$log" + assert_contains 'claude-model=' "$log" + assert_contains 'claude-opus=' "$log" + assert_contains 'claude-sonnet=' "$log" + assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-small=' "$log" + assert_contains 'claude-custom=' "$log" assert_contains 'claude-custom-name=' "$log" assert_contains 'claude-custom-caps=' "$log" + assert_contains 'claude-compact-window=<300000>' "$log" assert_contains 'claude-arg=<--effort>' "$log" assert_contains 'claude-arg=' "$log" } @@ -2613,9 +2630,9 @@ test_claude_gpt_custom_option_can_be_dropped() { CLAUDE_GPT_FAST_MODEL= \ "$REPO_ROOT/bin/claude-gpt" --profile work -- -p no-custom - assert_contains 'claude-opus=' "$log" - assert_contains 'claude-sonnet=' "$log" - assert_contains 'claude-haiku=' "$log" + assert_contains 'claude-opus=' "$log" + assert_contains 'claude-sonnet=' "$log" + assert_contains 'claude-haiku=' "$log" assert_contains 'claude-custom=' "$log" assert_contains 'claude-custom-name=' "$log" assert_contains 'claude-custom-caps=' "$log" @@ -2839,6 +2856,7 @@ main() { test_doctor_reports_legacy_sidecars \ test_doctor_refuses_kill_without_yes \ test_claude_gpt_export_is_access_only_and_identity_pinned \ + test_claude_gpt_rejects_unsafe_compact_windows \ test_claude_gpt_launcher_is_ephemeral_and_preserves_harness_args \ test_claude_gpt_maps_model_lanes_and_advertises_effort \ test_claude_gpt_lane_flags_and_env_override_models \ From 740691c7d5822032073f4950d285c51cf0e5dbed Mon Sep 17 00:00:00 2001 From: editnori Date: Sat, 11 Jul 2026 07:01:24 -0500 Subject: [PATCH 4/5] Fix Claude GPT quota rollover --- CHANGELOG.md | 4 +- README.md | 26 ++++-- bin/claude-gpt | 156 ++++++++++++++++++++++++++++++--- install.sh | 2 +- tests/run.sh | 230 ++++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 384 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb205d4..e7c0c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,15 @@ ## 0.2.0 - 2026-07-11 - Added `claude-gpt`, an opt-in Claude Code harness backed by saved ChatGPT/Codex subscription auth. -- Kept refresh tokens under `codex-auth` ownership by giving each local proxy an ephemeral access-only lease pinned to one account identity. +- Kept refresh tokens under `codex-auth` ownership by giving each local proxy an ephemeral access-only lease snapshot containing one account identity. - Kept proxy routing child-only, forwarded Claude Code arguments unchanged, and avoided switching the active Codex profile or rewriting cswap configuration. - Mapped Claude Code's Opus, Sonnet, and Haiku tiers to the `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` lanes, each overridable by a launcher flag or `CLAUDE_GPT_*` variable. - Added clear GPT labels and `effort,xhigh_effort,max_effort` capability declarations to every lane, including one custom `/model` option, `GPT-5.6 Sol Ultra Fast` (`gpt-5.6-sol-fast`); `claude-gpt --effort` sets only the starting level, `/effort` stays dynamic, and the launcher never pins `CCP_CODEX_EFFORT`. - Added `claude-gpt --effort ultracode` and its `ultra` alias for Claude Code's xhigh-plus-dynamic-workflow mode, separate from the Ultra Fast service tier. - Added the `[1m]` Claude-side model hint with an explicit 372K subscription ceiling, plus `--compact-window` and `CLAUDE_GPT_COMPACT_WINDOW` for safe earlier compaction. - Pinned a verified `claude-code-proxy` compatibility build that preserves Sol `max` effort and maps upstream context-window overflow to terminal HTTP 413, allowing Claude Code to trim, compact, and retry oversized resumed sessions instead of looping on 502 errors. +- Made default `claude-gpt` sessions follow `codex-auth` active-profile changes through staged access-only lease swaps; explicit `--profile` sessions remain identity-pinned. +- Mapped ordinary Codex quota exhaustion to immediate HTTP 429 and oversized rate-limit responses to HTTP 413 compaction recovery, removing nested proxy/Claude retry storms. ## 0.1.1 - 2026-07-10 diff --git a/README.md b/README.md index c606fab..ba5db07 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ All captures use the real Textual app with an in-memory synthetic backend. - `crontab` is optional; without it, run `codex-auth maintain` after a direct curl update. - Building the patched Codex generation also needs a Rust/Cargo toolchain and normal native build dependencies. - Running GPT inside Claude Code also needs Claude Code. The installer supplies - the pinned [`claude-code-proxy` compatibility build](https://github.com/editnori/claude-code-proxy/releases/tag/v0.1.10-codex-auth.1). + the pinned [`claude-code-proxy` compatibility build](https://github.com/editnori/claude-code-proxy/releases/tag/v0.1.10-codex-auth.2). ## Install @@ -70,14 +70,16 @@ If you only want the manager and not the `codex` shim: ``` `claude-code-proxy` is a separate MIT-licensed third-party dependency. -`install.sh` downloads the pinned `0.1.10-codex-auth.1` compatibility build, +`install.sh` downloads the pinned `0.1.10-codex-auth.2` compatibility build, verifies its published SHA-256 checksum, and installs it beside `claude-gpt`. That build stays source-visible in the [`editnori/claude-code-proxy` fork](https://github.com/editnori/claude-code-proxy) and contains two focused fixes submitted upstream: true Sol `max` effort ([PR #28](https://github.com/raine/claude-code-proxy/pull/28)) and terminal context-window responses that activate Claude compaction -([PR #29](https://github.com/raine/claude-code-proxy/pull/29)). The launcher +([PR #29](https://github.com/raine/claude-code-proxy/pull/29)), plus immediate +rate-limit classification and isolated-auth reloads +([PR #30](https://github.com/raine/claude-code-proxy/pull/30)). The launcher prefers the sibling binary and refuses a different version by default. Set `CODEX_AUTH_INSTALL_CLAUDE_GPT_PROXY=0` only if you do not want GPT-in-Claude support or will install the exact proxy version yourself. @@ -172,6 +174,11 @@ proxy returns the upstream context-window failure as terminal HTTP 413 message groups, compacts the session, and retries the interrupted turn. It no longer retries the same oversized request as a 502 server failure. +Codex rate-limit events also stop immediately. Normal quota exhaustion returns +HTTP 429 with its retry delay instead of becoming two nested ten-attempt retry +loops. If the same event arrives for an estimated input at or above 372K, the +proxy returns 413 so Claude can trim the oversized compaction request first. + Lower the ceiling when you want earlier compaction: ```bash @@ -187,14 +194,17 @@ relaunched before a newly installed compatibility build takes effect. This path uses the selected profile's ChatGPT/Codex subscription, not `OPENAI_API_KEY`. `codex-auth` remains the only refresh owner. The proxy receives an access-only lease in a private temporary directory, never the refresh token; -renewals must retain the same hashed account identity, and the proxy, lease, and +each lease snapshot contains one account identity, and the proxy, lease, and temporary files are removed when Claude Code exits. Ordinary `claude`, `codex`, and `cswap` configuration is not rewritten by the launcher. Claude Code still runs its normal harness and may update its own history or credential metadata as -it would on an ordinary launch. The launcher never switches the active Codex -profile; if the selected profile is already active, official Codex may rotate -that profile's credential in place during renewal so the live login does not go -stale. +it would on an ordinary launch. The launcher never changes which Codex profile +is active. A default `claude-gpt` session follows changes made by `codex-auth` +watch/auto mode: it stages and validates a new access-only lease, then the proxy +reloads that lease on the next request without restarting Claude. An explicit +`claude-gpt --profile NAME` remains pinned to that account, requires renewals to +retain its hashed identity, and ignores active profile changes. Official Codex +remains the only refresh-token owner in both modes. The proxy binds a kernel-selected loopback port for one Claude Code process. Its HTTP routes do not have their own local authorization layer, so this assumes diff --git a/bin/claude-gpt b/bin/claude-gpt index 7195a98..0558edf 100755 --- a/bin/claude-gpt +++ b/bin/claude-gpt @@ -3,7 +3,7 @@ set -euo pipefail umask 077 -CLAUDE_GPT_PROXY_VERSION="0.1.10-codex-auth.1" +CLAUDE_GPT_PROXY_VERSION="0.1.10-codex-auth.2" CLAUDE_GPT_DEFAULT_COMPACT_WINDOW="372000" # Dynamic model lanes. Claude Code keeps /model and /effort live; these only set # which GPT model each Claude tier maps to. The proxy translates every id and the @@ -38,7 +38,7 @@ subscription profile. This does not use OPENAI_API_KEY or change the active codex-auth profile. Launcher options: - --profile NAME use a saved profile (default: current active profile) + --profile NAME use and pin a saved profile (default: follow active profile) --model MODEL starting model (default: gpt-5.6-sol) --opus-model MODEL Opus deep lane (default: gpt-5.6-sol) --sonnet-model MODEL Sonnet balanced lane (default: gpt-5.6-terra) @@ -88,6 +88,36 @@ metadata_field() { jq -er --arg field "$field" '.[$field] | select(. != null and . != "")' <<<"$metadata" } +active_marker_state() { + local marker="$1" + jq -c ' + select(.version == 2) + | {profile, account_identity, profile_revision, profile_fingerprint} + ' "$marker" 2>/dev/null || printf '%s\n' unavailable +} + +metadata_marker_state() { + local metadata="$1" + jq -c ' + { + profile, + account_identity, + profile_revision, + profile_fingerprint: .credential_fingerprint + } + ' <<<"$metadata" +} + +proxy_lease_file_is_safe() { + local lease_file="$1" + jq -e --argjson lease_expires "$CLAUDE_GPT_PROXY_LEASE_EXPIRES" ' + .refresh == "" + and .expires == $lease_expires + and ((.access // "") | type == "string" and length > 0) + and ((.accountId // "") | type == "string" and length > 0) + ' "$lease_file" >/dev/null 2>&1 +} + claude_context_model() { local model="$1" if [[ -z "$model" || "${model,,}" == *'[1m]' ]]; then @@ -136,6 +166,7 @@ signal_process_tree() { main() { local profile="--active" + local follow_active=1 local model="${CLAUDE_GPT_MODEL:-$CLAUDE_GPT_DEFAULT_MODEL}" local opus_model="${CLAUDE_GPT_OPUS_MODEL:-$CLAUDE_GPT_DEFAULT_OPUS_MODEL}" local opus_model_name="${CLAUDE_GPT_OPUS_MODEL_NAME:-$CLAUDE_GPT_DEFAULT_OPUS_MODEL_NAME}" @@ -156,8 +187,10 @@ main() { local compact_window="${CLAUDE_GPT_COMPACT_WINDOW:-${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-$CLAUDE_GPT_DEFAULT_COMPACT_WINDOW}}" local effort="" bare=0 local script_dir codex_auth proxy_bin claude_bin curl_bin - local codex_home session_dir config_dir auth_file state_dir proxy_log proxy_state_log stop_file + local codex_home session_dir config_dir auth_file active_lease_candidate active_profile_file + local state_dir proxy_log proxy_state_log stop_file local metadata initial_expires expires profile_name identity now requested_port port proxy_version + local initial_profile_name initial_identity active_state="" local renew_margin_secs parent_death_code local proxy_pid="" lease_pid="" claude_pid="" wrapper_pid="$BASHPID" status=0 local -a claude_args=() @@ -167,6 +200,7 @@ main() { --profile) [[ $# -ge 2 && -n "$2" ]] || die "--profile needs a name" profile="$2" + [[ "$profile" == "--active" ]] || follow_active=0 shift 2 ;; --model) @@ -252,6 +286,8 @@ main() { session_dir="$(mktemp -d "$codex_home/.tmp/claude-gpt.XXXXXX")" config_dir="$session_dir/config" auth_file="$config_dir/codex/auth.json" + active_lease_candidate="$config_dir/codex/auth.active-next.json" + active_profile_file="${CODEX_AUTH_ACTIVE_PROFILE_FILE:-$codex_home/active-profile.json}" state_dir="$session_dir/state" proxy_log="$session_dir/proxy.log" proxy_state_log="$state_dir/claude-code-proxy/proxy.log" @@ -297,20 +333,43 @@ main() { || die "auth export did not return a stable account identity" initial_expires="$(metadata_field "$metadata" expires)" \ || die "auth export did not return the access-token expiry" + initial_profile_name="$profile_name" + initial_identity="$identity" # Opening Codex app-server against an isolated copy makes official Codex own # any refresh-token rotation. codex-auth safely merges the result back into # this one saved profile without switching the active profile name. - "$codex_auth" refresh --quiet --fast "$profile_name" >/dev/null - metadata="$("$codex_auth" claude-gpt-export "$profile_name" "$auth_file" "$identity")" \ - || die "could not renew the selected subscription profile" + if (( follow_active )); then + # A watch/auto switch can retire the original account while this refresh is + # starting. Its failure must not hide a newly valid active profile; the + # locked --active export below is the authoritative startup decision. + "$codex_auth" refresh --quiet --fast "$profile_name" >/dev/null 2>&1 || true + # The active profile may have changed while its predecessor refreshed. + # Resolve it again under codex-auth's mutation lock instead of putting the + # predecessor back into the session lease. + metadata="$("$codex_auth" claude-gpt-export --active "$auth_file")" \ + || die "could not renew the active subscription profile" + else + "$codex_auth" refresh --quiet --fast "$profile_name" >/dev/null + metadata="$("$codex_auth" claude-gpt-export "$profile_name" "$auth_file" "$identity")" \ + || die "could not renew the selected subscription profile" + fi + profile_name="$(metadata_field "$metadata" profile)" \ + || die "renewed auth export did not return a profile" + identity="$(metadata_field "$metadata" account_identity)" \ + || die "renewed auth export did not return a stable account identity" expires="$(metadata_field "$metadata" expires)" \ || die "renewed auth export did not return an expiry" + if (( follow_active )); then + active_state="$(metadata_marker_state "$metadata")" \ + || die "renewed auth export did not return active-profile metadata" + fi now="$(date +%s)" if (( expires / 1000 <= now + 90 )); then die "the selected access token is about to expire; run: codex-auth reauth $profile_name" fi - if (( initial_expires / 1000 <= now + 300 && expires <= initial_expires )); then + if [[ "$profile_name" == "$initial_profile_name" && "$identity" == "$initial_identity" ]] \ + && (( initial_expires / 1000 <= now + 300 && expires <= initial_expires )); then die "Codex could not refresh the selected access token; run: codex-auth reauth $profile_name" fi [[ "$(jq -r '.refresh' "$auth_file")" == "" ]] \ @@ -369,7 +428,10 @@ main() { fi renew_lease() { - local current_expires="$expires" next_metadata next_expires target sleep_for lease_now + local current_profile="$profile_name" current_identity="$identity" current_expires="$expires" + local current_active_state="$active_state" marker_state previous_profile previous_identity + local next_metadata next_profile next_identity next_expires next_active_state + local target sleep_for lease_now renew_margin_secs="${CLAUDE_GPT_RENEW_MARGIN_SECS:-290}" [[ "$renew_margin_secs" =~ ^[0-9]+$ && "$renew_margin_secs" -ge 1 ]] || renew_margin_secs=290 while [[ ! -e "$stop_file" ]] \ @@ -377,6 +439,42 @@ main() { && process_alive "$claude_pid" \ && process_alive "$proxy_pid" do + if (( follow_active )); then + marker_state="$(active_marker_state "$active_profile_file")" + if [[ "$marker_state" != "$current_active_state" ]] \ + && next_metadata="$("$codex_auth" claude-gpt-export --active "$active_lease_candidate" 2>/dev/null)" \ + && next_profile="$(metadata_field "$next_metadata" profile 2>/dev/null)" \ + && next_identity="$(metadata_field "$next_metadata" account_identity 2>/dev/null)" \ + && next_expires="$(metadata_field "$next_metadata" expires 2>/dev/null)" \ + && next_active_state="$(metadata_marker_state "$next_metadata" 2>/dev/null)" \ + && proxy_lease_file_is_safe "$active_lease_candidate" + then + lease_now="$(date +%s)" + if (( next_expires / 1000 > lease_now + 90 )) \ + && mv -f "$active_lease_candidate" "$auth_file" + then + previous_profile="$current_profile" + previous_identity="$current_identity" + current_profile="$next_profile" + current_identity="$next_identity" + current_expires="$next_expires" + current_active_state="$next_active_state" + if [[ "$current_profile" != "$previous_profile" || "$current_identity" != "$previous_identity" ]]; then + printf 'claude-gpt: active subscription profile changed: %s -> %s\n' \ + "$previous_profile" "$current_profile" >&2 + fi + else + rm -f "$active_lease_candidate" + # Keep the current, still-valid lease live while official Codex + # refreshes the newly selected profile. The next poll stages it + # again and only promotes a validated access-only replacement. + "$codex_auth" refresh --quiet --fast "$next_profile" >/dev/null 2>&1 || true + fi + else + rm -f "$active_lease_candidate" + fi + fi + lease_now="$(date +%s)" # Official Codex refreshes access tokens inside its final five minutes. # Enter that window with ten seconds of headroom, then retry briefly if a @@ -389,19 +487,53 @@ main() { continue fi - if "$codex_auth" refresh --quiet --fast "$profile_name" >/dev/null 2>&1 \ - && next_metadata="$("$codex_auth" claude-gpt-export "$profile_name" "$auth_file" "$identity" 2>/dev/null)" \ + next_metadata="" + if "$codex_auth" refresh --quiet --fast "$current_profile" >/dev/null 2>&1; then + if (( follow_active )); then + next_metadata="$("$codex_auth" claude-gpt-export --active "$active_lease_candidate" 2>/dev/null)" \ + || next_metadata="" + lease_now="$(date +%s)" + if [[ -z "$next_metadata" ]] \ + || ! next_expires="$(metadata_field "$next_metadata" expires 2>/dev/null)" \ + || (( next_expires / 1000 <= lease_now + 90 )) \ + || ! proxy_lease_file_is_safe "$active_lease_candidate" \ + || ! mv -f "$active_lease_candidate" "$auth_file" + then + rm -f "$active_lease_candidate" + next_metadata="" + fi + else + next_metadata="$("$codex_auth" claude-gpt-export "$current_profile" "$auth_file" "$current_identity" 2>/dev/null)" \ + || next_metadata="" + fi + fi + if [[ -n "$next_metadata" ]] \ + && next_profile="$(metadata_field "$next_metadata" profile 2>/dev/null)" \ + && next_identity="$(metadata_field "$next_metadata" account_identity 2>/dev/null)" \ && next_expires="$(metadata_field "$next_metadata" expires 2>/dev/null)" then - if (( next_expires > current_expires )); then + previous_profile="$current_profile" + previous_identity="$current_identity" + current_profile="$next_profile" + current_identity="$next_identity" + if (( follow_active )); then + current_active_state="$(metadata_marker_state "$next_metadata")" + fi + if [[ "$current_profile" != "$previous_profile" || "$current_identity" != "$previous_identity" ]] \ + || (( next_expires > current_expires )); then + if [[ "$current_profile" != "$previous_profile" || "$current_identity" != "$previous_identity" ]]; then + printf 'claude-gpt: active subscription profile changed: %s -> %s\n' \ + "$previous_profile" "$current_profile" >&2 + fi current_expires="$next_expires" continue fi + current_expires="$next_expires" fi lease_now="$(date +%s)" if (( lease_now >= current_expires / 1000 - 60 )); then - printf 'claude-gpt: subscription lease could not be renewed for profile %s\n' "$profile_name" >&2 + printf 'claude-gpt: subscription lease could not be renewed for profile %s\n' "$current_profile" >&2 process_alive "$claude_pid" && kill -TERM "$claude_pid" 2>/dev/null || true process_alive "$wrapper_pid" && kill -TERM "$wrapper_pid" 2>/dev/null || true return 1 diff --git a/install.sh b/install.sh index 7262741..b9169b1 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ codex_home="${CODEX_HOME:-$HOME/.codex}" standalone_root="$codex_home/packages/standalone" tui_source="$repo_dir/tui" tui_dir="$libdir/tui" -claude_gpt_proxy_version="0.1.10-codex-auth.1" +claude_gpt_proxy_version="0.1.10-codex-auth.2" mkdir -p "$bindir" "$libdir" if [[ ! -f "$tui_source/pyproject.toml" || ! -f "$tui_source/uv.lock" || ! -d "$tui_source/src/codex_auth_tui" ]]; then diff --git a/tests/run.sh b/tests/run.sh index 000a34b..39c0b93 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -174,20 +174,50 @@ case "${1:-}" in claude-gpt-export) destination="$3" expected_identity="${4:-}" - [[ -z "$expected_identity" || "$expected_identity" == "identity-pinned" ]] || exit 65 + requested_profile="$2" + if [[ "$requested_profile" == "--active" ]]; then + if [[ -n "${CODEX_TEST_ACTIVE_PROFILE_FILE:-}" ]]; then + selected_profile="$(jq -er '.profile | select(type == "string" and length > 0)' "$CODEX_TEST_ACTIVE_PROFILE_FILE")" + else + selected_profile=work + fi + else + selected_profile="$requested_profile" + fi + if [[ "$selected_profile" == "work" ]]; then + selected_identity=identity-pinned + else + selected_identity="identity-$selected_profile" + fi + [[ -z "$expected_identity" || "$expected_identity" == "$selected_identity" ]] || exit 65 mkdir -p "${destination%/*}" chmod 700 "${destination%/*}" + tmp="$(mktemp "${destination%/*}/.fake-claude-gpt-auth.XXXXXX")" jq -cn \ - --arg access "access-only-test-token" \ - '{access:$access,refresh:"",expires:4102444800000,accountId:"acct-work"}' \ - > "$destination" - chmod 600 "$destination" + --arg access "access-only-$selected_profile" \ + --arg account "acct-$selected_profile" \ + '{access:$access,refresh:"",expires:4102444800000,accountId:$account}' \ + > "$tmp" + chmod 600 "$tmp" + mv -f "$tmp" "$destination" expires=$(( $(date +%s) * 1000 + 3600000 )) - jq -cn --arg profile work --arg identity identity-pinned --argjson expires "$expires" \ - '{profile:$profile,account_identity:$identity,profile_revision:"revision",credential_fingerprint:"fingerprint",expires:$expires}' + jq -cn \ + --arg profile "$selected_profile" \ + --arg identity "$selected_identity" \ + --arg revision "revision-$selected_profile" \ + --arg fingerprint "fingerprint-$selected_profile" \ + --argjson expires "$expires" \ + '{profile:$profile,account_identity:$identity,profile_revision:$revision,credential_fingerprint:$fingerprint,expires:$expires}' ;; refresh) - [[ "${2:-}" == "--quiet" && "${3:-}" == "--fast" && "${4:-}" == "work" ]] || exit 66 + [[ "${2:-}" == "--quiet" && "${3:-}" == "--fast" && -n "${4:-}" ]] || exit 66 + if [[ "${CODEX_TEST_REFRESH_DELAY_SECS:-0}" != "0" ]]; then + sleep "$CODEX_TEST_REFRESH_DELAY_SECS" + fi + if [[ -n "${CODEX_TEST_REFRESH_FAIL_PROFILE:-}" \ + && "${4:-}" == "$CODEX_TEST_REFRESH_FAIL_PROFILE" ]]; then + exit 67 + fi ;; *) exit 64 @@ -204,7 +234,7 @@ write_fake_claude_gpt_proxy() { #!/usr/bin/env bash set -euo pipefail if [[ "${1:-}" == "--version" ]]; then - printf 'claude-code-proxy 0.1.10-codex-auth.1\n' + printf 'claude-code-proxy 0.1.10-codex-auth.2\n' exit 0 fi printf 'proxy-arg=<%s>\n' "$@" >> "$CODEX_TEST_LOG" @@ -235,11 +265,42 @@ if [[ "${CODEX_TEST_PROXY_EXIT_EARLY:-0}" == "1" ]]; then exit 42 fi trap 'exit 0' TERM INT HUP -while true; do sleep 1; done +last_lease="" +while true; do + if [[ "${CODEX_TEST_PROXY_WATCH_AUTH:-0}" == "1" ]]; then + lease="$(jq -er '[.accountId, .access] | join(":")' "$CCP_CONFIG_DIR/codex/auth.json" 2>/dev/null || true)" + if [[ -n "$lease" && "$lease" != "$last_lease" ]]; then + printf 'proxy-lease=<%s>\n' "$lease" >> "$CODEX_TEST_LOG" + last_lease="$lease" + fi + sleep 0.05 + else + sleep 1 + fi +done EOF chmod 0755 "$path" } +write_fake_active_profile_marker() { + local path="$1" + local profile="$2" + local parent tmp + parent="${path%/*}" + mkdir -p "$parent" + tmp="$(mktemp "$parent/.active-profile.XXXXXX")" + local identity="identity-$profile" + [[ "$profile" == "work" ]] && identity=identity-pinned + jq -cn \ + --arg profile "$profile" \ + --arg identity "$identity" \ + --arg revision "revision-$profile" \ + --arg fingerprint "fingerprint-$profile" \ + '{version:2,profile:$profile,kind:"chatgpt",account_identity:$identity, + profile_revision:$revision,profile_fingerprint:$fingerprint}' > "$tmp" + mv -f "$tmp" "$path" +} + write_fake_claude_gpt_curl() { local path="$1" mkdir -p "$(dirname "$path")" @@ -1000,7 +1061,7 @@ test_install_fetches_verified_claude_gpt_proxy() { mkdir -p "$payload" printf '%s\n' \ '#!/usr/bin/env bash' \ - 'printf "claude-code-proxy 0.1.10-codex-auth.1\\n"' > "$payload/claude-code-proxy" + 'printf "claude-code-proxy 0.1.10-codex-auth.2\\n"' > "$payload/claude-code-proxy" chmod 0755 "$payload/claude-code-proxy" tar -czf "$archive" -C "$payload" claude-code-proxy (cd "$tmp" && sha256sum "${archive##*/}" > "${checksum##*/}") @@ -1012,7 +1073,7 @@ test_install_fetches_verified_claude_gpt_proxy() { "$REPO_ROOT/install.sh" >/dev/null [[ -x "$prefix/bin/claude-code-proxy" ]] || fail "verified Claude GPT proxy was not installed" - [[ "$("$prefix/bin/claude-code-proxy" --version)" == "claude-code-proxy 0.1.10-codex-auth.1" ]] \ + [[ "$("$prefix/bin/claude-code-proxy" --version)" == "claude-code-proxy 0.1.10-codex-auth.2" ]] \ || fail "installed Claude GPT proxy reported the wrong version" } @@ -2692,6 +2753,148 @@ test_claude_gpt_launcher_renews_while_harness_runs() { assert_contains 'auth-arg=' "$log" } +test_claude_gpt_default_follows_active_profile_during_session() { + local tmp home log marker proxy_pid_file wrapper_pid attempt + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + marker="$tmp/custom-active-profile.json" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_active_profile_marker "$marker" work + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_TEST_PROXY_WATCH_AUTH=1 \ + CODEX_TEST_ACTIVE_PROFILE_FILE="$marker" \ + CODEX_AUTH_ACTIVE_PROFILE_FILE="$marker" \ + CODEX_TEST_CLAUDE_SLEEP=3 \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18895 \ + "$REPO_ROOT/bin/claude-gpt" -- -p follow-active \ + >"$tmp/out" 2>"$tmp/err" & + wrapper_pid=$! + + for (( attempt=0; attempt<100; attempt++ )); do + grep -Fq 'proxy-lease=' "$log" 2>/dev/null && break + kill -0 "$wrapper_pid" 2>/dev/null || break + sleep 0.05 + done + assert_contains 'proxy-lease=' "$log" + + write_fake_active_profile_marker "$marker" personal + for (( attempt=0; attempt<100; attempt++ )); do + grep -Fq 'proxy-lease=' "$log" 2>/dev/null && break + kill -0 "$wrapper_pid" 2>/dev/null || break + sleep 0.05 + done + wait "$wrapper_pid" + + assert_contains 'proxy-lease=' "$log" + assert_contains 'active subscription profile changed: work -> personal' "$tmp/err" + assert_contains 'auth-arg=<--active>' "$log" +} + +test_claude_gpt_default_recovers_switch_during_failed_initial_refresh() { + local tmp home log marker proxy_pid_file wrapper_pid attempt + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + marker="$tmp/custom-active-profile.json" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_active_profile_marker "$marker" work + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_TEST_PROXY_WATCH_AUTH=1 \ + CODEX_TEST_ACTIVE_PROFILE_FILE="$marker" \ + CODEX_AUTH_ACTIVE_PROFILE_FILE="$marker" \ + CODEX_TEST_REFRESH_DELAY_SECS=0.5 \ + CODEX_TEST_REFRESH_FAIL_PROFILE=work \ + CODEX_TEST_CLAUDE_SLEEP=1 \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18897 \ + "$REPO_ROOT/bin/claude-gpt" -- -p recover-initial-switch \ + >"$tmp/out" 2>"$tmp/err" & + wrapper_pid=$! + + for (( attempt=0; attempt<100; attempt++ )); do + grep -Fq 'auth-arg=' "$log" 2>/dev/null && break + kill -0 "$wrapper_pid" 2>/dev/null || break + sleep 0.01 + done + assert_contains 'auth-arg=' "$log" + write_fake_active_profile_marker "$marker" personal + wait "$wrapper_pid" + + assert_contains 'proxy-lease=' "$log" + assert_not_contains 'proxy-lease=' "$log" + assert_contains 'auth-arg=<--active>' "$log" +} + +test_claude_gpt_explicit_profile_stays_pinned_during_active_switch() { + local tmp home log marker proxy_pid_file wrapper_pid attempt pinned_exports + tmp="$(mktemp -d)" + home="$tmp/home" + log="$tmp/calls.log" + marker="$home/active-profile.json" + proxy_pid_file="$tmp/proxy.pid" + mkdir -p "$home" + write_fake_active_profile_marker "$marker" work + write_fake_claude_gpt_auth "$tmp/bin/codex-auth" + write_fake_claude_gpt_proxy "$tmp/bin/claude-code-proxy" + write_fake_claude_gpt_curl "$tmp/bin/curl" + write_fake_claude_harness "$tmp/bin/claude" + + CODEX_TEST_LOG="$log" \ + CODEX_TEST_PROXY_PID="$proxy_pid_file" \ + CODEX_TEST_PROXY_WATCH_AUTH=1 \ + CODEX_TEST_ACTIVE_PROFILE_FILE="$marker" \ + CODEX_TEST_CLAUDE_SLEEP=3 \ + CODEX_HOME="$home" \ + CODEX_AUTH_BIN="$tmp/bin/codex-auth" \ + CLAUDE_GPT_PROXY_BIN="$tmp/bin/claude-code-proxy" \ + CLAUDE_GPT_CURL_BIN="$tmp/bin/curl" \ + CLAUDE_GPT_CLAUDE_BIN="$tmp/bin/claude" \ + CLAUDE_GPT_PORT=18896 \ + CLAUDE_GPT_RENEW_MARGIN_SECS=3599 \ + "$REPO_ROOT/bin/claude-gpt" --profile work -- -p stay-pinned \ + >"$tmp/out" 2>"$tmp/err" & + wrapper_pid=$! + + for (( attempt=0; attempt<100; attempt++ )); do + grep -Fq 'proxy-lease=' "$log" 2>/dev/null && break + kill -0 "$wrapper_pid" 2>/dev/null || break + sleep 0.05 + done + assert_contains 'proxy-lease=' "$log" + + write_fake_active_profile_marker "$marker" personal + wait "$wrapper_pid" + + assert_not_contains 'proxy-lease=' "$log" + assert_not_contains 'auth-arg=<--active>' "$log" + pinned_exports="$(grep -Fc 'auth-arg=' "$log")" + (( pinned_exports >= 2 )) || fail "explicit Claude GPT profile was not renewed with its pinned identity" +} + test_claude_gpt_launcher_fails_closed_when_proxy_exits() { local tmp home log proxy_pid_file claude_pid_file status tmp="$(mktemp -d)" @@ -2864,6 +3067,9 @@ main() { test_claude_gpt_custom_option_can_be_dropped \ test_claude_gpt_launcher_reads_kernel_selected_port \ test_claude_gpt_launcher_renews_while_harness_runs \ + test_claude_gpt_default_follows_active_profile_during_session \ + test_claude_gpt_default_recovers_switch_during_failed_initial_refresh \ + test_claude_gpt_explicit_profile_stays_pinned_during_active_switch \ test_claude_gpt_launcher_fails_closed_when_proxy_exits \ test_claude_gpt_children_die_with_killed_wrapper do From 9c5b334118abff8866de5474e00da44ed12c0a9c Mon Sep 17 00:00:00 2001 From: editnori Date: Sat, 11 Jul 2026 07:07:38 -0500 Subject: [PATCH 5/5] Address launcher portability review --- README.md | 8 +++++--- bin/claude-gpt | 3 +++ lib/codex-auth/usage.sh | 14 +++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ba5db07..d8cc070 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ All captures use the real Textual app with an in-memory synthetic backend. ## Requirements - Linux or WSL with Bash, `jq`, `flock`, Git, and the official Codex CLI. + `claude-gpt` specifically requires Bash 5.1 or newer for supervised child waiting. - [`uv`](https://docs.astral.sh/uv/) for the isolated Textual environment. - `crontab` is optional; without it, run `codex-auth maintain` after a direct curl update. - Building the patched Codex generation also needs a Rust/Cargo toolchain and normal native build dependencies. @@ -388,9 +389,10 @@ crontab -l 2>/dev/null \ | awk '$0 == "# BEGIN codex-auth maintain" {skip=1; next} $0 == "# END codex-auth maintain" {skip=0; next} !skip' \ | crontab - curl -fsSL https://chatgpt.com/codex/install.sh | sh -rm -f ~/.local/bin/codex-auth ~/.local/bin/codex-auth-tui ~/.local/bin/codex-real \ - ~/.local/bin/claude-gpt ~/.local/bin/claude-code-proxy -rm -rf ~/.local/lib/codex-auth +prefix="${PREFIX:-$HOME/.local}" +rm -f "$prefix/bin/codex-auth" "$prefix/bin/codex-auth-tui" "$prefix/bin/codex-real" \ + "$prefix/bin/claude-gpt" "$prefix/bin/claude-code-proxy" +rm -rf "$prefix/lib/codex-auth" ``` This intentionally leaves `$CODEX_HOME/auth-profiles` and other account state in place. diff --git a/bin/claude-gpt b/bin/claude-gpt index 0558edf..4bdaef0 100755 --- a/bin/claude-gpt +++ b/bin/claude-gpt @@ -165,6 +165,9 @@ signal_process_tree() { } main() { + if (( BASH_VERSINFO[0] < 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] < 1) )); then + die "Bash 5.1 or newer is required (the launcher uses wait -n -p)" + fi local profile="--active" local follow_active=1 local model="${CLAUDE_GPT_MODEL:-$CLAUDE_GPT_DEFAULT_MODEL}" diff --git a/lib/codex-auth/usage.sh b/lib/codex-auth/usage.sh index c054899..98ec13b 100644 --- a/lib/codex-auth/usage.sh +++ b/lib/codex-auth/usage.sh @@ -478,7 +478,7 @@ usage_json_from_home() { rate_pid="$CODEX_RATE_PID" start="$(now_epoch)" - if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.2.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.2.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 1>&"$rate_in" 2>/dev/null; then usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" printf '%s\n' '{"error":{"message":"refresh unavailable"}}' return 0 @@ -492,8 +492,8 @@ usage_json_from_home() { if IFS= read -r -t 0.25 line 2>/dev/null <&"$rate_out"; then line_id="$(jq -r '.id // empty' <<<"$line" 2>/dev/null || true)" if [[ "$line_id" == "1" && "$requested" == "0" ]]; then - printf '%s\n' '{"method":"initialized"}' 2>/dev/null >&"$rate_in" || true - printf '%s\n' '{"id":2,"method":"account/rateLimits/read"}' 2>/dev/null >&"$rate_in" || true + printf '%s\n' '{"method":"initialized"}' 1>&"$rate_in" 2>/dev/null || true + printf '%s\n' '{"id":2,"method":"account/rateLimits/read"}' 1>&"$rate_in" 2>/dev/null || true requested=1 elif [[ "$line_id" == "2" ]]; then payload="$(jq -c 'if has("result") then .result else {error:.error} end' <<<"$line" 2>/dev/null || true)" @@ -557,7 +557,7 @@ reset_credit_json_from_home() { rate_pid="$CODEX_RESET_PID" start="$(now_epoch)" - if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.2.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 2>/dev/null >&"$rate_in"; then + if ! printf '%s\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-auth","title":"Codex Auth","version":"0.2.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}' 1>&"$rate_in" 2>/dev/null; then usage_json_cleanup_coproc "$rate_in" "$rate_out" "$rate_pid" printf '%s\n' '{"error":{"message":"reset unavailable"}}' return 0 @@ -575,16 +575,16 @@ reset_credit_json_from_home() { if IFS= read -r -t 0.25 line 2>/dev/null <&"$rate_out"; then line_id="$(jq -r '.id // empty' <<<"$line" 2>/dev/null || true)" if [[ "$line_id" == "1" && "$requested" == "0" ]]; then - printf '%s\n' '{"method":"initialized"}' 2>/dev/null >&"$rate_in" || true + printf '%s\n' '{"method":"initialized"}' 1>&"$rate_in" 2>/dev/null || true jq -cn --arg key "$idempotency_key" \ '{id:2,method:"account/rateLimitResetCredit/consume",params:{idempotencyKey:$key}}' \ - 2>/dev/null >&"$rate_in" || true + 1>&"$rate_in" 2>/dev/null || true requested=1 elif [[ "$line_id" == "2" ]]; then outcome="$(jq -r '.result.outcome // empty' <<<"$line" 2>/dev/null || true)" if [[ -n "$outcome" ]]; then consume_received=1 - printf '%s\n' '{"id":3,"method":"account/rateLimits/read"}' 2>/dev/null >&"$rate_in" || true + printf '%s\n' '{"id":3,"method":"account/rateLimits/read"}' 1>&"$rate_in" 2>/dev/null || true else payload="$(jq -c 'if has("error") then {error:.error} else {error:{message:"invalid reset response"}} end' <<<"$line" 2>/dev/null || true)" [[ -n "$payload" ]] || payload='{"error":{"message":"invalid reset response"}}'