21 Commits

19 changed files with 17971 additions and 2846 deletions

83
AGENTS.md Normal file
View File

@@ -0,0 +1,83 @@
# Project: Codex Launcher — Any AI Provider
## Overview
OpenAI Codex CLI & Desktop launcher that proxies to **any** AI provider.
Python-only (stdlib), zero pip dependencies. Supports Responses API, Chat Completions,
Anthropic Messages API, Command Code, and more via a translation proxy.
Maintained by:
- **roman-ryzenadvanced** — original Linux/GTK development
- **cobra91** — Windows port (tkinter GUI, MSIX support)
## Architecture
```
codex-launcher-gui.py (tkinter on Windows / GTK on Linux)
→ codex_launcher_lib.py (shared library: endpoints, config, process mgmt)
→ translate-proxy.py (HTTP proxy: Responses API → backend API)
→ upstream provider (OpenAI, Anthropic, DeepSeek, Antigravity, etc.)
```
### Key Files
| File | Purpose |
|------|---------|
| `src/codex-launcher-gui.py` | Windows tkinter GUI |
| `src/codex_launcher_lib.py` | Shared library (endpoints, config, process management) |
| `src/translate-proxy.py` | Translation proxy (core routing, adapters, streaming) |
| `src/antigravity_grpc/` | gRPC client for Antigravity provider |
### Backend Types
| Type | Wire Protocol | Example |
|------|--------------|---------|
| `openai-compat` | Chat Completions | DeepSeek, OpenRouter, Crof.ai |
| `anthropic` | Anthropic Messages | Anthropic direct, OpenCode Zen |
| `command-code` | Command Code /alpha/generate | CommandCode API |
| `gemini-oauth-*` | Google OAuth | Google Antigravity |
## Platform Compatibility
**MUST work on both Linux and Windows.** No exceptions.
### Platform-Specific Patterns
- **Process management**: `os.setsid()` + `os.killpg()` on Linux, `CREATE_NEW_PROCESS_GROUP` on Windows
- **Process listing**: `pgrep` on Linux, `tasklist` / `wmic` on Windows
- **Desktop launch**: exe path on Linux, `shell:AppsFolder\{AUMID}` for MSIX on Windows
- **Signals**: `signal.SIGTERM` on Linux, `taskkill /F` on Windows
- **Paths**: `~/.local/bin/` on Linux, `%LOCALAPPDATA%\Programs\Codex-Launcher\` on Windows
- **Config**: `~/.codex/config.toml` (same format on both)
- **POSIX-only APIs**: `os.getpgid()`, `/proc/{pid}/stat`, `os.setsid()` — always guard with `sys.platform` checks
### Testing Cross-Platform
- Never assume Unix-only APIs exist (`pgrep`, `getpgid`, `SIGTERM`)
- Use `sys.platform == "win32"` for Windows branches
- Test proxy startup on both platforms before committing
- Provider presets (PROVIDER_PRESETS) work identically on both
## Coding Conventions
- Python 3.8+ stdlib only, zero pip dependencies
- `snake_case` for functions/variables, `UPPER_CASE` for globals
- Immutable patterns: create new dicts/objects, don't mutate in-place
- Error handling: catch at boundaries, never silently swallow errors
- Thread-safe: use `threading.Lock` for shared state, `threading.Semaphore` for concurrency
## Common Pitfalls
- **MSIX exe paths**: `C:\Program Files\WindowsApps\` exes cannot be launched via `subprocess.Popen` — use `shell:AppsFolder` protocol
- **File locking on Windows**: Python can't overwrite files open in another process
- **Path separators**: always use `os.path.join()` or `Path` objects, never hardcoded `/`
- **Signal handling**: Windows doesn't support `SIGUSR1`/`SIGUSR2` — use events or named pipes
## Testing
- **Run before every commit**: `python -m pytest tests/ -v`
- **All tests must pass** before pushing a PR
- Test files live in `tests/` directory
- Tests use `pytest` (not unittest runner)
- Platform-specific tests must skip gracefully on other OS: `pytest.mark.skipif(sys.platform != "linux", reason="Linux-only")`
- Never mock filesystem paths with hardcoded separators — use `os.path.join` or `tmp_path`

View File

@@ -1,5 +1,83 @@
# Changelog
## v10.13.6 (2026-05-27)
**Anti-Loop Resilience, Auto Token Refresh, Budget Cap, MSIX Support**
### New Features
- **Cross-session loop tracker**: Keys by user request hash — detects loops even when client creates new sessions per retry. Resets counter on new tasks.
- **Tool-call budget**: 150 calls max per task, warning at 80. Injects directive to stop reading and write, instead of killing the session.
- **File-path read-loop detection**: Same file read 5+ times or 30+ total file reads triggers force-finalize
- **Auto 401 token refresh**: On 401 transient, force-refreshes Google OAuth token and retries once (both v2 + OA compat handlers)
- **Model-aware idle timeout**: Flash/mini/haiku models get 120s timeout instead of 300s
- **Smart compaction summary**: Directive text when read-loop detected in compacted history
- **`_send_ag_finalize()` helper**: Returns synthetic response for hard terminations
- **Default provider policy**: Unrecognized providers get balanced compaction (128K context, 60 items)
- **Anti-stall self-kill fix**: No longer kills own parent process or process group
- **Codex Desktop Updater**: Check/install/rollback/service management + manual rebuild from source
- **E2E test suite**: `bash test-antigravity.sh --task` for real CLI task testing
### Bug Fixes
- Fix `task_retry_count` counting every turn instead of same-task retries (spam bug)
- Fix tool-call budget killing session instead of injecting directive
- Fix `_schema` NameError in smart-continue nudge (cobra91 PR #17)
- Fix `_anti_stall_cleanup()` killing own parent/shell wrapper process
- Fix OA compat path loop tracker indentation
- Fix Codex CLI 0.134.0 profile system: separate `~/.codex/<slug>.config.toml` files
- Fix compaction causing model loops: `max_input_items: 60→200` for 1M-token models
- Merge cobra91 PR #17: MSIX Desktop launch, button state, `_schema` fix
## v3.13.5 (2026-05-27)
**Anti-Loop & Flash Model Resilience, Auto Token Refresh**
### New Features
- **Cross-session loop tracker**: Keys by user request hash — detects loops even when client creates new sessions per retry. Resets counter on new tasks.
- **Tool-call budget**: 150 calls max per task, warning at 80. Injects directive to stop reading and write, instead of killing the session.
- **File-path read-loop detection**: Same file read 5+ times or 30+ total file reads triggers force-finalize
- **Smart compaction summary**: Directive text when read-loop detected in compacted history
- **Model-aware idle timeout**: Flash/mini/haiku models get 120s timeout instead of 300s
- **Auto 401 token refresh**: On 401 transient, force-refreshes Google OAuth token and retries once
- **`_send_ag_finalize()` helper**: Returns synthetic response for hard terminations
- **Default provider policy**: Unrecognized providers get balanced compaction (128K context, 60 items)
- **Anti-stall self-kill fix**: No longer kills own parent process or process group
- **E2E test suite with real CLI task**: `test-antigravity.sh --task`
### Bug Fixes
- Fix `_schema` NameError in smart-continue nudge (cobra91 PR #17)
- Fix `_anti_stall_cleanup()` killing own parent/shell wrapper
- Fix task_retry_count counting every turn instead of same-task retries
- Fix tool-call budget cap killing session instead of injecting directive
- Merged cobra91 PR #17: MSIX Desktop launch, button state
## v3.13.0 (2026-05-27)
**Codex Desktop Updater, Antigravity E2E, Profile System Fix**
### New Features
- **Codex Desktop Updater**: `CodexUpdaterWindow` class — check updates, install, rollback, service management, manual rebuild from source (`ilysenko/codex-desktop-linux`)
- **Antigravity E2E test suite**: `~/.local/bin/test-antigravity.sh` — validates token, REST endpoints, proxy adapter, model resolution
- **Antigravity prod endpoint working**: `cloudcode-pa.googleapis.com` returns 200 with real responses for `gemini-3-flash`
### Bug Fixes
- **Fix Antigravity endpoint order**: prod (`cloudcode-pa.googleapis.com`) first, then daily-sandbox, then autopush-sandbox
- **Fix Antigravity model resolution**: `gemini-3.5-flash-high``gemini-3-flash` via `_model_alias` map
- **Fix OAUTH_PROVIDER derivation**: auto-derived from `BACKEND` env var when running without `--config`
- **Fix `service_disabled` bail**: only returns error from prod endpoint, skips sandbox endpoints
- **Fix compaction causing model loops**: `max_input_items: 60→200` (prod), `80→250` (sandbox); `tool_output_limit: 6000→8000`; `compaction: "aggressive"→"conservative"` — model was "forgetting" earlier reads due to aggressive compaction
- **Fix Codex CLI 0.134.0 profile system**: profiles now written to separate `~/.codex/<slug>.config.toml` files instead of `[profiles.*]` sections in main config
- **Fix updater false success**: checks for "successfully"/"No update ready" in output text, not return code
## v3.12.1 (2026-05-27)
**Fix Antigravity Adapter (PR #15)**
### Bug Fixes
- Simplified model resolution, removed broken `_sanitize_gemini_schema()`
- Restored correct headers
- Expanded model alias map for all Antigravity variants
- Re-enabled gRPC fallback by default
## v3.12.0 (2026-05-27)
**gRPC Auto-Fallback for Antigravity Provider (PR #13)**

32
CLAUDE.md Normal file
View File

@@ -0,0 +1,32 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Codex Launcher — Any AI Provider. Run OpenAI Codex CLI & Desktop with any AI provider.
## Pre-Commit Checklist
- [ ] Run unit tests: `python -m pytest tests/ -v` (all must pass)
- [ ] Verify cross-platform: no `os.getpgid`, `/proc/`, `pgrep`, `SIGUSR*` without `sys.platform` guard
- [ ] Check syntax: `python -c "import py_compile; py_compile.compile('src/translate-proxy.py', doraise=True)"`
- [ ] No hardcoded Unix paths or Windows-only APIs without platform checks
- [ ] No secrets or API keys in source code
## Development Commands
```bash
# Run tests
python -m pytest tests/ -v
# Syntax check
python -c "import py_compile; py_compile.compile('src/translate-proxy.py', doraise=True)"
# Run proxy locally
python src/translate-proxy.py --port 8080
```
## Agent Guidelines
See @AGENTS.md for architecture details, platform compatibility rules, and coding conventions.

View File

@@ -138,6 +138,12 @@ A three-component system:
- **has_content function_call fix** (v3.11.6) — tool-call-only responses now correctly flagged as having content, preventing infinite loops on OpenAdapter/Z.AI/OpenRouter providers
- **Vision/OCR preprocessing** (v3.11.6) — when provider rejects images, automatically calls a configurable vision fallback API (Kilo.ai) to describe images as text for text-only models; MD5-cached; retries on vision errors with preprocessed text
- **Auth config-missing fix** (v3.11.6) — graceful handling when Codex config.toml is missing instead of showing raw os error
- **Codex Desktop Updater** (v10.13.6) — built-in updater window with Check/Install/Rollback buttons, service management, and manual rebuild from source (`ilysenko/codex-desktop-linux`)
- **Codex CLI 0.134.0 profile system** (v10.13.6) — profiles written to separate `~/.codex/<slug>.config.toml` files for compatibility with Codex CLI 0.134.0+
- **Anti-loop resilience** (v10.13.6) — cross-session loop tracker keyed by user request hash, tool-call budget (150 calls), file read-loop detection, auto 401 token refresh
- **Conservative compaction for large models** (v10.13.6) — `max_input_items: 200` for Antigravity's 1M-token models; prevents model from "forgetting" earlier file reads
- **Antigravity E2E test suite** (v10.13.6) — `bash test-antigravity.sh [--task]` validates token, REST endpoints, proxy adapter, model resolution; `--task` runs real CLI task with anomaly detection
- **MSIX Desktop support** (v10.13.6) — Windows Store install detection, `shell:AppsFolder` launch, tasklist-based process monitoring (cobra91 PR #17)
- Zero dependencies — pure Python stdlib
### Command Code Adapter

View File

@@ -20,12 +20,81 @@ BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
LOG_DIR = HOME / ".cache/codex-desktop"
LAUNCH_LOG = LOG_DIR / "launcher.log"
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
ACTIVE_ENDPOINT_FILE = HOME / ".codex/.active-endpoint.json"
DEFAULT_CONFIG = """model = ""
model_provider = ""
model_catalog_json = ""
"""
CHANGELOG = [
("10.13.8", "2026-05-27", [
"Fix: force_finalize skips Gemini call entirely (was hallucinating tool calls without tools)",
"Fix: _send_ag_finalize returns status=failed (was stored as valid history causing loops)",
"Fix: _forward_gemini_sse wrapped in try/except for TimeoutError/BrokenPipe",
"Fix: file tracker mutations inside lock scope (was racing in ThreadingHTTPServer)",
"Fix: compaction summary strips raw tool outputs (was re-triggering read loops)",
"Fix: post-compaction write directive when 10+ reads with 0 writes",
"Fix: detect get_goal/completion_budget null-tool loops (3+ → force finalize)",
"Fix: read-loop threshold raised to 8 same-file / 40 total (was too aggressive at 5/30)",
"Fix: strip timestamps from loop hash, base64 image data from normalizer",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplified model resolution",
"Removed broken schema sanitization, restored headers",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity (PR #13)",
"Dynamic version fetch with probe validation",
"Antigravity v2 handler rewrite (anti-api)",
]),
("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs (PR #11)",
"Gemini sanitizer: trim non-user turns for Google API compliance",
]),
("3.11.9", "2026-05-26", [
"Fix Antigravity: preserve functionCall/functionResponse (PR #10)",
"Prevents tool responses from being dropped in multi-turn sessions",
]),
("3.11.8", "2026-05-26", [
"Vision cache persisted across requests (PR #8 merge)",
"No redundant vision API calls for same image URL",
]),
("3.11.7", "2026-05-26", [
"Vision auto-detect: uses provider's vision model for image description",
"Vision preprocessing replaces image stripping",
"Fix AttributeError in image_url string handling",
"Merge PR #6: vision/OCR preprocessing, PR #7: 177 unit tests",
"Auth os error 2 fix: proper config-missing message in GUI",
]),
("3.11.6", "2026-05-26", [
"Antigravity loop breakers: per-session tracking, repeated tool detection",
"has_content fix: function_call counts as valid output",
"Latest user instruction appended once per request for Antigravity",
"Antigravity-only changes, no touch to other providers",
]),
("3.11.5", "2026-05-26", [
"Token-aware compaction: fixes context_length_exceeded on small-context models",
"Proactive compaction triggers on token count, not just item count",
"Universal adaptive compaction for all providers (removed crof.ai gates)",
"Vision model detection + image stripping for non-vision models",
"Per-model token limit learning from error messages",
"Smart-continue text-tool detection for text-only models",
"Active endpoint sync: auto-removes stale references on startup",
]),
("3.11.0", "2026-05-26", [
"Merge cobra PR: concurrency semaphore (max 3), auto-continue for truncated text",
"SO_REUSEADDR on sticky port, proxy-stderr.log, stream diagnostics logging",
"Timeout/OSError handler sends response.failed SSE instead of silent drop",
"Restart Proxy button: only restarts proxy without killing Codex Desktop",
"Tool call argument normalizer: fixes Arguments→arguments, strips markdown wrapping",
"Smart-continue loop (2× retries): escalating nudges when model stops text-only mid-task",
"XML tool call extraction: parses <name> patterns from text, injects as real calls",
"Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
"API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
"GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
"Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
]),
("3.10.4", "2026-05-25", [
"OAuth Secrets editor in GUI — update client ID/secret without editing files",
"Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
@@ -425,6 +494,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type):
return {
"openai-compat": "OpenAI-compatible",
@@ -910,6 +982,27 @@ def restore_config():
shutil.copy2(str(CONFIG_BAK), str(tmp))
os.replace(str(tmp), str(CONFIG))
def set_active_endpoint(name):
ACTIVE_ENDPOINT_FILE.parent.mkdir(parents=True, exist_ok=True)
write_secure_text(ACTIVE_ENDPOINT_FILE, json.dumps({"active": name}, indent=2))
def validate_active_endpoint(logfn=None):
if not ACTIVE_ENDPOINT_FILE.exists():
return
try:
d = json.loads(ACTIVE_ENDPOINT_FILE.read_text())
active = d.get("active", "")
if not active:
return
eps = load_endpoints()
names = {ep.get("name", "") for ep in eps}
if active not in names:
ACTIVE_ENDPOINT_FILE.unlink()
if logfn:
logfn(f"Removed stale active-endpoint '{active}' (provider no longer exists)")
except Exception:
pass
def write_secure_text(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
@@ -953,23 +1046,29 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [
main_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n',
]
write_secure_text(CONFIG, "".join(lines))
write_secure_text(profile_path, "".join(profile_lines))
def _toml_safe(val):
val = str(val).replace('"', '\\"')
@@ -988,12 +1087,12 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [
main_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
@@ -1002,15 +1101,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'request_max_retries = 1\n',
f'stream_max_retries = 0\n',
f'stream_idle_timeout_ms = 600000\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n',
]
write_secure_text(CONFIG, "".join(lines))
write_secure_text(profile_path, "".join(profile_lines))
def _gen_model_catalog(endpoint, selected_model=None):
default_model = selected_model or endpoint.get("default_model")
@@ -1253,6 +1356,9 @@ def _check_codex_auth():
if out.returncode == 0 and text:
return ("logged_in", text)
if text:
_tl = text.lower()
if "no such file" in _tl or "os error 2" in _tl or "not found" in _tl:
return ("not_configured", "Config missing — launch once to create")
return ("error", text)
return ("unknown", "No output from codex login status")
except FileNotFoundError:
@@ -1849,6 +1955,7 @@ class LauncherWin(Gtk.Window):
self._proc = None
self._endpoints_data = load_endpoints()
recover_config_if_needed()
validate_active_endpoint()
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox)
@@ -1856,7 +1963,7 @@ class LauncherWin(Gtk.Window):
# header row
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label(label="<b>Codex Launcher v3.10.9</b>")
lbl = Gtk.Label(label=f"<b>Codex Launcher v{CHANGELOG[0][0]}</b>")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
changelog_btn = Gtk.Button(label="Changelog")
@@ -1883,6 +1990,9 @@ class LauncherWin(Gtk.Window):
oauth_btn = Gtk.Button(label="OAuth Secrets")
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
hdr.pack_end(oauth_btn, False, False, 0)
updater_btn = Gtk.Button(label="Update Desktop")
updater_btn.connect("clicked", lambda b: self._open_updater())
hdr.pack_end(updater_btn, False, False, 0)
# verification status bar
self._cli_info = _detect_codex_cli()
@@ -2095,6 +2205,8 @@ class LauncherWin(Gtk.Window):
self._relogin_btn.set_sensitive("cli" not in self._missing)
elif status == "not_installed":
self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>")
elif status == "not_configured":
self._auth_label.set_markup("<span foreground='#d29922'>⚠ Config missing — launch once to create</span>")
else:
self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
self._relogin_btn.set_sensitive("cli" not in self._missing)
@@ -2331,6 +2443,18 @@ class LauncherWin(Gtk.Window):
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
subprocess.Popen([sys.executable, _py], start_new_session=True)
def _open_updater(self):
try:
if not UPDATER_BIN and not _detect_codex_desktop():
self.log("Codex Desktop not installed. Nothing to update.")
return
self._updater_window = CodexUpdaterWindow()
self._updater_window.connect("destroy", lambda *_: setattr(self, "_updater_window", None))
except Exception as e:
import traceback; traceback.print_exc()
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
d.run(); d.destroy()
def _backup_profile(self):
chooser = Gtk.FileChooserDialog(
title="Backup Codex Profile",
@@ -2594,6 +2718,8 @@ class LauncherWin(Gtk.Window):
begin_config_transaction(f"launch:{ep['name']}")
write_config_for_native(ep, model)
set_active_endpoint(ep["name"])
if target == "desktop":
if needs_proxy:
_kill_existing_desktop(self.log)
@@ -2651,6 +2777,7 @@ class LauncherWin(Gtk.Window):
begin_config_transaction(f"launch:bgp:{pool['name']}")
write_config_for_translated(bgp_ep, model, port)
set_active_endpoint(pool["name"])
if target == "desktop":
_kill_existing_desktop(self.log)
@@ -2771,7 +2898,7 @@ class LauncherWin(Gtk.Window):
cmd_parts.extend(["codex", "-c", f"model={model}",
"-s", sandbox, "-a", approval])
else:
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}",
cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}",
"-s", sandbox, "-a", approval])
self.log(f"Running: {' '.join(cmd_parts)}")
@@ -4398,10 +4525,54 @@ class EditEndpointDialog(Gtk.Dialog):
data["default"] = name
save_endpoints(data)
self._hot_reload_proxy_key(new_ep)
self._parent_mgr._rebuild()
self._parent_mgr._parent._on_endpoints_updated()
self.destroy()
def _hot_reload_proxy_key(self, ep):
try:
ep_name = ep.get("name", "")
proxy_port = None
import glob as _glob
for cfg_file in _glob.glob(str(PROXY_CONFIG_DIR / "proxy-*.json")):
try:
with open(cfg_file) as f:
pcfg = json.load(f)
if ep_name.lower().replace(" ", "-") in cfg_file.lower():
proxy_port = pcfg.get("port")
pcfg["api_key"] = ep.get("api_key", "")
with open(cfg_file, "w") as f:
json.dump(pcfg, f, indent=2)
break
except Exception:
continue
if proxy_port:
import urllib.request as _ur
try:
url = f"http://127.0.0.1:{proxy_port}/admin/reload"
resp = _ur.urlopen(url, timeout=3)
result = json.loads(resp.read())
reloaded = result.get("reloaded", False)
preview = result.get("api_key_preview", "?")
self._parent_mgr._parent.log(
f"[hot-reload] key {'updated' if reloaded else 'unchanged'}: {preview}")
if reloaded:
verify_url = f"http://127.0.0.1:{proxy_port}/admin/verify-key"
vresp = _ur.urlopen(verify_url, timeout=10)
vresult = json.loads(vresp.read())
valid = vresult.get("valid", False)
if valid:
self._parent_mgr._parent.log(
f"[hot-reload] key verified OK ({vresult.get('models', '?')} models)")
else:
self._parent_mgr._parent.log(
f"[hot-reload] WARNING: key verification failed: {vresult.get('error', 'unknown')}")
except Exception:
pass
except Exception:
pass
def _show_error(self, msg):
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
d.run(); d.destroy()
@@ -5722,5 +5893,510 @@ class BenchmarkWindow(Gtk.Window):
GLib.idle_add(_show)
# ═══════════════════════════════════════════════════════════════════
# Codex Desktop Updater — auto-update from ilysenko/codex-desktop-linux
# ═══════════════════════════════════════════════════════════════════
UPSTREAM_REPO = "ilysenko/codex-desktop-linux"
UPDATER_BIN = shutil.which("codex-update-manager") or ""
UPDATER_STATE_FILE = Path.home() / ".local/state/codex-update-manager/state.json"
UPDATER_SERVICE_LOG = Path.home() / ".local/state/codex-update-manager/service.log"
def _get_updater_status():
try:
out = subprocess.run(
[UPDATER_BIN, "status", "--json"],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0 and out.stdout.strip():
return json.loads(out.stdout.strip())
except Exception:
pass
return None
def _get_installed_desktop_version():
try:
out = subprocess.run(
["dpkg-query", "-W", "-f", "${Version}", "codex-desktop"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except Exception:
pass
return None
def _get_upstream_info():
try:
req = urllib.request.Request(
f"https://api.github.com/repos/{UPSTREAM_REPO}/commits?per_page=1",
headers={"Accept": "application/vnd.github+json", "User-Agent": "codex-launcher"},
)
resp = urllib.request.urlopen(req, timeout=10)
commits = json.loads(resp.read())
if commits:
c = commits[0]
return {
"sha": c["sha"][:12],
"date": c["commit"]["committer"]["date"][:10],
"message": c["commit"]["message"].split("\n")[0][:80],
}
except Exception:
pass
return None
def _is_updater_service_active():
try:
out = subprocess.run(
["systemctl", "--user", "is-active", "codex-update-manager.service"],
capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() == "active"
except Exception:
return False
class CodexUpdaterWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Codex Desktop Updater")
self.set_default_size(580, 520)
self.set_border_width(10)
self.set_position(Gtk.WindowPosition.CENTER)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox)
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label()
lbl.set_markup("<b>Codex Desktop Updater</b>\n<small>Auto-update from github.com/ilysenko/codex-desktop-linux</small>")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
info_frame = Gtk.Frame(label="Current Installation")
vbox.pack_start(info_frame, False, False, 4)
info_grid = Gtk.Grid(column_spacing=12, row_spacing=4, margin=8)
info_frame.add(info_grid)
self._installed_lbl = Gtk.Label(label="Checking…", xalign=0)
self._service_lbl = Gtk.Label(label="Checking…", xalign=0)
self._upstream_lbl = Gtk.Label(label="Checking…", xalign=0)
self._candidate_lbl = Gtk.Label(label="—", xalign=0)
self._cli_lbl = Gtk.Label(label="Checking…", xalign=0)
labels = [
(0, "Installed:"), (1, self._installed_lbl),
(2, "Upstream:"), (3, self._upstream_lbl),
(4, "Service:"), (5, self._service_lbl),
(6, "Candidate:"), (7, self._candidate_lbl),
(8, "CLI:"), (9, self._cli_lbl),
]
for idx, widget in labels:
if isinstance(widget, str):
widget = Gtk.Label(label=widget, xalign=0)
info_grid.attach(widget, idx % 2, idx // 2, 1, 1)
btn_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(btn_box, False, False, 4)
self._check_btn = Gtk.Button(label="Check for Updates")
self._check_btn.connect("clicked", lambda b: self._check_updates())
btn_box.pack_start(self._check_btn, True, True, 0)
self._install_btn = Gtk.Button(label="Install Update")
self._install_btn.connect("clicked", lambda b: self._install_update())
self._install_btn.set_sensitive(False)
self._install_btn.get_style_context().add_class("suggested-action")
btn_box.pack_start(self._install_btn, True, True, 0)
self._rollback_btn = Gtk.Button(label="Rollback")
self._rollback_btn.connect("clicked", lambda b: self._rollback())
self._rollback_btn.set_sensitive(False)
btn_box.pack_start(self._rollback_btn, True, True, 0)
auto_note = Gtk.Label(xalign=0)
auto_note.set_markup("<small>↑ Auto-updater: only detects new upstream <i>Codex.dmg</i> from OpenAI. "
"For latest community patches, use Rebuild from Source below.</small>")
auto_note.set_use_markup(True)
vbox.pack_start(auto_note, False, False, 0)
svc_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(svc_box, False, False, 0)
self._svc_start_btn = Gtk.Button(label="Start Service")
self._svc_start_btn.connect("clicked", lambda b: self._toggle_service("start"))
svc_box.pack_start(self._svc_start_btn, True, True, 0)
self._svc_stop_btn = Gtk.Button(label="Stop Service")
self._svc_stop_btn.connect("clicked", lambda b: self._toggle_service("stop"))
svc_box.pack_start(self._svc_stop_btn, True, True, 0)
self._svc_enable_btn = Gtk.Button(label="Enable Autostart")
self._svc_enable_btn.connect("clicked", lambda b: self._toggle_service("enable"))
svc_box.pack_start(self._svc_enable_btn, True, True, 0)
rebuild_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_box, False, False, 4)
rebuild_info = Gtk.Label(xalign=0)
rebuild_info.set_markup(
"<b>Rebuild from Source (Recommended)</b>\n"
"<small>The auto-updater only detects new upstream Codex DMGs from OpenAI's CDN.\n"
"To get the <i>latest community fixes</i> from ilysenko/codex-desktop-linux,\n"
"use Clone/Pull then Build &amp; Install to rebuild a fresh .deb from source.</small>"
)
rebuild_info.set_use_markup(True)
rebuild_box.pack_start(rebuild_info, True, True, 0)
rebuild_btn_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_btn_box, False, False, 0)
self._clone_btn = Gtk.Button(label="Clone / Pull Repo")
self._clone_btn.connect("clicked", lambda b: self._clone_or_pull())
rebuild_btn_box.pack_start(self._clone_btn, True, True, 0)
self._build_btn = Gtk.Button(label="Build & Install .deb")
self._build_btn.connect("clicked", lambda b: self._build_and_install())
self._build_btn.set_sensitive(False)
self._build_btn.get_style_context().add_class("suggested-action")
rebuild_btn_box.pack_start(self._build_btn, True, True, 0)
self._rebuild_dir_lbl = Gtk.Label(label="", xalign=0)
vbox.pack_start(self._rebuild_dir_lbl, False, False, 0)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
vbox.pack_start(sw, True, True, 0)
self._log_buf = Gtk.TextBuffer()
tv = Gtk.TextView(buffer=self._log_buf)
tv.set_editable(False)
tv.set_cursor_visible(False)
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
sw.add(tv)
bb = Gtk.Box(spacing=8)
vbox.pack_start(bb, False, False, 0)
clear_btn = Gtk.Button(label="Clear Log")
clear_btn.connect("clicked", lambda b: self._log_buf.set_text(""))
bb.pack_start(clear_btn, False, False, 0)
view_log_btn = Gtk.Button(label="View Service Log")
view_log_btn.connect("clicked", lambda b: self._view_service_log())
bb.pack_start(view_log_btn, False, False, 0)
close_btn = Gtk.Button(label="Close")
close_btn.connect("clicked", lambda b: self.destroy())
bb.pack_end(close_btn, False, False, 0)
self.show_all()
self._rebuild_dir = Path.home() / ".cache/codex-launcher/codex-desktop-linux"
self._rebuild_dir_lbl.set_markup(f"<small>Build dir: {self._rebuild_dir}</small>")
self._rebuild_dir_lbl.set_use_markup(True)
self._log("Updater initialized")
threading.Thread(target=self._refresh_status, daemon=True).start()
def _log(self, msg):
def _append():
e = self._log_buf.get_end_iter()
self._log_buf.insert(e, msg + "\n")
GLib.idle_add(_append)
def _refresh_status(self):
installed = _get_installed_desktop_version()
upstream = _get_upstream_info()
status = _get_updater_status()
svc_active = _is_updater_service_active()
def _update():
if installed:
self._installed_lbl.set_markup(f"<span foreground='#2ea043'><b>{installed}</b></span>")
self._installed_lbl.set_use_markup(True)
else:
self._installed_lbl.set_text("Not installed via dpkg")
if upstream:
self._upstream_lbl.set_markup(
f"<span foreground='#2ea043'>{upstream['date']}</span>"
f" <small>({upstream['sha']}) {upstream['message']}</small>"
)
self._upstream_lbl.set_use_markup(True)
else:
self._upstream_lbl.set_text("Could not fetch")
if svc_active:
self._service_lbl.set_markup("<span foreground='#2ea043'>● active</span>")
self._service_lbl.set_use_markup(True)
else:
self._service_lbl.set_markup("<span foreground='#d29922'>● inactive</span>")
self._service_lbl.set_use_markup(True)
if status:
cand = status.get("candidate_version")
if cand:
self._candidate_lbl.set_markup(f"<span foreground='#58a6ff'><b>{cand}</b></span>")
self._candidate_lbl.set_use_markup(True)
self._install_btn.set_sensitive(True)
else:
self._candidate_lbl.set_text("No update pending")
self._install_btn.set_sensitive(False)
cli_ver = status.get("cli_installed_version", "")
cli_latest = status.get("cli_latest_version", "")
cli_status = status.get("cli_status", "")
if cli_ver:
color = "#2ea043" if cli_status == "up_to_date" else "#d29922"
self._cli_lbl.set_markup(
f"<span foreground='{color}'>{cli_ver}"
f"{' (up to date)' if cli_status == 'up_to_date' else f' → {cli_latest}'}"
f"</span>"
)
self._cli_lbl.set_use_markup(True)
has_rollback = bool(status.get("last_known_good_version"))
self._rollback_btn.set_sensitive(has_rollback)
else:
if not UPDATER_BIN:
self._candidate_lbl.set_text("codex-update-manager not found")
else:
self._candidate_lbl.set_text("Status unavailable")
if self._rebuild_dir.exists():
self._build_btn.set_sensitive(True)
GLib.idle_add(_update)
self._log(f"Status: installed={installed} svc={'active' if svc_active else 'inactive'}")
def _check_updates(self):
self._check_btn.set_sensitive(False)
self._log("Checking for updates…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "check-now"],
capture_output=True, text=True, timeout=120,
)
self._log(f"check-now: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._check_btn.set_sensitive(True))
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _install_update(self):
self._install_btn.set_sensitive(False)
self._log("Installing update (may prompt for sudo)…")
def _run():
try:
desktop_running = False
try:
out = subprocess.run(
["pgrep", "-f", "/opt/codex-desktop/electron"],
capture_output=True, text=True, timeout=5,
)
desktop_running = out.returncode == 0
except Exception:
pass
if desktop_running:
self._log("⚠ Codex Desktop is running. Closing it to proceed with update…")
subprocess.run(["pkill", "-f", "/opt/codex-desktop/electron"], timeout=10)
import time; time.sleep(3)
self._log("Desktop closed.")
out = subprocess.run(
[UPDATER_BIN, "install-ready"],
capture_output=True, text=True, timeout=300,
)
self._log(f"install-ready: rc={out.returncode}")
combined = (out.stdout or "") + (out.stderr or "")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
if out.returncode == 0 and "successfully" in combined.lower():
self._log("Update installed successfully!")
elif "No Codex Desktop update is ready" in combined:
self._log("⚠ No update is ready. Run 'Check for Updates' first, or use 'Clone/Pull + Build & Install' for a manual update.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
elif "Close it to install" in combined:
self._log("⚠ Desktop was still running. Close Desktop manually and try again.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
elif out.returncode == 0:
self._log("⚠ install-ready returned OK but no confirmation of actual install. Output: " + combined[:200])
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
else:
self._log("Update may not have completed. Check the log above.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _rollback(self):
self._rollback_btn.set_sensitive(False)
self._log("Rolling back to previous version…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "rollback"],
capture_output=True, text=True, timeout=300,
)
self._log(f"rollback: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _toggle_service(self, action):
cmd_map = {
"start": ["systemctl", "--user", "start", "codex-update-manager.service"],
"stop": ["systemctl", "--user", "stop", "codex-update-manager.service"],
"enable": ["systemctl", "--user", "enable", "--now", "codex-update-manager.service"],
}
cmd = cmd_map.get(action)
if not cmd:
return
self._log(f"Running: {' '.join(cmd)}")
def _run():
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
self._log(f"{action}: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _clone_or_pull(self):
self._clone_btn.set_sensitive(False)
self._log(f"Clone/pull {UPSTREAM_REPO}…")
def _run():
try:
self._rebuild_dir.parent.mkdir(parents=True, exist_ok=True)
if self._rebuild_dir.exists():
self._log("Pulling latest changes…")
out = subprocess.run(
["git", "pull", "--ff-only"],
capture_output=True, text=True, timeout=60,
cwd=str(self._rebuild_dir),
)
else:
self._log("Cloning repository…")
out = subprocess.run(
["git", "clone", "--depth=1", f"https://github.com/{UPSTREAM_REPO}.git", str(self._rebuild_dir)],
capture_output=True, text=True, timeout=120,
)
self._log(f"git: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:200])
if out.stderr:
self._log(out.stderr.strip()[:200])
if out.returncode == 0:
self._log("Repository ready.")
GLib.idle_add(lambda: self._build_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._clone_btn.set_sensitive(True))
threading.Thread(target=_run, daemon=True).start()
def _build_and_install(self):
self._build_btn.set_sensitive(False)
self._log("Building Codex Desktop from source (this may take several minutes)…")
def _run():
try:
self._log("Installing build dependencies…")
out = subprocess.run(
["bash", "-c", "bash scripts/install-deps.sh"],
capture_output=True, text=True, timeout=300,
cwd=str(self._rebuild_dir),
)
self._log(f"install-deps: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
self._log("Building app from upstream DMG…")
out = subprocess.run(
["make", "build-app-fresh"],
capture_output=True, text=True, timeout=600,
cwd=str(self._rebuild_dir),
)
self._log(f"build-app-fresh: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Build failed. Check log above.")
return
self._log("Building .deb package…")
out = subprocess.run(
["make", "deb"],
capture_output=True, text=True, timeout=120,
cwd=str(self._rebuild_dir),
)
self._log(f"deb: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Deb build failed.")
return
deb_files = list((self._rebuild_dir / "dist").glob("codex-desktop_*.deb"))
if not deb_files:
self._log("No .deb found in dist/")
return
deb_path = deb_files[-1]
self._log(f"Installing {deb_path.name}…")
out = subprocess.run(
["pkexec", "dpkg", "-i", str(deb_path)],
capture_output=True, text=True, timeout=120,
)
self._log(f"dpkg -i: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:300])
if out.stderr:
self._log(out.stderr.strip()[:300])
if out.returncode == 0:
self._log("Codex Desktop updated successfully!")
else:
self._log("Installation failed. Try: sudo dpkg -i " + str(deb_path))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _view_service_log(self):
if UPDATER_SERVICE_LOG.exists():
subprocess.Popen(["xdg-open", str(UPDATER_SERVICE_LOG)])
else:
self._log(f"Service log not found at {UPDATER_SERVICE_LOG}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,6 +8,7 @@ the tkinter GUI (Windows). No pip dependencies. No GTK/PyGObject imports.
import base64
import collections
import contextlib
import copy
import hashlib
import json
import os
@@ -68,6 +69,9 @@ BGP_POOLS_FILE = CONFIG_DIR / "bgp-pools.json"
LAUNCH_LOG = LOG_DIR / "launcher.log"
OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json"
GEMINI_OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
GEMINI_OAUTH_CLIENT_SECRET = "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"
if IS_WINDOWS:
PROXY = BIN_DIR / "translate-proxy.py"
CLEANUP = BIN_DIR / "cleanup-codex-stale.py"
@@ -82,7 +86,159 @@ model_provider = ""
model_catalog_json = ""
"""
_MODULE_DIR = Path(__file__).resolve().parent
if str(_MODULE_DIR) not in sys.path:
sys.path.insert(0, str(_MODULE_DIR))
try:
import universal_runtime as _universal_runtime
except (ImportError, ModuleNotFoundError):
_universal_runtime = None
def detect_runtime_environment():
if _universal_runtime is None:
return {"profile": "unknown", "fallback_mode": "builtin"}
return _universal_runtime.detect_environment()
def build_cross_platform_profile(mode="basic", overrides=None):
if _universal_runtime is None:
return {"profile": "legacy", "mode": mode, "overrides": overrides or {}}
return _universal_runtime.build_runtime_profile(mode=mode, overrides=overrides)
def run_doctor_plus():
if _universal_runtime is None:
return {"health": "unknown", "checks": []}
deps = ["python3" if not IS_WINDOWS else "python", "curl"]
return _universal_runtime.doctor_plus(deps, [CONFIG, ENDPOINTS_FILE, PROXY_CONFIG_DIR / "probe"])
def choose_policy_route(routes, policy=None):
if _universal_runtime is None:
return routes[0] if routes else {}
return _universal_runtime.select_policy_route(routes, policy=policy)
def create_session_portability_pack(destination, metadata=None, files=None):
if _universal_runtime is None:
raise RuntimeError("universal runtime unavailable")
return _universal_runtime.export_session_pack(Path(destination), metadata or {}, [Path(p) for p in (files or [])])
def restore_session_portability_pack(bundle_path, destination_dir):
if _universal_runtime is None:
raise RuntimeError("universal runtime unavailable")
return _universal_runtime.import_session_pack(Path(bundle_path), Path(destination_dir))
CHANGELOG = [
("10.13.8", "2026-05-27", [
"Fix: force_finalize skips Gemini call entirely (was hallucinating tool calls without tools)",
"Fix: _send_ag_finalize returns status=failed (was stored as valid history causing loops)",
"Fix: _forward_gemini_sse wrapped in try/except for TimeoutError/BrokenPipe",
"Fix: file tracker mutations inside lock scope (was racing in ThreadingHTTPServer)",
"Fix: compaction summary strips raw tool outputs (was re-triggering read loops)",
"Fix: post-compaction write directive when 10+ reads with 0 writes",
"Fix: detect get_goal/completion_budget null-tool loops (3+ → force finalize)",
"Fix: read-loop threshold raised to 8 same-file / 40 total (was too aggressive at 5/30)",
"Fix: strip timestamps from loop hash, base64 image data from normalizer",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplify model resolution",
"Removed broken schema sanitization, restored correct headers",
"Expanded model alias map for all Antigravity variants",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity provider (PR #13)",
"New antigravity_grpc module with protobuf client",
"REST 404 triggers gRPC fallback using display names",
"gRPC supports streaming and unary generate",
"Dynamic version fetch with probe validation",
"Antigravity v2 handler rewrite (anti-api approach)",
"Safety settings, stopSequences, sessionId, requestType: agent",
]),
("3.11.11", "2026-05-26", [
"Final trimming only removes plain messages, never function_call_output",
]),
("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)",
"Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance",
"Stricter function call/response isolation — no merging across role boundaries",
]),
("3.11.9", "2026-05-26", [
"Fix Antigravity: preserve functionCall/functionResponse in Gemini sanitizer (PR #10)",
"Prevents tool responses from being merged/dropped in multi-turn Antigravity sessions",
]),
("3.11.8", "2026-05-26", [
"Vision description cache persisted across requests (no redundant API calls for same image)",
"Merge PR #8: fix vision cache persistence across requests",
]),
("3.11.7", "2026-05-26", [
"Vision auto-detect: uses provider's own vision model (e.g. 0G-Qwen-VL) as fallback for image description",
"Vision preprocessing replaces image stripping: images described via API instead of just removed",
"Fix AttributeError in image_url handling when value is string not dict",
"Merge PR #6: vision/OCR preprocessing for text-only models",
"Merge PR #7: 177 unit tests for translate-proxy.py",
"Auth os error 2 fix: GUI shows config-missing message instead of raw error",
]),
("3.11.6", "2026-05-26", [
"Antigravity loop breakers: per-session tracking, edit-intent nudge (first turn only)",
"Loop breaker: same tool+args repeated 5+ times triggers force finalization",
"Latest user instruction appended exactly once per request",
"Detailed [antigravity-loop] logging for all tracking fields",
"has_content fix: function_call now counts as valid output (no more infinite loops)",
"Antigravity-only changes, no touch to other providers",
]),
("3.11.5", "2026-05-26", [
"Token-aware compaction: fixes context_length_exceeded on small-context models (25 items x 1600 tokens)",
"Proactive compaction triggers on token count (>80% model limit), not just item count",
"Universal adaptive compaction: removed crof.ai-only gates, all providers get compaction",
"Vision model detection: strips images for non-vision models, keeps for vision-capable ones",
"Per-model token limit learning from context_length_exceeded error messages",
"Compaction aggression levels: normal vs extreme when tokens > 1.5x model limit",
"Smart-continue text-tool detection: triggers on tool-call text patterns, not just function_call_output",
"Active endpoint sync: GUI auto-removes stale endpoint references on startup",
]),
("3.11.0", "2026-05-26", [
"Merge cobra PR: concurrency semaphore (max 3), auto-continue for truncated text",
"SO_REUSEADDR on sticky port, proxy-stderr.log, stream diagnostics logging",
"Timeout/OSError handler sends response.failed SSE instead of silent drop",
"Restart Proxy button: only restarts proxy without killing Codex Desktop",
"Tool call argument normalizer: fixes Arguments->arguments, strips markdown wrapping",
"Smart-continue loop (2x retries): escalating nudges when model stops text-only mid-task",
"XML tool call extraction: parses patterns from text, injects as real calls",
"Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
"API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
"GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
"Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
]),
("3.10.12", "2026-05-26", [
"Sticky endpoint: caches last working endpoint, sequential fallback on failure",
"Endpoint order: cloudcode-pa first (matches agy CLI), daily-cloudcode-pa fallback",
"Anti-stall engine: kills stale proxy processes + clears pycache on startup",
"Smart error classification: quota vs capacity vs banned vs validation vs auth",
"Rate limit reset parsing: extracts cooldown from error body for accuracy",
"Missing headers: X-Client-Name, X-Client-Version, x-goog-api-client, sessionId",
"Guardrail skip: simple messages (hi) skip agent guardrail, no more tool-call loops",
"Claude fixes: preserve all tools, skip compaction/normalizer/sanitization for Claude",
"Normalizer model param: distinguishes Claude vs Gemini for correct behavior",
]),
("3.10.11", "2026-05-26", [
"Hybrid endpoint fallback: cloudcode-pa then daily-cloudcode-pa on 429",
"daily-cloudcode-pa.googleapis.com (same endpoint agy-core uses)",
"429 errors log full response body for debugging",
"Rate-limit marking only after ALL endpoints fail",
"Restored SERVICE_DISABLED (403) fallthrough",
]),
("3.10.10", "2026-05-25", [
"Fix normalizer stripping ALL context after compaction on resumed sessions",
"No auto-reset when compaction summary present (preserves 1925+ turn history)",
"Always preserve compaction summaries in normalizer output",
"Deduplicate consecutive identical goal_context messages",
"Emergency reset preserves compaction summaries",
"Fix hashlib NameError in _antigravity_normalize_context (string comparison instead)",
]),
("3.10.9", "2026-05-25", [
"Antigravity: production-only endpoints (cloudcode-pa.googleapis.com), sandbox blocked unless ALLOW_ANTIGRAVITY_STAGING=1",
"Antigravity: 403 SERVICE_DISABLED falls through, 429 returns to client (no sandbox fallback)",
@@ -349,7 +505,7 @@ CHANGELOG = [
]
# ═══════════════════════════════════════════════════════════════════════
# Provider presets (17 providers)
# Provider presets (25+ providers)
# ═══════════════════════════════════════════════════════════════════════
PROVIDER_PRESETS = {
@@ -375,7 +531,9 @@ PROVIDER_PRESETS = {
"glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
"minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free",
"deepseek-v4-flash-free", "nemotron-3-super-free",
"qwen3.6-plus", "qwen3.5-plus", "big-pickle",
"qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free",
"gemini-3-flash", "gemini-3.1-pro", "gemini-3.5-flash",
"big-pickle", "grok-build-0.1",
],
},
"OpenCode Zen (Anthropic)": {
@@ -384,7 +542,7 @@ PROVIDER_PRESETS = {
"models": [
"claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5",
"claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5",
"claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku",
"claude-sonnet-4", "claude-haiku-4-5",
],
},
"OpenCode Go (OpenAI-compatible)": {
@@ -392,8 +550,10 @@ PROVIDER_PRESETS = {
"base_url": "https://opencode.ai/zen/go/v1",
"models": [
"glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
"mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5",
"qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash",
"mimo-v2-omni", "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro",
"minimax-m2.7", "minimax-m2.5",
"qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus",
"deepseek-v4-pro", "deepseek-v4-flash", "hy3-preview",
],
},
"OpenCode Go (Anthropic)": {
@@ -406,6 +566,20 @@ PROVIDER_PRESETS = {
"base_url": "https://crof.ai/v1",
"models": [],
},
"Ocenza": {
"backend_type": "openai-compat",
"base_url": "https://global.ocenza.com/v1",
"models": [
"gpt-oss-120b", "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro",
],
},
"MiMo (Xiaomi)": {
"backend_type": "openai-compat",
"base_url": "https://token-plan-sgp.xiaomimimo.com/v1",
"models": [
"mimo-v2-omni", "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro",
],
},
"NVIDIA NIM": {
"backend_type": "openai-compat",
"base_url": "https://integrate.api.nvidia.com/v1",
@@ -437,6 +611,41 @@ PROVIDER_PRESETS = {
"base_url": "https://openrouter.ai/api/v1",
"models": [],
},
"Perplexity": {
"backend_type": "openai-compat",
"base_url": "https://api.perplexity.ai",
"models": [
"sonar",
"sonar-pro",
"sonar-reasoning-pro",
"sonar-deep-research",
],
},
"Cohere": {
"backend_type": "openai-compat",
"base_url": "https://api.cohere.ai/compatibility/v1",
"models": [],
},
"Hugging Face": {
"backend_type": "openai-compat",
"base_url": "https://router.huggingface.co/v1",
"models": [],
},
"Together AI": {
"backend_type": "openai-compat",
"base_url": "https://api.together.xyz/v1",
"models": [],
},
"Groq": {
"backend_type": "openai-compat",
"base_url": "https://api.groq.com/openai/v1",
"models": [],
},
"Fireworks AI": {
"backend_type": "openai-compat",
"base_url": "https://api.fireworks.ai/inference/v1",
"models": [],
},
"Google Gemini (API Key)": {
"backend_type": "openai-compat",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
@@ -501,6 +710,16 @@ PROVIDER_PRESETS = {
"base_url": "http://localhost:11434/v1",
"models": [],
},
"LM Studio (local)": {
"backend_type": "openai-compat",
"base_url": "http://127.0.0.1:1234/v1",
"models": [],
},
"vLLM / OpenAI-Compatible (self-hosted)": {
"backend_type": "openai-compat",
"base_url": "http://localhost:8000/v1",
"models": [],
},
}
# ═══════════════════════════════════════════════════════════════════════
@@ -618,6 +837,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type):
return {
@@ -733,29 +955,33 @@ def apply_provider_preset(endpoint, preset_name):
def load_endpoints():
if ENDPOINTS_FILE.exists():
try:
return json.loads(ENDPOINTS_FILE.read_text())
except Exception:
pass
return json.loads(ENDPOINTS_FILE.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[lib] failed to load endpoints: {exc}", file=sys.stderr)
return {"default": None, "endpoints": []}
def save_endpoints(data):
ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
ENDPOINTS_FILE.write_text(json.dumps(data, indent=2))
tmp = ENDPOINTS_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2))
os.replace(str(tmp), str(ENDPOINTS_FILE))
def load_bgp_pools():
if BGP_POOLS_FILE.exists():
try:
return json.loads(BGP_POOLS_FILE.read_text())
except Exception:
pass
return json.loads(BGP_POOLS_FILE.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[lib] failed to load bgp pools: {exc}", file=sys.stderr)
return {"pools": []}
def save_bgp_pools(data):
BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
BGP_POOLS_FILE.write_text(json.dumps(data, indent=2))
tmp = BGP_POOLS_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2))
os.replace(str(tmp), str(BGP_POOLS_FILE))
def get_endpoint(name):
@@ -821,10 +1047,28 @@ def write_secure_text(path, text):
# ═══════════════════════════════════════════════════════════════════════
def backup_config():
if CONFIG.exists():
if not CONFIG.exists():
return
tmp = CONFIG_BAK.with_suffix(".tmp")
shutil.copy2(str(CONFIG), str(tmp))
os.replace(str(tmp), str(CONFIG_BAK))
ts = time.strftime("%Y%m%d_%H%M%S")
rot = CONFIG.parent / f"config.toml.{ts}.bak"
try:
shutil.copy2(str(CONFIG), str(rot))
_rotate_backups(CONFIG.parent, "config.toml.*.bak", max_backups=10)
except Exception as exc:
print(f"[lib] backup rotation failed: {exc}", file=sys.stderr)
def _rotate_backups(directory, pattern, max_backups=10):
import glob as _glob
files = sorted(_glob.glob(str(directory / pattern)), key=os.path.getmtime, reverse=True)
for old in files[max_backups:]:
try:
os.remove(old)
except Exception:
pass
def restore_config():
@@ -851,7 +1095,7 @@ def recover_config_if_needed(logfn=None):
if not CONFIG_TXN.exists():
return
try:
txn = json.loads(CONFIG_TXN.read_text())
txn = json.loads(CONFIG_TXN.read_text(encoding="utf-8"))
if txn.get("config_existed") and CONFIG_BAK.exists():
restore_config()
if logfn:
@@ -1008,10 +1252,9 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n',
main_config = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
@@ -1019,16 +1262,21 @@ def write_config_for_native(endpoint, selected_model):
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
write_secure_text(profile_path, "".join(profile_lines))
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
@@ -1037,10 +1285,9 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n',
main_config = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
@@ -1048,16 +1295,21 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
f'experimental_bearer_token = "codex-launcher-local"\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
write_secure_text(profile_path, "".join(profile_lines))
# ═══════════════════════════════════════════════════════════════════════
# Model fetching
@@ -1083,6 +1335,24 @@ def endpoint_model_headers(endpoint):
return headers
def check_provider_latency(endpoint, timeout=5):
bt = endpoint.get("backend_type", "")
if bt in ("native", "codex-default", "gemini-oauth-antigravity"):
return None
base = endpoint.get("base_url", "").strip()
if not base:
return None
url = base.rstrip("/") + "/models"
try:
headers = endpoint_model_headers(endpoint)
req = urllib.request.Request(url, headers=headers, method="GET")
t0 = time.time()
urllib.request.urlopen(req, timeout=timeout)
return time.time() - t0
except Exception:
return None
def fetch_models_for_endpoint(endpoint, timeout=10):
bt = endpoint.get("backend_type", "")
if bt == "gemini-oauth-antigravity":
@@ -1140,9 +1410,16 @@ ANTIGRAVITY_MODELS = [
def load_oauth_secrets():
try:
with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f:
return json.load(f)
data = json.load(f)
except Exception:
return {}
data = {}
for key in ("antigravity", "gemini_cli"):
sec = data.get(key, {})
if not sec.get("client_id"):
data.setdefault(key, {})["client_id"] = GEMINI_OAUTH_CLIENT_ID
if not sec.get("client_secret"):
data.setdefault(key, {})["client_secret"] = GEMINI_OAUTH_CLIENT_SECRET
return data
def save_oauth_secrets(data):
@@ -1326,7 +1603,7 @@ def run_endpoint_doctor(endpoint):
token_path = PROXY_CONFIG_DIR / token_name
if token_path.exists():
try:
td = json.loads(token_path.read_text())
td = json.loads(token_path.read_text(encoding="utf-8"))
exp = td.get("expires_at", 0)
if exp > time.time():
remaining = exp - time.time()
@@ -1395,9 +1672,9 @@ def run_endpoint_doctor(endpoint):
def _load_pid_registry():
if PID_REGISTRY.exists():
try:
return json.loads(PID_REGISTRY.read_text())
except Exception:
pass
return json.loads(PID_REGISTRY.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[lib] failed to load pid registry: {exc}", file=sys.stderr)
return {}
@@ -1440,8 +1717,9 @@ _PROXY_PORT_FILE = PROXY_CONFIG_DIR / ".last-proxy-port"
def _pick_free_port():
saved = None
try:
saved = int(_PROXY_PORT_FILE.read_text().strip())
saved = int(_PROXY_PORT_FILE.read_text(encoding="utf-8").strip())
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", saved))
return saved
except (ValueError, OSError, FileNotFoundError):
@@ -1489,8 +1767,8 @@ def start_proxy_for(endpoint, logfn):
discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", [])
if discovered:
model_list = discovered
except Exception:
pass
except Exception as exc:
print(f"[lib] oauth token discovery: {exc}", file=sys.stderr)
pcfg = {
"port": port,
@@ -1502,6 +1780,11 @@ def start_proxy_for(endpoint, logfn):
"reasoning_enabled": endpoint.get("reasoning_enabled", True),
"reasoning_effort": endpoint.get("reasoning_effort", "medium"),
"force_model": endpoint.get("default_model") or "",
"caveman_mode": endpoint.get("caveman_mode", False),
"rtk_compression": endpoint.get("rtk_compression", False),
"auto_compact": endpoint.get("auto_compact", False),
"adaptive_compact": endpoint.get("adaptive_compact", False),
"tool_output_truncation": endpoint.get("tool_output_truncation", True),
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
for m in model_list],
}
@@ -1533,11 +1816,19 @@ def _start_proxy_with_config(pcfg_path, port, logfn):
)
_register_pgid_entry("proxy", _proxy_proc.pid)
_proxy_log_path = PROXY_CONFIG_DIR / "proxy-stderr.log"
_proxy_log_file = open(_proxy_log_path, "a", encoding="utf-8")
def _pipe_stderr():
if not _proxy_proc.stderr:
return
for line in _proxy_proc.stderr:
logfn(f"[proxy] {line.rstrip()}")
try:
_proxy_log_file.write(line)
_proxy_log_file.flush()
except Exception:
pass
threading.Thread(target=_pipe_stderr, daemon=True).start()
@@ -1581,6 +1872,11 @@ def start_bgp_proxy(pool, model, logfn):
"target_url": "http://bgp.placeholder",
"api_key": "",
"bgp_routes": pool.get("routes", []),
"caveman_mode": endpoint.get("caveman_mode", False) if endpoint else False,
"rtk_compression": endpoint.get("rtk_compression", False) if endpoint else False,
"auto_compact": endpoint.get("auto_compact", False) if endpoint else False,
"adaptive_compact": endpoint.get("adaptive_compact", False) if endpoint else False,
"tool_output_truncation": endpoint.get("tool_output_truncation", True) if endpoint else True,
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
}
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
@@ -1606,6 +1902,12 @@ def detect_codex_cli():
def detect_codex_desktop():
"""Detect Codex Desktop installation.
Returns (path_or_aumid, is_msix) tuple on Windows, path string on Linux.
For MSIX installs, returns the AppUserModelId since the exe cannot be
launched directly via subprocess from WindowsApps.
"""
if IS_WINDOWS:
la = os.environ.get("LOCALAPPDATA", "")
pf = os.environ.get("PROGRAMFILES", "")
@@ -1618,8 +1920,8 @@ def detect_codex_desktop():
]
for p in desktop_paths:
if p.exists():
return str(p)
# MSIX / Microsoft Store install: locate via Get-AppxPackage
return str(p), False
# MSIX / Microsoft Store install
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
@@ -1630,13 +1932,70 @@ def detect_codex_desktop():
if loc:
msix_exe = Path(loc) / "app" / "Codex.exe"
if msix_exe.exists():
return str(msix_exe)
r2 = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-AppxPackage *OpenAI.Codex*).PackageFamilyName"],
capture_output=True, text=True, timeout=10,
)
family = r2.stdout.strip() if r2.returncode == 0 else ""
if family:
return f"{family}!App", True
except Exception:
pass
return None
return None, False
if START_SH and START_SH.exists():
return str(START_SH)
return None
return str(START_SH), False
return None, False
def launch_codex_desktop(desktop_info):
"""Launch Codex Desktop process.
Args:
desktop_info: (path_or_aumid, is_msix) tuple from detect_codex_desktop()
Returns:
subprocess.Popen object or None
"""
path, is_msix = desktop_info
if IS_WINDOWS:
if is_msix:
return subprocess.Popen(
["cmd", "/c", "start", "", f"shell:AppsFolder\\{path}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
return subprocess.Popen(
[path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
return subprocess.Popen(
[path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
def is_codex_desktop_running():
"""Check if Codex Desktop (or MSIX Codex) is currently running."""
if IS_WINDOWS:
try:
for name in ("Codex Desktop.exe", "Codex.exe"):
out = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {name}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
)
for line in out.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) >= 2 and parts[1].strip('"').isdigit():
return True
except Exception:
pass
return False
else:
try:
out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
return bool(out.stdout.strip())
except Exception:
return False
def check_codex_auth():
@@ -1655,6 +2014,10 @@ def check_codex_auth():
return ("unknown", "No output from codex login status")
except FileNotFoundError:
return ("not_installed", "codex not found")
except OSError as e:
if e.errno == 2:
return ("not_configured", "Config not found — launch Codex once to create it")
return ("error", str(e))
except Exception as e:
return ("error", str(e))
@@ -1664,7 +2027,7 @@ def check_codex_auth():
def last_log_lines(n=15):
try:
t = LAUNCH_LOG.read_text()
t = LAUNCH_LOG.read_text(encoding="utf-8")
return "\n".join(t.splitlines()[-n:])
except Exception:
return "(no log file)"
@@ -1675,9 +2038,10 @@ def last_log_lines(n=15):
def kill_existing_desktop(logfn=None):
if IS_WINDOWS:
for img in ("Codex Desktop.exe", "Codex.exe"):
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq Codex Desktop.exe", "/FO", "CSV", "/NH"],
["tasklist", "/FI", f"IMAGENAME eq {img}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
)
for line in out.stdout.strip().splitlines():
@@ -1780,9 +2144,9 @@ _DIAGNOSTIC_SYSTEM_PROMPT = (
def load_monitoring_config():
if MONITORING_FILE.exists():
try:
return json.loads(MONITORING_FILE.read_text())
except Exception:
pass
return json.loads(MONITORING_FILE.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[lib] failed to load monitoring config: {exc}", file=sys.stderr)
return {
"enabled": False,
"provider_url": "",
@@ -1802,9 +2166,9 @@ def save_monitoring_config(cfg):
def load_incident_store():
if INCIDENT_STORE_FILE.exists():
try:
return json.loads(INCIDENT_STORE_FILE.read_text())
except Exception:
pass
return json.loads(INCIDENT_STORE_FILE.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[lib] failed to load incident store: {exc}", file=sys.stderr)
return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}
@@ -1817,16 +2181,18 @@ def monitoring_log(msg):
try:
with open(str(MONITORING_LOG), "a") as f:
f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
except Exception:
pass
except Exception as exc:
print(f"[lib] monitoring_log write failed: {exc}", file=sys.stderr)
class IncidentStore:
def __init__(self):
self._store = load_incident_store()
self._dirty = False
self._lock = threading.Lock()
def lookup(self, pattern):
with self._lock:
inc = self._store.get("incidents", {}).get(pattern)
if inc and inc.get("success_count", 0) > 0:
rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1)
@@ -1835,34 +2201,45 @@ class IncidentStore:
return None
def record(self, pattern, fix, success=True):
incs = self._store.setdefault("incidents", {})
with self._lock:
new_store = copy.deepcopy(self._store)
incs = new_store.setdefault("incidents", {})
inc = incs.setdefault(pattern, {
"fix": fix, "success_count": 0, "fail_count": 0,
"last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"occurrences": 0,
})
inc = dict(inc)
inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
inc["occurrences"] = inc.get("occurrences", 0) + 1
if success:
inc["success_count"] = inc.get("success_count", 0) + 1
else:
inc["fail_count"] = inc.get("fail_count", 0) + 1
incs[pattern] = inc
self._store = new_store
self._dirty = True
def record_ai_call(self, tokens=0):
stats = self._store.setdefault("stats", {"ai_calls": 0, "tokens_used": 0})
with self._lock:
new_store = copy.deepcopy(self._store)
stats = dict(new_store.get("stats", {"ai_calls": 0, "tokens_used": 0}))
stats["ai_calls"] = stats.get("ai_calls", 0) + 1
stats["tokens_used"] = stats.get("tokens_used", 0) + tokens
new_store["stats"] = stats
self._store = new_store
self._dirty = True
def flush(self):
with self._lock:
if self._dirty:
save_incident_store(self._store)
self._dirty = False
@property
def stats(self):
return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0})
with self._lock:
return dict(self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}))
class AIDiagnosticAgent:
@@ -1983,10 +2360,10 @@ class HealthWatcher(threading.Thread):
try:
cfg_path = PROXY_CONFIG_DIR / "proxy-config.json"
if cfg_path.exists():
d = json.loads(cfg_path.read_text())
d = json.loads(cfg_path.read_text(encoding="utf-8"))
return d.get("port")
except Exception:
pass
except Exception as exc:
print(f"[lib] _get_proxy_port: {exc}", file=sys.stderr)
return None
def _check_health(self, port):
@@ -2051,7 +2428,7 @@ class HealthWatcher(threading.Thread):
for log_name in ["cc-debug.log", "proxy.log"]:
log_path = PROXY_CONFIG_DIR / log_name
try:
text = log_path.read_text()
text = log_path.read_text(encoding="utf-8")
lines.extend(text.splitlines()[-20:])
except Exception:
pass
@@ -2101,9 +2478,9 @@ class _LogAnalyzerThread(threading.Thread):
def load_usage_stats():
try:
if _USAGE_STATS_FILE.exists():
return json.loads(_USAGE_STATS_FILE.read_text())
except Exception:
pass
return json.loads(_USAGE_STATS_FILE.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[lib] failed to load usage stats: {exc}", file=sys.stderr)
return {"providers": {}, "updated": None}
# ═══════════════════════════════════════════════════════════════════════

View File

@@ -27,6 +27,20 @@ model_catalog_json = ""
"""
CHANGELOG = [
("3.13.0", "2026-05-27", [
"Codex Desktop Updater: auto-update from ilysenko/codex-desktop-linux",
"Check for updates, install, rollback, service management",
"Manual rebuild from source (clone → build → install .deb)",
"Fix Antigravity: prod endpoint first, skip sandbox service_disabled",
"Fix Antigravity: model resolution (gemini-3.5-flash-high → gemini-3-flash)",
"Fix OAUTH_PROVIDER derivation from BACKEND env var",
"Antigravity E2E test suite: bash ~/.local/bin/test-antigravity.sh",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplified model resolution",
"Removed broken schema sanitization, restored headers",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity (PR #13)",
"Dynamic version fetch with probe validation",
@@ -478,6 +492,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type):
return {
"openai-compat": "OpenAI-compatible",
@@ -1027,23 +1044,29 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [
main_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n',
]
write_secure_text(CONFIG, "".join(lines))
write_secure_text(profile_path, "".join(profile_lines))
def _toml_safe(val):
val = str(val).replace('"', '\\"')
@@ -1062,12 +1085,12 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [
main_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
@@ -1076,15 +1099,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'request_max_retries = 1\n',
f'stream_max_retries = 0\n',
f'stream_idle_timeout_ms = 600000\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n',
f'model_catalog_json = "{mc_path}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n',
]
write_secure_text(CONFIG, "".join(lines))
write_secure_text(profile_path, "".join(profile_lines))
def _gen_model_catalog(endpoint, selected_model=None):
default_model = selected_model or endpoint.get("default_model")
@@ -1961,6 +1988,9 @@ class LauncherWin(Gtk.Window):
oauth_btn = Gtk.Button(label="OAuth Secrets")
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
hdr.pack_end(oauth_btn, False, False, 0)
updater_btn = Gtk.Button(label="Update Desktop")
updater_btn.connect("clicked", lambda b: self._open_updater())
hdr.pack_end(updater_btn, False, False, 0)
# verification status bar
self._cli_info = _detect_codex_cli()
@@ -2411,6 +2441,18 @@ class LauncherWin(Gtk.Window):
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
subprocess.Popen([sys.executable, _py], start_new_session=True)
def _open_updater(self):
try:
if not UPDATER_BIN and not _detect_codex_desktop():
self.log("Codex Desktop not installed. Nothing to update.")
return
self._updater_window = CodexUpdaterWindow()
self._updater_window.connect("destroy", lambda *_: setattr(self, "_updater_window", None))
except Exception as e:
import traceback; traceback.print_exc()
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
d.run(); d.destroy()
def _backup_profile(self):
chooser = Gtk.FileChooserDialog(
title="Backup Codex Profile",
@@ -2854,7 +2896,7 @@ class LauncherWin(Gtk.Window):
cmd_parts.extend(["codex", "-c", f"model={model}",
"-s", sandbox, "-a", approval])
else:
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}",
cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}",
"-s", sandbox, "-a", approval])
self.log(f"Running: {' '.join(cmd_parts)}")
@@ -5849,5 +5891,510 @@ class BenchmarkWindow(Gtk.Window):
GLib.idle_add(_show)
# ═══════════════════════════════════════════════════════════════════
# Codex Desktop Updater — auto-update from ilysenko/codex-desktop-linux
# ═══════════════════════════════════════════════════════════════════
UPSTREAM_REPO = "ilysenko/codex-desktop-linux"
UPDATER_BIN = shutil.which("codex-update-manager") or ""
UPDATER_STATE_FILE = Path.home() / ".local/state/codex-update-manager/state.json"
UPDATER_SERVICE_LOG = Path.home() / ".local/state/codex-update-manager/service.log"
def _get_updater_status():
try:
out = subprocess.run(
[UPDATER_BIN, "status", "--json"],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0 and out.stdout.strip():
return json.loads(out.stdout.strip())
except Exception:
pass
return None
def _get_installed_desktop_version():
try:
out = subprocess.run(
["dpkg-query", "-W", "-f", "${Version}", "codex-desktop"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except Exception:
pass
return None
def _get_upstream_info():
try:
req = urllib.request.Request(
f"https://api.github.com/repos/{UPSTREAM_REPO}/commits?per_page=1",
headers={"Accept": "application/vnd.github+json", "User-Agent": "codex-launcher"},
)
resp = urllib.request.urlopen(req, timeout=10)
commits = json.loads(resp.read())
if commits:
c = commits[0]
return {
"sha": c["sha"][:12],
"date": c["commit"]["committer"]["date"][:10],
"message": c["commit"]["message"].split("\n")[0][:80],
}
except Exception:
pass
return None
def _is_updater_service_active():
try:
out = subprocess.run(
["systemctl", "--user", "is-active", "codex-update-manager.service"],
capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() == "active"
except Exception:
return False
class CodexUpdaterWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Codex Desktop Updater")
self.set_default_size(580, 520)
self.set_border_width(10)
self.set_position(Gtk.WindowPosition.CENTER)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox)
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label()
lbl.set_markup("<b>Codex Desktop Updater</b>\n<small>Auto-update from github.com/ilysenko/codex-desktop-linux</small>")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
info_frame = Gtk.Frame(label="Current Installation")
vbox.pack_start(info_frame, False, False, 4)
info_grid = Gtk.Grid(column_spacing=12, row_spacing=4, margin=8)
info_frame.add(info_grid)
self._installed_lbl = Gtk.Label(label="Checking…", xalign=0)
self._service_lbl = Gtk.Label(label="Checking…", xalign=0)
self._upstream_lbl = Gtk.Label(label="Checking…", xalign=0)
self._candidate_lbl = Gtk.Label(label="—", xalign=0)
self._cli_lbl = Gtk.Label(label="Checking…", xalign=0)
labels = [
(0, "Installed:"), (1, self._installed_lbl),
(2, "Upstream:"), (3, self._upstream_lbl),
(4, "Service:"), (5, self._service_lbl),
(6, "Candidate:"), (7, self._candidate_lbl),
(8, "CLI:"), (9, self._cli_lbl),
]
for idx, widget in labels:
if isinstance(widget, str):
widget = Gtk.Label(label=widget, xalign=0)
info_grid.attach(widget, idx % 2, idx // 2, 1, 1)
btn_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(btn_box, False, False, 4)
self._check_btn = Gtk.Button(label="Check for Updates")
self._check_btn.connect("clicked", lambda b: self._check_updates())
btn_box.pack_start(self._check_btn, True, True, 0)
self._install_btn = Gtk.Button(label="Install Update")
self._install_btn.connect("clicked", lambda b: self._install_update())
self._install_btn.set_sensitive(False)
self._install_btn.get_style_context().add_class("suggested-action")
btn_box.pack_start(self._install_btn, True, True, 0)
self._rollback_btn = Gtk.Button(label="Rollback")
self._rollback_btn.connect("clicked", lambda b: self._rollback())
self._rollback_btn.set_sensitive(False)
btn_box.pack_start(self._rollback_btn, True, True, 0)
auto_note = Gtk.Label(xalign=0)
auto_note.set_markup("<small>↑ Auto-updater: only detects new upstream <i>Codex.dmg</i> from OpenAI. "
"For latest community patches, use Rebuild from Source below.</small>")
auto_note.set_use_markup(True)
vbox.pack_start(auto_note, False, False, 0)
svc_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(svc_box, False, False, 0)
self._svc_start_btn = Gtk.Button(label="Start Service")
self._svc_start_btn.connect("clicked", lambda b: self._toggle_service("start"))
svc_box.pack_start(self._svc_start_btn, True, True, 0)
self._svc_stop_btn = Gtk.Button(label="Stop Service")
self._svc_stop_btn.connect("clicked", lambda b: self._toggle_service("stop"))
svc_box.pack_start(self._svc_stop_btn, True, True, 0)
self._svc_enable_btn = Gtk.Button(label="Enable Autostart")
self._svc_enable_btn.connect("clicked", lambda b: self._toggle_service("enable"))
svc_box.pack_start(self._svc_enable_btn, True, True, 0)
rebuild_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_box, False, False, 4)
rebuild_info = Gtk.Label(xalign=0)
rebuild_info.set_markup(
"<b>Rebuild from Source (Recommended)</b>\n"
"<small>The auto-updater only detects new upstream Codex DMGs from OpenAI's CDN.\n"
"To get the <i>latest community fixes</i> from ilysenko/codex-desktop-linux,\n"
"use Clone/Pull then Build &amp; Install to rebuild a fresh .deb from source.</small>"
)
rebuild_info.set_use_markup(True)
rebuild_box.pack_start(rebuild_info, True, True, 0)
rebuild_btn_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_btn_box, False, False, 0)
self._clone_btn = Gtk.Button(label="Clone / Pull Repo")
self._clone_btn.connect("clicked", lambda b: self._clone_or_pull())
rebuild_btn_box.pack_start(self._clone_btn, True, True, 0)
self._build_btn = Gtk.Button(label="Build & Install .deb")
self._build_btn.connect("clicked", lambda b: self._build_and_install())
self._build_btn.set_sensitive(False)
self._build_btn.get_style_context().add_class("suggested-action")
rebuild_btn_box.pack_start(self._build_btn, True, True, 0)
self._rebuild_dir_lbl = Gtk.Label(label="", xalign=0)
vbox.pack_start(self._rebuild_dir_lbl, False, False, 0)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
vbox.pack_start(sw, True, True, 0)
self._log_buf = Gtk.TextBuffer()
tv = Gtk.TextView(buffer=self._log_buf)
tv.set_editable(False)
tv.set_cursor_visible(False)
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
sw.add(tv)
bb = Gtk.Box(spacing=8)
vbox.pack_start(bb, False, False, 0)
clear_btn = Gtk.Button(label="Clear Log")
clear_btn.connect("clicked", lambda b: self._log_buf.set_text(""))
bb.pack_start(clear_btn, False, False, 0)
view_log_btn = Gtk.Button(label="View Service Log")
view_log_btn.connect("clicked", lambda b: self._view_service_log())
bb.pack_start(view_log_btn, False, False, 0)
close_btn = Gtk.Button(label="Close")
close_btn.connect("clicked", lambda b: self.destroy())
bb.pack_end(close_btn, False, False, 0)
self.show_all()
self._rebuild_dir = Path.home() / ".cache/codex-launcher/codex-desktop-linux"
self._rebuild_dir_lbl.set_markup(f"<small>Build dir: {self._rebuild_dir}</small>")
self._rebuild_dir_lbl.set_use_markup(True)
self._log("Updater initialized")
threading.Thread(target=self._refresh_status, daemon=True).start()
def _log(self, msg):
def _append():
e = self._log_buf.get_end_iter()
self._log_buf.insert(e, msg + "\n")
GLib.idle_add(_append)
def _refresh_status(self):
installed = _get_installed_desktop_version()
upstream = _get_upstream_info()
status = _get_updater_status()
svc_active = _is_updater_service_active()
def _update():
if installed:
self._installed_lbl.set_markup(f"<span foreground='#2ea043'><b>{installed}</b></span>")
self._installed_lbl.set_use_markup(True)
else:
self._installed_lbl.set_text("Not installed via dpkg")
if upstream:
self._upstream_lbl.set_markup(
f"<span foreground='#2ea043'>{upstream['date']}</span>"
f" <small>({upstream['sha']}) {upstream['message']}</small>"
)
self._upstream_lbl.set_use_markup(True)
else:
self._upstream_lbl.set_text("Could not fetch")
if svc_active:
self._service_lbl.set_markup("<span foreground='#2ea043'>● active</span>")
self._service_lbl.set_use_markup(True)
else:
self._service_lbl.set_markup("<span foreground='#d29922'>● inactive</span>")
self._service_lbl.set_use_markup(True)
if status:
cand = status.get("candidate_version")
if cand:
self._candidate_lbl.set_markup(f"<span foreground='#58a6ff'><b>{cand}</b></span>")
self._candidate_lbl.set_use_markup(True)
self._install_btn.set_sensitive(True)
else:
self._candidate_lbl.set_text("No update pending")
self._install_btn.set_sensitive(False)
cli_ver = status.get("cli_installed_version", "")
cli_latest = status.get("cli_latest_version", "")
cli_status = status.get("cli_status", "")
if cli_ver:
color = "#2ea043" if cli_status == "up_to_date" else "#d29922"
self._cli_lbl.set_markup(
f"<span foreground='{color}'>{cli_ver}"
f"{' (up to date)' if cli_status == 'up_to_date' else f' → {cli_latest}'}"
f"</span>"
)
self._cli_lbl.set_use_markup(True)
has_rollback = bool(status.get("last_known_good_version"))
self._rollback_btn.set_sensitive(has_rollback)
else:
if not UPDATER_BIN:
self._candidate_lbl.set_text("codex-update-manager not found")
else:
self._candidate_lbl.set_text("Status unavailable")
if self._rebuild_dir.exists():
self._build_btn.set_sensitive(True)
GLib.idle_add(_update)
self._log(f"Status: installed={installed} svc={'active' if svc_active else 'inactive'}")
def _check_updates(self):
self._check_btn.set_sensitive(False)
self._log("Checking for updates…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "check-now"],
capture_output=True, text=True, timeout=120,
)
self._log(f"check-now: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._check_btn.set_sensitive(True))
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _install_update(self):
self._install_btn.set_sensitive(False)
self._log("Installing update (may prompt for sudo)…")
def _run():
try:
desktop_running = False
try:
out = subprocess.run(
["pgrep", "-f", "/opt/codex-desktop/electron"],
capture_output=True, text=True, timeout=5,
)
desktop_running = out.returncode == 0
except Exception:
pass
if desktop_running:
self._log("⚠ Codex Desktop is running. Closing it to proceed with update…")
subprocess.run(["pkill", "-f", "/opt/codex-desktop/electron"], timeout=10)
import time; time.sleep(3)
self._log("Desktop closed.")
out = subprocess.run(
[UPDATER_BIN, "install-ready"],
capture_output=True, text=True, timeout=300,
)
self._log(f"install-ready: rc={out.returncode}")
combined = (out.stdout or "") + (out.stderr or "")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
if out.returncode == 0 and "successfully" in combined.lower():
self._log("Update installed successfully!")
elif "No Codex Desktop update is ready" in combined:
self._log("⚠ No update is ready. Run 'Check for Updates' first, or use 'Clone/Pull + Build & Install' for a manual update.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
elif "Close it to install" in combined:
self._log("⚠ Desktop was still running. Close Desktop manually and try again.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
elif out.returncode == 0:
self._log("⚠ install-ready returned OK but no confirmation of actual install. Output: " + combined[:200])
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
else:
self._log("Update may not have completed. Check the log above.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _rollback(self):
self._rollback_btn.set_sensitive(False)
self._log("Rolling back to previous version…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "rollback"],
capture_output=True, text=True, timeout=300,
)
self._log(f"rollback: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _toggle_service(self, action):
cmd_map = {
"start": ["systemctl", "--user", "start", "codex-update-manager.service"],
"stop": ["systemctl", "--user", "stop", "codex-update-manager.service"],
"enable": ["systemctl", "--user", "enable", "--now", "codex-update-manager.service"],
}
cmd = cmd_map.get(action)
if not cmd:
return
self._log(f"Running: {' '.join(cmd)}")
def _run():
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
self._log(f"{action}: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _clone_or_pull(self):
self._clone_btn.set_sensitive(False)
self._log(f"Clone/pull {UPSTREAM_REPO}…")
def _run():
try:
self._rebuild_dir.parent.mkdir(parents=True, exist_ok=True)
if self._rebuild_dir.exists():
self._log("Pulling latest changes…")
out = subprocess.run(
["git", "pull", "--ff-only"],
capture_output=True, text=True, timeout=60,
cwd=str(self._rebuild_dir),
)
else:
self._log("Cloning repository…")
out = subprocess.run(
["git", "clone", "--depth=1", f"https://github.com/{UPSTREAM_REPO}.git", str(self._rebuild_dir)],
capture_output=True, text=True, timeout=120,
)
self._log(f"git: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:200])
if out.stderr:
self._log(out.stderr.strip()[:200])
if out.returncode == 0:
self._log("Repository ready.")
GLib.idle_add(lambda: self._build_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._clone_btn.set_sensitive(True))
threading.Thread(target=_run, daemon=True).start()
def _build_and_install(self):
self._build_btn.set_sensitive(False)
self._log("Building Codex Desktop from source (this may take several minutes)…")
def _run():
try:
self._log("Installing build dependencies…")
out = subprocess.run(
["bash", "-c", "bash scripts/install-deps.sh"],
capture_output=True, text=True, timeout=300,
cwd=str(self._rebuild_dir),
)
self._log(f"install-deps: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
self._log("Building app from upstream DMG…")
out = subprocess.run(
["make", "build-app-fresh"],
capture_output=True, text=True, timeout=600,
cwd=str(self._rebuild_dir),
)
self._log(f"build-app-fresh: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Build failed. Check log above.")
return
self._log("Building .deb package…")
out = subprocess.run(
["make", "deb"],
capture_output=True, text=True, timeout=120,
cwd=str(self._rebuild_dir),
)
self._log(f"deb: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Deb build failed.")
return
deb_files = list((self._rebuild_dir / "dist").glob("codex-desktop_*.deb"))
if not deb_files:
self._log("No .deb found in dist/")
return
deb_path = deb_files[-1]
self._log(f"Installing {deb_path.name}…")
out = subprocess.run(
["pkexec", "dpkg", "-i", str(deb_path)],
capture_output=True, text=True, timeout=120,
)
self._log(f"dpkg -i: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:300])
if out.stderr:
self._log(out.stderr.strip()[:300])
if out.returncode == 0:
self._log("Codex Desktop updated successfully!")
else:
self._log("Installation failed. Try: sudo dpkg -i " + str(deb_path))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _view_service_log(self):
if UPDATER_SERVICE_LOG.exists():
subprocess.Popen(["xdg-open", str(UPDATER_SERVICE_LOG)])
else:
self._log(f"Service log not found at {UPDATER_SERVICE_LOG}")
if __name__ == "__main__":
main()

View File

@@ -33,6 +33,7 @@ from codex_launcher_lib import (
PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH,
ANTIGRAVITY_MODELS,
safe_name, label_for_backend, normalize_model_id, normalize_base_url,
_profile_slug,
parse_model_list, now_utc_iso, apply_provider_preset,
load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools,
get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle,
@@ -40,7 +41,7 @@ from codex_launcher_lib import (
recover_config_if_needed, write_config_for_native, write_config_for_translated,
endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint,
refresh_endpoint_models, run_endpoint_doctor,
detect_codex_cli, detect_codex_desktop, check_codex_auth,
detect_codex_cli, detect_codex_desktop, launch_codex_desktop, is_codex_desktop_running, check_codex_auth,
last_log_lines, kill_existing_desktop, safe_cleanup_owned,
start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state,
detect_terminal, open_url, open_file, write_secure_text,
@@ -2073,6 +2074,164 @@ class BenchmarkWindow:
self._dlg.after(0, _show)
# ═══════════════════════════════════════════════════════════════════════
# Codex Desktop Updater Window
# ═══════════════════════════════════════════════════════════════════════
class UpdateDesktopWindow:
def __init__(self, parent):
self._dlg = tk.Toplevel(parent)
self._dlg.title("Codex Desktop Updater")
self._dlg.geometry("580x520")
self._dlg.transient(parent)
main = ttk.Frame(self._dlg, padding=12)
main.pack(fill="both", expand=True)
ttk.Label(main, text="Codex Desktop Updater",
font=("Segoe UI", 11, "bold")).pack(anchor="w")
if IS_WINDOWS:
info_frame = ttk.LabelFrame(main, text="Status", padding=8)
info_frame.pack(fill="x", pady=(8, 0))
ttk.Label(info_frame,
text="Update feature available on Linux only.\n"
"On Windows, use the official Codex Desktop installer\n"
"or download updates from https://codex.desktop.openai.com",
foreground="#d29922").pack(anchor="w")
ttk.Button(self._dlg, text="Close",
command=self._dlg.destroy).pack(pady=(8, 0))
return
self._status_text = scrolledtext.ScrolledText(main, height=14, state="disabled",
wrap="word", font=("Consolas", 9))
self._status_text.pack(fill="both", expand=True, pady=(8, 0))
btn_frame = ttk.Frame(main)
btn_frame.pack(fill="x", pady=(8, 0))
ttk.Button(btn_frame, text="Refresh Status",
command=self._refresh_status).pack(side="left", padx=(0, 4))
ttk.Button(btn_frame, text="Check for Updates",
command=self._check_updates).pack(side="left", padx=(0, 4))
ttk.Button(btn_frame, text="Install Update",
command=self._install_update).pack(side="left", padx=(0, 4))
ttk.Button(btn_frame, text="Rollback",
command=self._rollback).pack(side="left", padx=(0, 4))
svc_frame = ttk.Frame(main)
svc_frame.pack(fill="x", pady=(4, 0))
ttk.Button(svc_frame, text="Start Service",
command=lambda: self._svc_cmd("start")).pack(side="left", padx=(0, 4))
ttk.Button(svc_frame, text="Stop Service",
command=lambda: self._svc_cmd("stop")).pack(side="left", padx=(0, 4))
ttk.Button(svc_frame, text="Enable Service",
command=lambda: self._svc_cmd("enable")).pack(side="left", padx=(0, 4))
manual_frame = ttk.LabelFrame(main, text="Manual Rebuild (ilysenko/codex-desktop-linux)", padding=4)
manual_frame.pack(fill="x", pady=(8, 0))
ttk.Button(manual_frame, text="Clone/Pull Repo",
command=self._clone_pull).pack(side="left", padx=(0, 4))
ttk.Button(manual_frame, text="Build & Install .deb",
command=self._build_install).pack(side="left", padx=(0, 4))
ttk.Button(main, text="Close",
command=self._dlg.destroy).pack(side="right", pady=(8, 0))
self._refresh_status()
def _set_status(self, text):
self._status_text.configure(state="normal")
self._status_text.delete("1.0", "end")
self._status_text.insert("end", text)
self._status_text.configure(state="disabled")
def _run_cmd(self, cmd, label):
def _thread():
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
out = r.stdout.strip() or r.stderr.strip() or "(no output)"
self._dlg.after(0, lambda: self._set_status(f"[{label}]\n{out}"))
except FileNotFoundError:
self._dlg.after(0, lambda: self._set_status(
f"[{label}] codex-update-manager not found.\n"
"Install from ilysenko/codex-desktop-linux"))
except Exception as e:
self._dlg.after(0, lambda: self._set_status(f"[{label}] Error: {e}"))
threading.Thread(target=_thread, daemon=True).start()
def _refresh_status(self):
if IS_WINDOWS:
return
def _thread():
try:
r = subprocess.run(["codex-update-manager", "status", "--json"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
data = json.loads(r.stdout.strip())
lines = []
lines.append(f"Installed version: {data.get('installed_version', 'unknown')}")
lines.append(f"Upstream version: {data.get('upstream_version', 'unknown')}")
lines.append(f"Upstream date: {data.get('upstream_date', 'unknown')}")
lines.append(f"Update available: {data.get('update_available', False)}")
lines.append(f"Service status: {data.get('service_status', 'unknown')}")
lines.append(f"Service enabled: {data.get('service_enabled', False)}")
if data.get("last_check"):
lines.append(f"Last check: {data['last_check']}")
if data.get("error"):
lines.append(f"Error: {data['error']}")
self._dlg.after(0, lambda: self._set_status("\n".join(lines)))
else:
err = r.stderr.strip() or r.stdout.strip() or "unknown error"
self._dlg.after(0, lambda: self._set_status(f"Status error:\n{err}"))
except FileNotFoundError:
self._dlg.after(0, lambda: self._set_status(
"codex-update-manager not found.\n"
"Install from ilysenko/codex-desktop-linux"))
except Exception as e:
self._dlg.after(0, lambda: self._set_status(f"Status error: {e}"))
threading.Thread(target=_thread, daemon=True).start()
def _check_updates(self):
if IS_WINDOWS:
return
self._run_cmd(["codex-update-manager", "check-now"], "Check for Updates")
def _install_update(self):
if IS_WINDOWS:
return
self._run_cmd(["codex-update-manager", "install-ready"], "Install Update")
def _rollback(self):
if IS_WINDOWS:
return
self._run_cmd(["codex-update-manager", "rollback"], "Rollback")
def _svc_cmd(self, action):
if IS_WINDOWS:
return
self._run_cmd(["systemctl", "--user", action, "codex-update-manager"],
f"Service {action}")
def _clone_pull(self):
if IS_WINDOWS:
return
repo_dir = str(Path.home() / "codex-desktop-linux")
if Path(repo_dir).exists():
self._run_cmd(["git", "-C", repo_dir, "pull"], "Pull Repo")
else:
self._run_cmd(["git", "clone",
"https://github.com/ilysenko/codex-desktop-linux",
repo_dir], "Clone Repo")
def _build_install(self):
if IS_WINDOWS:
return
repo_dir = str(Path.home() / "codex-desktop-linux")
self._run_cmd(["bash", "-c",
f"cd {repo_dir} && bash build-install.sh"],
"Build & Install .deb")
# ═══════════════════════════════════════════════════════════════════════
# Main Launcher Window
# ═══════════════════════════════════════════════════════════════════════
@@ -2161,6 +2320,7 @@ class LauncherWin:
ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0))
ttk.Button(tb1, text="OAuth Secrets", command=self._edit_oauth_secrets).pack(side="left", padx=(6, 0))
ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right")
ttk.Button(tb1, text="Update Desktop", command=self._open_updater).pack(side="right", padx=(0, 6))
# Detection status — one row per item so long paths don't truncate
self._cli_info = detect_codex_cli()
@@ -2178,9 +2338,10 @@ class LauncherWin:
desk_row = ttk.Frame(main)
desk_row.pack(fill="x", pady=(2, 0))
if self._desktop_info:
if self._desktop_info[0]:
label = "MSIX (Store)" if self._desktop_info[1] else self._desktop_info[0]
ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left")
ttk.Label(desk_row, text=f" ({self._desktop_info})", foreground="gray").pack(side="left")
ttk.Label(desk_row, text=f" ({label})", foreground="gray").pack(side="left")
else:
ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left")
ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0))
@@ -2188,7 +2349,7 @@ class LauncherWin:
self._missing = []
if not self._cli_info:
self._missing.append("cli")
if not self._desktop_info:
if not self._desktop_info[0]:
self._missing.append("desktop")
# Auth status
@@ -2301,8 +2462,9 @@ class LauncherWin:
self.log(f"✓ Codex CLI detected ({ver})")
else:
self.log("✗ Codex CLI NOT found -- CLI launch disabled.")
if self._desktop_info:
self.log(f"✓ Codex Desktop detected ({self._desktop_info})")
if self._desktop_info[0]:
label = "MSIX (Store)" if self._desktop_info[1] else self._desktop_info[0]
self.log(f"✓ Codex Desktop detected ({label})")
else:
self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.")
if self._missing:
@@ -2407,6 +2569,9 @@ class LauncherWin:
def _open_benchmark(self):
BenchmarkWindow(self._root)
def _open_updater(self):
UpdateDesktopWindow(self._root)
def _open_proxy_log_dir(self):
log_dir = str(PROXY_CONFIG_DIR)
req_log = PROXY_CONFIG_DIR / "requests.log"
@@ -2969,7 +3134,7 @@ class LauncherWin:
# ── Launch ───────────────────────────────────────────────────────
def _set_busy(self, busy):
def _set_busy(self, busy, proxy_alive=False):
has_cli = "cli" not in self._missing
has_desk = "desktop" not in self._missing
def _update():
@@ -2977,8 +3142,8 @@ class LauncherWin:
self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal")
self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal")
self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal")
self._kill_btn.configure(state="normal" if busy else "disabled")
self._restart_btn.configure(state="normal" if busy else "disabled")
self._kill_btn.configure(state="normal" if busy or proxy_alive else "disabled")
self._restart_btn.configure(state="normal" if busy or proxy_alive else "disabled")
self._root.after(0, _update)
def _launch(self, target):
@@ -3065,7 +3230,7 @@ class LauncherWin:
finally:
if keep_session_alive:
self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.")
self._set_busy(False)
self._set_busy(False, proxy_alive=True)
self.log("Ready. Use Kill && Cleanup when finished.")
else:
stop_proxy()
@@ -3129,25 +3294,31 @@ class LauncherWin:
self.log("Ready.")
def _launch_desktop(self, ep, model):
desktop_path = self._desktop_info
if not desktop_path:
if not self._desktop_info[0]:
self.log("ERROR: Codex Desktop not found")
return False
if IS_WINDOWS:
self._proc = subprocess.Popen(
[desktop_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
self._proc = subprocess.Popen(
[desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
_, is_msix = self._desktop_info
self._proc = launch_codex_desktop(self._desktop_info)
if not self._proc:
self.log("ERROR: Failed to launch Codex Desktop")
return False
pid = self._proc.pid
self.log(f"Desktop started (PID {pid})")
self.log(f"Log: {LAUNCH_LOG}")
# MSIX: cmd.exe exits immediately, monitor via tasklist instead
if is_msix and IS_WINDOWS:
time.sleep(3)
if not is_codex_desktop_running():
self.log("ERROR: Codex Desktop did not start")
self._proc = None
return False
self.log("Codex Desktop is running (MSIX)")
self._proc = None
return True
t0 = time.time()
stall_warned = False
while self._proc and self._proc.poll() is None:
@@ -3184,7 +3355,7 @@ class LauncherWin:
if ep["backend_type"] == "native":
cmd_parts.extend(["codex", "-c", f"model={model}"])
else:
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"])
cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}"])
self.log(f"Running: {' '.join(cmd_parts)}")
if IS_WINDOWS:
@@ -3203,18 +3374,13 @@ class LauncherWin:
def _launch_desktop_direct(self):
self.log("Launching Codex Desktop (default OAuth)...")
desktop_path = self._desktop_info
if not desktop_path:
if not self._desktop_info[0]:
self.log("ERROR: Codex Desktop not found")
return
if IS_WINDOWS:
self._proc = subprocess.Popen(
[desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
self._proc = subprocess.Popen(
[desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
self._proc = launch_codex_desktop(self._desktop_info)
if not self._proc:
self.log("ERROR: Failed to launch Codex Desktop")
return
pid = self._proc.pid
self.log(f"Desktop started (PID {pid})")

View File

@@ -83,6 +83,25 @@ model_catalog_json = ""
"""
CHANGELOG = [
("10.13.6", "2026-05-27", [
"Anti-loop: cross-session tracker, tool-call budget (150), file read-loop detection",
"Auto 401 token refresh with retry",
"Model-aware idle timeout: flash 120s, pro 300s",
"Smart compaction: directive summary on read-loops",
"Default provider policy for unrecognized backends",
"Anti-stall fix: no longer kills own parent/process group",
"Codex Desktop Updater: check/install/rollback/manual rebuild",
"Fix Codex CLI 0.134.0 profiles: separate .config.toml files",
"Fix compaction: max_input_items 60->200 for 1M-token models",
"E2E test suite: bash test-antigravity.sh [--task]",
"Merge cobra91 PR #17: MSIX Desktop launch, button state",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplify model resolution",
"Removed broken schema sanitization, restored correct headers",
"Expanded model alias map for all Antigravity variants",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity provider (PR #13)",
"New antigravity_grpc module with protobuf client",
@@ -708,6 +727,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type):
return {
@@ -1098,10 +1120,9 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n',
main_config = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
@@ -1109,16 +1130,21 @@ def write_config_for_native(endpoint, selected_model):
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
write_secure_text(profile_path, "".join(profile_lines))
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
@@ -1127,10 +1153,9 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n',
main_config = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
@@ -1138,16 +1163,21 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
f'experimental_bearer_token = "codex-launcher-local"\n',
f'\n[profiles."{endpoint["name"]}"]\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n',
]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
write_secure_text(profile_path, "".join(profile_lines))
# ═══════════════════════════════════════════════════════════════════════
# Model fetching
@@ -1705,6 +1735,12 @@ def detect_codex_cli():
def detect_codex_desktop():
"""Detect Codex Desktop installation.
Returns (path_or_aumid, is_msix) tuple on Windows, path string on Linux.
For MSIX installs, returns the AppUserModelId since the exe cannot be
launched directly via subprocess from WindowsApps.
"""
if IS_WINDOWS:
la = os.environ.get("LOCALAPPDATA", "")
pf = os.environ.get("PROGRAMFILES", "")
@@ -1717,8 +1753,8 @@ def detect_codex_desktop():
]
for p in desktop_paths:
if p.exists():
return str(p)
# MSIX / Microsoft Store install: locate via Get-AppxPackage
return str(p), False
# MSIX / Microsoft Store install
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
@@ -1729,13 +1765,70 @@ def detect_codex_desktop():
if loc:
msix_exe = Path(loc) / "app" / "Codex.exe"
if msix_exe.exists():
return str(msix_exe)
r2 = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-AppxPackage *OpenAI.Codex*).PackageFamilyName"],
capture_output=True, text=True, timeout=10,
)
family = r2.stdout.strip() if r2.returncode == 0 else ""
if family:
return f"{family}!App", True
except Exception:
pass
return None
return None, False
if START_SH and START_SH.exists():
return str(START_SH)
return None
return str(START_SH), False
return None, False
def launch_codex_desktop(desktop_info):
"""Launch Codex Desktop process.
Args:
desktop_info: (path_or_aumid, is_msix) tuple from detect_codex_desktop()
Returns:
subprocess.Popen object or None
"""
path, is_msix = desktop_info
if IS_WINDOWS:
if is_msix:
return subprocess.Popen(
["cmd", "/c", "start", "", f"shell:AppsFolder\\{path}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
return subprocess.Popen(
[path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
return subprocess.Popen(
[path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
def is_codex_desktop_running():
"""Check if Codex Desktop (or MSIX Codex) is currently running."""
if IS_WINDOWS:
try:
for name in ("Codex Desktop.exe", "Codex.exe"):
out = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {name}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
)
for line in out.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) >= 2 and parts[1].strip('"').isdigit():
return True
except Exception:
pass
return False
else:
try:
out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
return bool(out.stdout.strip())
except Exception:
return False
def check_codex_auth():
@@ -1778,9 +1871,10 @@ def last_log_lines(n=15):
def kill_existing_desktop(logfn=None):
if IS_WINDOWS:
for img in ("Codex Desktop.exe", "Codex.exe"):
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq Codex Desktop.exe", "/FO", "CSV", "/NH"],
["tasklist", "/FI", f"IMAGENAME eq {img}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
)
for line in out.stdout.strip().splitlines():

View File

@@ -1031,6 +1031,10 @@ def _init_runtime():
TARGET_URL = CONFIG["target_url"].rstrip("/")
API_KEY = CONFIG["api_key"]
OAUTH_PROVIDER = CONFIG.get("oauth_provider") or ""
if not OAUTH_PROVIDER and BACKEND == "gemini-oauth-antigravity":
OAUTH_PROVIDER = "google-antigravity"
if not OAUTH_PROVIDER and BACKEND == "gemini-oauth":
OAUTH_PROVIDER = "google-cli"
MODELS = CONFIG["models"]
CC_VERSION = CONFIG.get("cc_version", "")
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
@@ -2007,10 +2011,10 @@ _PROVIDER_POLICIES = {
"openadapter": {"reasoning_mode": "off", "max_tokens": 32768, "strip_reasoning": True,
"tool_output_limit": 1000, "max_input_items": 10, "compaction": "aggressive",
"synthetic_tool_results": True},
"cloudcode-pa": {"compaction": "aggressive", "context_size": 1000000,
"tool_output_limit": 6000, "max_input_items": 60},
"googleapis": {"compaction": "balanced", "context_size": 1000000,
"tool_output_limit": 6000, "max_input_items": 80},
"cloudcode-pa": {"compaction": "conservative", "context_size": 1000000,
"tool_output_limit": 8000, "max_input_items": 200},
"googleapis": {"compaction": "conservative", "context_size": 1000000,
"tool_output_limit": 8000, "max_input_items": 250},
}
def provider_policy(target_url=None, backend=None):
@@ -5544,6 +5548,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
return chat_body
def _handle_antigravity_v2(self, body, model, stream, tracker=None):
_model_alias = {
"gemini-3.5-flash-high": "gemini-3-flash",
"gemini-3.5-flash-medium": "gemini-3-flash",
"gemini-3.5-flash-low": "gemini-3.5-flash-low",
"gemini-3.5-flash": "gemini-3-flash",
"gemini-3-flash-preview": "gemini-3-flash",
"gemini-3-pro-preview": "gemini-3.1-pro-low",
"gemini-3-pro": "gemini-3.1-pro-low",
"gemini-3-pro-low": "gemini-3.1-pro-low",
"gemini-3-pro-high": "gemini-3.1-pro-low",
"gemini-3.1-pro": "gemini-3.1-pro-low",
"gemini-3.1-pro-high": "gemini-3.1-pro-low",
"claude-sonnet-4.6": "claude-sonnet-4-6",
"claude-sonnet-4.6-thinking": "claude-sonnet-4-6",
"claude-opus-4.6": "claude-opus-4-6-thinking",
"claude-opus-4.6-thinking": "claude-opus-4-6-thinking",
}
_resolved = _model_alias.get(model, model)
if _resolved != model:
print(f"[{getattr(self, '_session_id', '?')}] [antigravity-v2] model resolved: {model} -> {_resolved}", file=sys.stderr)
model = _resolved
input_data = body.get("input", "")
_schema = _load_schema(model=model)
if _schema and not _schema.supports_vision:
@@ -5783,13 +5809,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
_os_arch = _plat.machine().lower().replace("x86_64", "x64").replace("aarch64", "arm64")
_fetched_ver = _ensure_antigravity_version()
_ag_ua = f"antigravity/{_fetched_ver} {_os_name}/{_os_arch}"
# Get platform for Client-Metadata header (repo4/opencode-antigravity-auth)
_client_meta_platform = "WINDOWS" if _os_name == "windows" else "MACOS"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"User-Agent": _ag_ua,
"User-Agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/{_fetched_ver} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36",
"X-Client-Name": "antigravity",
"X-Client-Version": _ensure_antigravity_client_version(),
"x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
"x-goog-api-client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": json.dumps({
"ideType": "ANTIGRAVITY",
"platform": _client_meta_platform,
"pluginType": "GEMINI"
}),
}
wrapped = {
@@ -5802,14 +5836,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
}
wrapped["request"]["sessionId"] = f"{uuid.uuid4().hex}{int(time.time()*1000)}"
_allow_staging = os.environ.get("ALLOW_ANTIGRAVITY_STAGING", "0") == "1"
_antigravity_endpoints = [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
]
if _allow_staging:
_antigravity_endpoints.append("https://autopush-cloudcode-pa.sandbox.googleapis.com")
body_b = json.dumps(wrapped).encode()
print(f"[{self._session_id}] [antigravity-v2] model={model} stream={stream} contents={len(contents)} tools={bool(gemini_tools)} project={project_id} ver={_fetched_ver}", file=sys.stderr)
@@ -5855,7 +5886,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
pass
if e.code == 400:
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}})
if err_class in ("auth_permanent", "service_disabled", "forbidden", "account_banned", "validation_required"):
if err_class in ("auth_permanent", "forbidden", "account_banned", "validation_required"):
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}})
if err_class == "service_disabled":
_is_prod = "cloudcode-pa.googleapis.com" in ep and "sandbox" not in ep
if _is_prod:
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}})
if err_class in ("quota_exhausted", "rate_limited"):
pool = _google_antigravity_pool
@@ -6571,6 +6606,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
headers["X-Client-Name"] = "antigravity"
headers["X-Client-Version"] = _ensure_antigravity_client_version()
headers["x-goog-api-client"] = "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x"
# Add X-Machine-Session-Id header as seen in badrisnarayanan/antigravity-claude-proxy
if "request" in wrapped and "sessionId" in wrapped["request"]:
headers["X-Machine-Session-Id"] = wrapped["request"]["sessionId"]
else:
headers["User-Agent"] = "google-api-nodejs-client/9.15.1"
headers["X-Goog-Api-Client"] = "gl-node/22.17.0"

482
test-antigravity.sh Normal file
View File

@@ -0,0 +1,482 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# test-antigravity.sh — End-to-end Antigravity proxy test + real task
#
# Phases:
# 1. Token validity
# 2. Direct REST endpoint probe
# 3. Proxy adapter (start proxy, test /responses)
# 4. Real Codex CLI task (if --task flag given)
# 5. Anomaly detection + analysis
#
# Usage:
# bash ~/.local/bin/test-antigravity.sh # quick tests
# bash ~/.local/bin/test-antigravity.sh --task # + real CLI task
# bash ~/.local/bin/test-antigravity.sh --verbose # show all logs
# Exit: 0 = all pass, 1 = some fail
# ═══════════════════════════════════════════════════════════════════
set -uo pipefail
VERBOSE=0; RUN_TASK=0
for arg in "$@"; do
case "$arg" in
--verbose|-v) VERBOSE=1 ;;
--task|-t) RUN_TASK=1 ;;
esac
done
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS=0; FAIL=0; SKIP=0; RESULTS=()
log_pass() { echo -e " ${GREEN}PASS${NC} $1"; ((PASS++)); RESULTS+=("PASS $1"); }
log_fail() { echo -e " ${RED}FAIL${NC} $1"; ((FAIL++)); RESULTS+=("FAIL $1"); }
log_skip() { echo -e " ${YELLOW}SKIP${NC} $1"; ((SKIP++)); RESULTS+=("SKIP $1"); }
log_info() { echo -e " ${CYAN}INFO${NC} $1"; }
TOKEN_PATH="$HOME/.cache/codex-proxy/google-antigravity-oauth-token.json"
[ ! -f "$TOKEN_PATH" ] && { echo "ERROR: No token file. Login via GUI first."; exit 1; }
ACCESS_TOKEN=$(python3 -c "
import json, os, sys, time, urllib.request, urllib.parse
tp = os.path.expanduser('~/.cache/codex-proxy/google-antigravity-oauth-token.json')
d = json.load(open(tp))
if d.get('expires_at', 0) > time.time(): print(d['access_token']); sys.exit(0)
cid, cs, rt = d.get('client_id',''), d.get('client_secret',''), d.get('refresh_token','')
if not all([cid, cs, rt]): print('ERROR'); sys.exit(1)
data = urllib.parse.urlencode({'client_id':cid,'client_secret':cs,'refresh_token':rt,'grant_type':'refresh_token'}).encode()
resp = urllib.request.urlopen(urllib.request.Request('https://oauth2.googleapis.com/token', data=data), timeout=15)
tok = json.loads(resp.read()); d.update(tok); d['expires_at'] = time.time() + tok.get('expires_in',3600)
json.dump(d, open(tp,'w')); print(tok.get('access_token','ERROR'))
" 2>&1) || true
[[ "$ACCESS_TOKEN" == ERROR* ]] || [ -z "$ACCESS_TOKEN" ] && { echo "ERROR: Token refresh failed: $ACCESS_TOKEN"; exit 1; }
PROJECT_ID=$(python3 -c "import json; print(json.load(open('$TOKEN_PATH')).get('project_id',''))")
[ -z "$PROJECT_ID" ] && { echo "ERROR: No project_id"; exit 1; }
echo "═══════════════════════════════════════════════════════════════"
echo " Antigravity E2E Test Suite"
echo "═══════════════════════════════════════════════════════════════"
echo " Project: $PROJECT_ID Token: ${ACCESS_TOKEN:0:20}..."
# ── Test 1: Token validity ────────────────────────────────────────
echo ""; echo "─── Test 1: Token Validity ───"
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://www.googleapis.com/oauth2/v1/userinfo" --max-time 5)
[ "$HTTP" = "200" ] && log_pass "Token valid" || log_fail "Token invalid (HTTP $HTTP)"
# ── Test 2: Direct REST probe (prod first, fast timeout) ─────────
echo ""; echo "─── Test 2: Direct REST Endpoint Probe ───"
ENDPOINTS=(
"https://cloudcode-pa.googleapis.com"
"https://daily-cloudcode-pa.sandbox.googleapis.com"
"https://autopush-cloudcode-pa.sandbox.googleapis.com"
)
MODELS=("gemini-3-flash")
BEST_EP=""; BEST_MODEL=""
for model in "${MODELS[@]}"; do
for ep in "${ENDPOINTS[@]}"; do
ep_s=$(echo "$ep" | sed 's|https://||;s|.googleapis.com||')
RESP=$(curl -s -w "\n%{http_code}" -X POST "${ep}/v1internal:generateContent" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/2.0.6 Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36" \
-H 'Client-Metadata: {"ideType":"ANTIGRAVITY","platform":"LINUX","pluginType":"GEMINI"}' \
-d "{\"project\":\"$PROJECT_ID\",\"model\":\"$model\",\"requestType\":\"agent\",\"userAgent\":\"antigravity/2.0.6 linux/x64\",\"requestId\":\"t$(date +%s)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi\"}]}],\"sessionId\":\"t$(date +%s%N)\",\"generationConfig\":{\"maxOutputTokens\":256}}}" \
--connect-timeout 5 --max-time 20 2>&1)
HTTP=$(echo "$RESP" | tail -1); BODY=$(echo "$RESP" | sed '$d')
if [ "$HTTP" = "200" ]; then
TEXT=$(echo "$BODY" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
parts = d.get('response',{}).get('candidates',[{}])[0].get('content',{}).get('parts',[])
texts = [p['text'] for p in parts if 'text' in p and p['text']]
print(' '.join(texts)[:80] if texts else 'EMPTY')
except: print('EMPTY')" 2>/dev/null)
if [ "$TEXT" != "EMPTY" ] && ! echo "$TEXT" | grep -qi "no longer supported"; then
log_pass "$model @ ${ep_s} → \"$TEXT\""
[ -z "$BEST_EP" ] && BEST_EP="$ep" && BEST_MODEL="$model"
else
log_fail "$model @ ${ep_s} → 200 but empty/deprecated"
fi
else
ERR=$(echo "$BODY" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('error',{}).get('status','')[:50])
except: pass" 2>/dev/null)
log_skip "$model @ ${ep_s}$HTTP $ERR"
fi
done
done
# ── Test 3: Proxy adapter (start proxy, test /responses) ──────────
echo ""; echo "─── Test 3: Proxy Adapter (end-to-end) ───"
set +e
TEST_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()")
PROXY_API_KEY="test-$RANDOM"
find /home/roman/.local/bin -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null; true
PROXY_PID=""
export PROXY_PORT=$TEST_PORT
export PROXY_API_KEY=$PROXY_API_KEY
export PROXY_BACKEND=gemini-oauth-antigravity
export PROXY_TARGET_URL=https://cloudcode-pa.googleapis.com
python3 /home/roman/.local/bin/translate-proxy.py >/tmp/antigravity-test-proxy.log 2>&1 &
PROXY_PID=$!
cleanup() { kill $PROXY_PID 2>/dev/null || true; wait $PROXY_PID 2>/dev/null || true; }
trap cleanup EXIT
sleep 3
if ! kill -0 $PROXY_PID 2>/dev/null; then
log_fail "Proxy failed to start (port $TEST_PORT)"
cat /tmp/antigravity-test-proxy.log 2>/dev/null | tail -5
else
log_pass "Proxy started on :$TEST_PORT"
# /v1/models
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $PROXY_API_KEY" \
"http://127.0.0.1:$TEST_PORT/v1/models" --max-time 5)
[ "$HTTP" = "200" ] && log_pass "/v1/models → 200" || log_fail "/v1/models → $HTTP"
# /responses (non-stream)
RESP_HTTP=$(curl -s -w "%{http_code}" -o /tmp/antigravity-test-response.json \
-X POST "http://127.0.0.1:$TEST_PORT/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $PROXY_API_KEY" \
-d '{
"model":"gemini-3.5-flash-high",
"stream":false,
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Say hello in exactly 3 words"}]}],
"tools":[{"type":"function","name":"test_tool","description":"test","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}],
"instructions":"You are a helpful assistant.",
"max_output_tokens":256
}' --connect-timeout 10 --max-time 60 2>&1)
if [ "$RESP_HTTP" = "200" ]; then
TEXT=$(python3 -c "
import json
d = json.load(open('/tmp/antigravity-test-response.json'))
out = d.get('output', [])
texts = []
for item in out:
for p in (item.get('content', []) if isinstance(item, dict) else []):
if isinstance(p, dict): texts.append(p.get('text', ''))
print(' '.join(t for t in texts if t).strip()[:120] or 'EMPTY')
" 2>/dev/null)
if [ "$TEXT" = "EMPTY" ]; then
log_fail "Proxy /responses → 200 but EMPTY"
else
log_pass "Proxy /responses → 200: \"$TEXT\""
fi
else
ERR=$(python3 -c "
import json; d = json.load(open('/tmp/antigravity-test-response.json'))
print(d.get('error',{}).get('message','')[:120])" 2>/dev/null || echo "unknown")
log_fail "Proxy /responses → $RESP_HTTP: $ERR"
fi
# Verify model resolution in logs
if grep -q "model resolved: gemini-3.5-flash-high -> gemini-3-flash" /tmp/antigravity-test-proxy.log; then
log_pass "Model resolution: gemini-3.5-flash-high → gemini-3-flash"
else
log_fail "Model resolution not found in proxy logs"
fi
[ "$VERBOSE" = "1" ] && cat /tmp/antigravity-test-proxy.log
fi
# ── Test 4: Real Codex CLI Task ────────────────────────────────────
if [ "$RUN_TASK" = "1" ]; then
echo ""; echo "─── Test 4: Real Codex CLI Task ───"
if ! command -v codex &>/dev/null; then
log_skip "Codex CLI not found"
else
CLI_VERSION=$(codex --version 2>/dev/null || echo "unknown")
log_info "Codex CLI: $CLI_VERSION"
TASK_PROMPT='Create a file /tmp/e2e-test-output.txt with the text "Hello from Codex CLI E2E test" followed by the current date. Then read it back and confirm the content is correct. This is a simple smoke test.'
TASK_WORKSPACE="/tmp/e2e-test-workspace"
mkdir -p "$TASK_WORKSPACE"
mkdir -p /tmp/antigravity-task-logs
TASK_PROXY_LOG="/tmp/antigravity-task-logs/proxy-$(date +%s).log"
TASK_CLI_LOG="/tmp/antigravity-task-logs/cli-$(date +%s).log"
TASK_MONITOR_LOG="/tmp/antigravity-task-logs/monitor-$(date +%s).log"
# Set up proxy for CLI task (use the one already running on TEST_PORT)
# Write codex profile + config pointing to our test proxy
CONFIG_DIR="$HOME/.codex"
CONFIG_FILE="$CONFIG_DIR/config.toml"
CONFIG_BACKUP="$CONFIG_DIR/config.toml.task-backup"
[ -f "$CONFIG_FILE" ] && cp "$CONFIG_FILE" "$CONFIG_BACKUP"
# Generate model catalog
CATALOG_PATH="$HOME/.cache/codex-proxy/models-Antigravity-Test.json"
python3 -c "
import json, os
models = ['gemini-3.5-flash-high', 'gemini-3.5-flash-medium', 'gemini-3.5-flash-low',
'gemini-3.1-pro-high', 'gemini-3.1-pro-low',
'claude-sonnet-4-6', 'claude-opus-4-6-thinking', 'gpt-oss-120b-medium']
catalog = []
for m in models:
catalog.append({'slug':m,'model':m,'display_name':m,'description':'Antigravity '+m,'hidden':False,'isDefault':m=='gemini-3.5-flash-high','shell_type':'shell_command','visibility':'list','default_reasoning_level':'medium','supported_reasoning_levels':[{'effort':'low','description':'Fast'},{'effort':'medium','description':'Balanced'},{'effort':'high','description':'Deep'}]})
os.makedirs(os.path.dirname('$CATALOG_PATH'), exist_ok=True)
json.dump(catalog, open('$CATALOG_PATH','w'), indent=2)
" || log_fail "Failed to create model catalog"
# Write main config
cat > "$CONFIG_FILE" <<CONFEOF
model = "gemini-3.5-flash-high"
model_provider = "Antigravity Test"
model_catalog_json = "$CATALOG_PATH"
[model_providers."Antigravity Test"]
name = "Antigravity Test"
base_url = "http://127.0.0.1:$TEST_PORT"
experimental_bearer_token = "$PROXY_API_KEY"
wire_api = "responses"
request_max_retries = 1
stream_max_retries = 0
stream_idle_timeout_ms = 600000
[projects."/home/roman/Codex-Launcher-Any-AI-Provider"]
trust_level = "trusted"
CONFEOF
# Write profile file for Codex CLI 0.134.0+
PROFILE_FILE="$CONFIG_DIR/Antigravity-Test.config.toml"
cat > "$PROFILE_FILE" <<PROFEOF
model = "gemini-3.5-flash-high"
model_provider = "Antigravity Test"
model_catalog_json = "$CATALOG_PATH"
service_tier = "fast"
approvals_reviewer = "user"
PROFEOF
log_info "Config written: profile=Antigravity-Test, port=$TEST_PORT"
# ── Anomaly monitor (background) ──
ANOMALY_FOUND=0
(
PROXY_LOG="/tmp/antigravity-test-proxy.log"
START_TIME=$(date +%s)
TIMEOUT_SEC=600
PREV_LINE_COUNT=0
STALL_COUNT=0
LOOP_DETECTOR=""
LOOP_COUNT=0
while true; do
sleep 10
[ ! -f "$PROXY_LOG" ] && continue
NOW=$(date +%s)
ELAPSED=$(( NOW - START_TIME ))
[ "$ELAPSED" -gt "$TIMEOUT_SEC" ] && {
echo "[MONITOR] TIMEOUT: Task exceeded ${TIMEOUT_SEC}s" >> "$TASK_MONITOR_LOG"
break
}
# Check proxy is alive
if ! kill -0 $PROXY_PID 2>/dev/null; then
echo "[MONITOR] FATAL: Proxy process died" >> "$TASK_MONITOR_LOG"
break
fi
# Count lines in proxy log
LINE_COUNT=$(wc -l < "$PROXY_LOG" 2>/dev/null || echo 0)
NEW_LINES=$(( LINE_COUNT - PREV_LINE_COUNT ))
PREV_LINE_COUNT=$LINE_COUNT
# Stall detection: no new log lines for 3 consecutive checks = stalled
if [ "$NEW_LINES" -eq 0 ]; then
STALL_COUNT=$(( STALL_COUNT + 1 ))
if [ "$STALL_COUNT" -ge 18 ]; then
echo "[MONITOR] STALL: No proxy activity for 180s" >> "$TASK_MONITOR_LOG"
fi
else
STALL_COUNT=0
fi
# Loop detection: check if same tool call repeats
RECENT=$(tail -50 "$PROXY_LOG" 2>/dev/null | grep "exec_command" | tail -5 | md5sum | cut -c1-8)
if [ -n "$RECENT" ] && [ "$RECENT" = "$LOOP_DETECTOR" ]; then
LOOP_COUNT=$(( LOOP_COUNT + 1 ))
if [ "$LOOP_COUNT" -ge 6 ]; then
echo "[MONITOR] LOOP: Same tool calls repeating ($LOOP_COUNT times)" >> "$TASK_MONITOR_LOG"
fi
else
LOOP_DETECTOR="$RECENT"
LOOP_COUNT=0
fi
# Check for error patterns
ERRORS=$(tail -100 "$PROXY_LOG" 2>/dev/null | grep -ciE "error|failed|timeout|500|502|503|429" || echo 0)
if [ "$ERRORS" -gt 10 ]; then
echo "[MONITOR] ERRORS: $ERRORS error lines in last 100 log lines" >> "$TASK_MONITOR_LOG"
fi
# Check for compaction issues
COMPACT_LINES=$(tail -200 "$PROXY_LOG" 2>/dev/null | grep -c "compacted\|compaction\|trimming" || echo 0)
if [ "$COMPACT_LINES" -gt 20 ]; then
echo "[MONITOR] COMPACTION: Excessive compaction ($COMPACT_LINES events)" >> "$TASK_MONITOR_LOG"
fi
# Check context item count
HIGH_ITEM=$(tail -200 "$PROXY_LOG" 2>/dev/null | grep -oP '\[\d+\]' | grep -oP '\d+' | sort -rn | head -1 || echo 0)
if [ -n "$HIGH_ITEM" ] && [ "$HIGH_ITEM" -gt 100 ]; then
echo "[MONITOR] CONTEXT: High item count detected: [$HIGH_ITEM]" >> "$TASK_MONITOR_LOG"
fi
# Log heartbeat
echo "[MONITOR] ${ELAPSED}s elapsed, ${LINE_COUNT} log lines, ${NEW_LINES} new, ${ERRORS} errors" >> "$TASK_MONITOR_LOG"
done
) &
MONITOR_PID=$!
# ── Launch Codex CLI with the task ──
log_info "Launching Codex CLI with real task..."
log_info "Task: Create and verify a simple test file"
log_info "Monitor log: $TASK_MONITOR_LOG"
cd "$TASK_WORKSPACE"
set +e
codex exec --profile Antigravity-Test -c "model=gemini-3.5-flash-high" \
-c 'sandbox_permissions=["disk-full-read-access","disk-full-write-access"]' \
"$TASK_PROMPT" \
> "$TASK_CLI_LOG" 2>&1
CLI_EXIT=$?
set -e
# Stop monitor
kill $MONITOR_PID 2>/dev/null || true
wait $MONITOR_PID 2>/dev/null || true
CLI_DURATION=$(wc -l < "$TASK_CLI_LOG" 2>/dev/null || echo 0)
log_info "CLI exited (code $CLI_EXIT, $CLI_DURATION output lines)"
# ── Analyze results ──
echo ""; echo "─── Test 4a: CLI Task Results ───"
if [ "$CLI_EXIT" -eq 0 ]; then
log_pass "CLI task completed successfully"
else
log_fail "CLI task failed (exit code $CLI_EXIT)"
echo " Last 10 lines of CLI output:"
tail -10 "$TASK_CLI_LOG" 2>/dev/null | sed 's/^/ /'
fi
# Check monitor log for anomalies
echo ""; echo "─── Test 4b: Anomaly Analysis ───"
if [ -f "$TASK_MONITOR_LOG" ]; then
ANOMALIES=$(grep -c "\[MONITOR\]" "$TASK_MONITOR_LOG" 2>/dev/null || echo 0)
CRITICAL=$(grep -cE "FATAL|LOOP|TIMEOUT|STALL|ERRORS|COMPACTION|CONTEXT" "$TASK_MONITOR_LOG" 2>/dev/null || echo 0)
log_info "Monitor: $ANOMALIES checks, $CRITICAL anomalies detected"
if [ "$CRITICAL" -gt 0 ]; then
echo -e " ${RED}ANOMALIES FOUND:${NC}"
grep -E "FATAL|LOOP|TIMEOUT|STALL|ERRORS|COMPACTION|CONTEXT" "$TASK_MONITOR_LOG" | while read line; do
echo -e " ${RED}$line${NC}"
done
log_fail "$CRITICAL anomalies detected during task"
else
log_pass "No anomalies detected during task"
fi
[ "$VERBOSE" = "1" ] && cat "$TASK_MONITOR_LOG"
else
log_skip "No monitor log produced"
fi
# Check proxy log for issues
echo ""; echo "─── Test 4c: Proxy Health ───"
if [ -f "/tmp/antigravity-test-proxy.log" ]; then
ERROR_COUNT=$(grep -ciE "error|failed|exception|traceback" /tmp/antigravity-test-proxy.log || echo 0)
TIMEOUT_COUNT=$(grep -ci "timeout\|timed.out" /tmp/antigravity-test-proxy.log || echo 0)
COMPACT_COUNT=$(grep -c "compacted\|compaction" /tmp/antigravity-test-proxy.log || echo 0)
ITEM_COUNT=$(grep -oP '\[\d+\]' /tmp/antigravity-test-proxy.log | grep -oP '\d+' | sort -rn | head -1 || echo 0)
log_info "Proxy errors: $ERROR_COUNT, timeouts: $TIMEOUT_COUNT, compactions: $COMPACT_COUNT, max context items: $ITEM_COUNT"
[ "$ERROR_COUNT" -gt 20 ] && log_fail "High error count: $ERROR_COUNT"
[ "$TIMEOUT_COUNT" -gt 5 ] && log_fail "Timeout count: $TIMEOUT_COUNT"
[ "$ITEM_COUNT" -gt 100 ] && log_fail "Context items grew to: $ITEM_COUNT (compaction may be failing)"
[ "$ITEM_COUNT" -le 100 ] && [ "$ITEM_COUNT" -gt 0 ] && log_pass "Context items stayed under 100 (max: $ITEM_COUNT)"
# Check for repeated identical tool calls (loop detection)
DUPE_CALLS=$(grep "exec_command" /tmp/antigravity-test-proxy.log | sed 's/.*args=//' | sort | uniq -c | sort -rn | head -1 | awk '{print $1}' || echo 0)
if [ "$DUPE_CALLS" -gt 10 ]; then
log_fail "Loop detected: same tool call repeated $DUPE_CALLS times"
else
log_pass "No tool call loops (max repeat: $DUPE_CALLS)"
fi
fi
# Check if the file was actually created
echo ""; echo "─── Test 4d: Task Output Quality ───"
if [ -f "/tmp/e2e-test-output.txt" ]; then
CONTENT=$(cat /tmp/e2e-test-output.txt 2>/dev/null)
if echo "$CONTENT" | grep -q "Hello from Codex CLI E2E test"; then
log_pass "Task output file created with correct content"
else
log_fail "Task output file exists but content is wrong: $CONTENT"
fi
else
log_fail "Task output file /tmp/e2e-test-output.txt was NOT created"
fi
# Check proxy log for tool-strip events (budget cap defense)
echo ""; echo "─── Test 4e: Anti-Loop Defense Verification ───"
if [ -f "/tmp/antigravity-test-proxy.log" ]; then
NULL_TOOL_LOOPS=$(grep -c "NULL-TOOL LOOP" /tmp/antigravity-test-proxy.log || echo 0)
TOOL_STRIPPED=$(grep -c "TOOLS STRIPPED" /tmp/antigravity-test-proxy.log || echo 0)
BUDGET_HIT=$(grep -c "HARD CAP" /tmp/antigravity-test-proxy.log || echo 0)
READ_LOOP=$(grep -c "FILE READ LOOP" /tmp/antigravity-test-proxy.log || echo 0)
FORCE_FINALIZE=$(grep -c "force_finalize" /tmp/antigravity-test-proxy.log || echo 0)
log_info "Anti-loop events: null-tool=$NULL_TOOL_LOOPS stripped=$TOOL_STRIPPED budget=$BUDGET_HIT read-loop=$READ_LOOP finalize=$FORCE_FINALIZE"
# For a simple task, none of these should fire
if [ "$BUDGET_HIT" -gt 0 ]; then
log_fail "Budget cap hit on simple task — model looping"
else
log_pass "No budget cap triggered (task completed cleanly)"
fi
if [ "$TOOL_STRIPPED" -gt 0 ]; then
log_fail "Tools were stripped — model hit hard limit"
else
log_pass "No tool stripping needed (model behaved)"
fi
fi
# Restore original config
[ -f "$CONFIG_BACKUP" ] && mv "$CONFIG_BACKUP" "$CONFIG_FILE"
rm -f "$PROFILE_FILE"
log_info "Config restored"
fi
fi
# ── Summary ───────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo " Results: $PASS passed, $FAIL failed, $SKIP skipped"
echo "═══════════════════════════════════════════════════════════════"
[ -n "$BEST_EP" ] && echo -e " ${GREEN}Best direct:${NC} $BEST_MODEL @ $BEST_EP"
if [ "$FAIL" -gt 0 ]; then
echo -e "\n${RED}FAILED — Do NOT push until all tests pass${NC}"
for r in "${RESULTS[@]}"; do echo "$r" | grep -q "^FAIL" && echo " $r"; done
exit 1
else
echo -e "\n${GREEN}ALL TESTS PASSED — Safe to push${NC}"
exit 0
fi

9323
translate-proxy.py Executable file

File diff suppressed because it is too large Load Diff