v3.13.0: Desktop Updater, profile system fix, Antigravity E2E, conservative compaction
- Codex Desktop Updater: check/install/rollback/service management + manual rebuild - Fix Codex CLI 0.134.0 profiles: separate <slug>.config.toml files - Fix Antigravity: prod endpoint first, model resolution, OAUTH_PROVIDER - Fix compaction: max_input_items 60->200 for 1M-token models - Antigravity E2E test suite: test-antigravity.sh - Windows GUI: UpdateDesktopWindow + profile slug fix - Updated CHANGELOG.md and README.md
This commit is contained in:
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,5 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## v3.12.0 (2026-05-27)
|
||||||
|
|
||||||
**gRPC Auto-Fallback for Antigravity Provider (PR #13)**
|
**gRPC Auto-Fallback for Antigravity Provider (PR #13)**
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ 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
|
- **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
|
- **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
|
- **Auth config-missing fix** (v3.11.6) — graceful handling when Codex config.toml is missing instead of showing raw os error
|
||||||
|
- **Codex Desktop Updater** (v3.13.0) — 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** (v3.13.0) — profiles written to separate `~/.codex/<slug>.config.toml` files for compatibility with Codex CLI 0.134.0+
|
||||||
|
- **Conservative compaction for large models** (v3.13.0) — `max_input_items: 200` for Antigravity's 1M-token models; prevents model from "forgetting" earlier file reads
|
||||||
|
- **Antigravity E2E test suite** (v3.13.0) — `bash ~/.local/bin/test-antigravity.sh` validates token, REST endpoints, proxy adapter, model resolution
|
||||||
- Zero dependencies — pure Python stdlib
|
- Zero dependencies — pure Python stdlib
|
||||||
|
|
||||||
### Command Code Adapter
|
### Command Code Adapter
|
||||||
|
|||||||
@@ -20,12 +20,77 @@ BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
|
|||||||
LOG_DIR = HOME / ".cache/codex-desktop"
|
LOG_DIR = HOME / ".cache/codex-desktop"
|
||||||
LAUNCH_LOG = LOG_DIR / "launcher.log"
|
LAUNCH_LOG = LOG_DIR / "launcher.log"
|
||||||
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
|
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
|
||||||
|
ACTIVE_ENDPOINT_FILE = HOME / ".codex/.active-endpoint.json"
|
||||||
DEFAULT_CONFIG = """model = ""
|
DEFAULT_CONFIG = """model = ""
|
||||||
model_provider = ""
|
model_provider = ""
|
||||||
model_catalog_json = ""
|
model_catalog_json = ""
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
("3.13.0", "2026-05-27", [
|
||||||
|
"Codex Desktop Updater: auto-update from ilysenko/codex-desktop-linux",
|
||||||
|
"Fix Antigravity: prod endpoint first, model resolution, OAUTH_PROVIDER derivation",
|
||||||
|
"Fix Codex CLI 0.134.0 profile system: separate .config.toml files",
|
||||||
|
"Fix compaction: max_input_items 60->200 for 1M-token Antigravity models",
|
||||||
|
"Antigravity E2E test suite: bash 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",
|
||||||
|
"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", [
|
("3.10.4", "2026-05-25", [
|
||||||
"OAuth Secrets editor in GUI — update client ID/secret without editing files",
|
"OAuth Secrets editor in GUI — update client ID/secret without editing files",
|
||||||
"Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
|
"Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
|
||||||
@@ -425,6 +490,9 @@ def safe_name(name):
|
|||||||
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
||||||
return f"{base}-{digest}"
|
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):
|
def label_for_backend(backend_type):
|
||||||
return {
|
return {
|
||||||
"openai-compat": "OpenAI-compatible",
|
"openai-compat": "OpenAI-compatible",
|
||||||
@@ -910,6 +978,27 @@ def restore_config():
|
|||||||
shutil.copy2(str(CONFIG_BAK), str(tmp))
|
shutil.copy2(str(CONFIG_BAK), str(tmp))
|
||||||
os.replace(str(tmp), str(CONFIG))
|
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):
|
def write_secure_text(path, text):
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
@@ -953,23 +1042,29 @@ def write_config_for_native(endpoint, selected_model):
|
|||||||
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\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'\n[model_providers."{endpoint["name"]}"]\n',
|
||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
||||||
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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 = "{_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'service_tier = "default"\n',
|
||||||
f'approvals_reviewer = "user"\n',
|
f'approvals_reviewer = "user"\n',
|
||||||
]
|
]
|
||||||
write_secure_text(CONFIG, "".join(lines))
|
write_secure_text(profile_path, "".join(profile_lines))
|
||||||
|
|
||||||
def _toml_safe(val):
|
def _toml_safe(val):
|
||||||
val = str(val).replace('"', '\\"')
|
val = str(val).replace('"', '\\"')
|
||||||
@@ -988,12 +1083,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 = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'review_model = "{_toml_safe(selected_model)}"\n',
|
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\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'\n[model_providers."{endpoint["name"]}"]\n',
|
||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
||||||
@@ -1002,15 +1097,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
|||||||
f'request_max_retries = 1\n',
|
f'request_max_retries = 1\n',
|
||||||
f'stream_max_retries = 0\n',
|
f'stream_max_retries = 0\n',
|
||||||
f'stream_idle_timeout_ms = 600000\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'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'service_tier = "fast"\n',
|
f'service_tier = "fast"\n',
|
||||||
f'approvals_reviewer = "user"\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):
|
def _gen_model_catalog(endpoint, selected_model=None):
|
||||||
default_model = selected_model or endpoint.get("default_model")
|
default_model = selected_model or endpoint.get("default_model")
|
||||||
@@ -1253,6 +1352,9 @@ def _check_codex_auth():
|
|||||||
if out.returncode == 0 and text:
|
if out.returncode == 0 and text:
|
||||||
return ("logged_in", text)
|
return ("logged_in", text)
|
||||||
if 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 ("error", text)
|
||||||
return ("unknown", "No output from codex login status")
|
return ("unknown", "No output from codex login status")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -1849,6 +1951,7 @@ class LauncherWin(Gtk.Window):
|
|||||||
self._proc = None
|
self._proc = None
|
||||||
self._endpoints_data = load_endpoints()
|
self._endpoints_data = load_endpoints()
|
||||||
recover_config_if_needed()
|
recover_config_if_needed()
|
||||||
|
validate_active_endpoint()
|
||||||
|
|
||||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
self.add(vbox)
|
self.add(vbox)
|
||||||
@@ -1856,7 +1959,7 @@ class LauncherWin(Gtk.Window):
|
|||||||
# header row
|
# header row
|
||||||
hdr = Gtk.Box(spacing=8)
|
hdr = Gtk.Box(spacing=8)
|
||||||
vbox.pack_start(hdr, False, False, 0)
|
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)
|
lbl.set_use_markup(True)
|
||||||
hdr.pack_start(lbl, False, False, 0)
|
hdr.pack_start(lbl, False, False, 0)
|
||||||
changelog_btn = Gtk.Button(label="Changelog")
|
changelog_btn = Gtk.Button(label="Changelog")
|
||||||
@@ -1883,6 +1986,9 @@ class LauncherWin(Gtk.Window):
|
|||||||
oauth_btn = Gtk.Button(label="OAuth Secrets")
|
oauth_btn = Gtk.Button(label="OAuth Secrets")
|
||||||
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
|
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
|
||||||
hdr.pack_end(oauth_btn, False, False, 0)
|
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
|
# verification status bar
|
||||||
self._cli_info = _detect_codex_cli()
|
self._cli_info = _detect_codex_cli()
|
||||||
@@ -2095,6 +2201,8 @@ class LauncherWin(Gtk.Window):
|
|||||||
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
||||||
elif status == "not_installed":
|
elif status == "not_installed":
|
||||||
self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>")
|
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:
|
else:
|
||||||
self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
|
self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
|
||||||
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
||||||
@@ -2331,6 +2439,18 @@ class LauncherWin(Gtk.Window):
|
|||||||
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
|
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
|
||||||
subprocess.Popen([sys.executable, _py], start_new_session=True)
|
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):
|
def _backup_profile(self):
|
||||||
chooser = Gtk.FileChooserDialog(
|
chooser = Gtk.FileChooserDialog(
|
||||||
title="Backup Codex Profile",
|
title="Backup Codex Profile",
|
||||||
@@ -2594,6 +2714,8 @@ class LauncherWin(Gtk.Window):
|
|||||||
begin_config_transaction(f"launch:{ep['name']}")
|
begin_config_transaction(f"launch:{ep['name']}")
|
||||||
write_config_for_native(ep, model)
|
write_config_for_native(ep, model)
|
||||||
|
|
||||||
|
set_active_endpoint(ep["name"])
|
||||||
|
|
||||||
if target == "desktop":
|
if target == "desktop":
|
||||||
if needs_proxy:
|
if needs_proxy:
|
||||||
_kill_existing_desktop(self.log)
|
_kill_existing_desktop(self.log)
|
||||||
@@ -2651,6 +2773,7 @@ class LauncherWin(Gtk.Window):
|
|||||||
|
|
||||||
begin_config_transaction(f"launch:bgp:{pool['name']}")
|
begin_config_transaction(f"launch:bgp:{pool['name']}")
|
||||||
write_config_for_translated(bgp_ep, model, port)
|
write_config_for_translated(bgp_ep, model, port)
|
||||||
|
set_active_endpoint(pool["name"])
|
||||||
|
|
||||||
if target == "desktop":
|
if target == "desktop":
|
||||||
_kill_existing_desktop(self.log)
|
_kill_existing_desktop(self.log)
|
||||||
@@ -2771,7 +2894,7 @@ class LauncherWin(Gtk.Window):
|
|||||||
cmd_parts.extend(["codex", "-c", f"model={model}",
|
cmd_parts.extend(["codex", "-c", f"model={model}",
|
||||||
"-s", sandbox, "-a", approval])
|
"-s", sandbox, "-a", approval])
|
||||||
else:
|
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])
|
"-s", sandbox, "-a", approval])
|
||||||
|
|
||||||
self.log(f"Running: {' '.join(cmd_parts)}")
|
self.log(f"Running: {' '.join(cmd_parts)}")
|
||||||
@@ -4398,10 +4521,54 @@ class EditEndpointDialog(Gtk.Dialog):
|
|||||||
data["default"] = name
|
data["default"] = name
|
||||||
|
|
||||||
save_endpoints(data)
|
save_endpoints(data)
|
||||||
|
self._hot_reload_proxy_key(new_ep)
|
||||||
self._parent_mgr._rebuild()
|
self._parent_mgr._rebuild()
|
||||||
self._parent_mgr._parent._on_endpoints_updated()
|
self._parent_mgr._parent._on_endpoints_updated()
|
||||||
self.destroy()
|
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):
|
def _show_error(self, msg):
|
||||||
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
|
||||||
d.run(); d.destroy()
|
d.run(); d.destroy()
|
||||||
@@ -5722,5 +5889,510 @@ class BenchmarkWindow(Gtk.Window):
|
|||||||
|
|
||||||
GLib.idle_add(_show)
|
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 & 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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
BIN
codex-launcher_3.13.0_all.deb
Normal file
BIN
codex-launcher_3.13.0_all.deb
Normal file
Binary file not shown.
@@ -83,6 +83,109 @@ model_catalog_json = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
("3.13.0", "2026-05-27", [
|
||||||
|
"Codex Desktop Updater: auto-update from ilysenko/codex-desktop-linux",
|
||||||
|
"Fix Antigravity: prod endpoint first, model resolution, OAUTH_PROVIDER derivation",
|
||||||
|
"Fix Codex CLI 0.134.0 profile system: separate .config.toml files",
|
||||||
|
"Fix compaction: max_input_items 60->200 for 1M-token Antigravity models",
|
||||||
|
"Antigravity E2E test suite: bash test-antigravity.sh",
|
||||||
|
]),
|
||||||
|
("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", [
|
("3.10.9", "2026-05-25", [
|
||||||
"Antigravity: production-only endpoints (cloudcode-pa.googleapis.com), sandbox blocked unless ALLOW_ANTIGRAVITY_STAGING=1",
|
"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)",
|
"Antigravity: 403 SERVICE_DISABLED falls through, 429 returns to client (no sandbox fallback)",
|
||||||
@@ -618,6 +721,9 @@ def safe_name(name):
|
|||||||
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
||||||
return f"{base}-{digest}"
|
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):
|
def label_for_backend(backend_type):
|
||||||
return {
|
return {
|
||||||
@@ -1008,10 +1114,9 @@ def write_config_for_native(endpoint, selected_model):
|
|||||||
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
||||||
|
|
||||||
mc_str = str(mc_path).replace("\\", "/")
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
@@ -1019,16 +1124,21 @@ def write_config_for_native(endpoint, selected_model):
|
|||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
||||||
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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 = "{_toml_safe(selected_model)}"\n',
|
||||||
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
f'service_tier = "default"\n',
|
f'service_tier = "default"\n',
|
||||||
f'approvals_reviewer = "user"\n',
|
f'approvals_reviewer = "user"\n',
|
||||||
]
|
]
|
||||||
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
write_secure_text(profile_path, "".join(profile_lines))
|
||||||
merged = _merge_toml(existing, "".join(new_config))
|
|
||||||
write_secure_text(CONFIG, merged)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
||||||
@@ -1037,10 +1147,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 = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
||||||
|
|
||||||
mc_str = str(mc_path).replace("\\", "/")
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
@@ -1048,16 +1157,21 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
|||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
||||||
f'experimental_bearer_token = "codex-launcher-local"\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 = "{_toml_safe(selected_model)}"\n',
|
||||||
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
f'service_tier = "fast"\n',
|
f'service_tier = "fast"\n',
|
||||||
f'approvals_reviewer = "user"\n',
|
f'approvals_reviewer = "user"\n',
|
||||||
]
|
]
|
||||||
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
write_secure_text(profile_path, "".join(profile_lines))
|
||||||
merged = _merge_toml(existing, "".join(new_config))
|
|
||||||
write_secure_text(CONFIG, merged)
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# Model fetching
|
# Model fetching
|
||||||
@@ -1442,6 +1556,7 @@ def _pick_free_port():
|
|||||||
try:
|
try:
|
||||||
saved = int(_PROXY_PORT_FILE.read_text().strip())
|
saved = int(_PROXY_PORT_FILE.read_text().strip())
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
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))
|
s.bind(("127.0.0.1", saved))
|
||||||
return saved
|
return saved
|
||||||
except (ValueError, OSError, FileNotFoundError):
|
except (ValueError, OSError, FileNotFoundError):
|
||||||
@@ -1533,11 +1648,19 @@ def _start_proxy_with_config(pcfg_path, port, logfn):
|
|||||||
)
|
)
|
||||||
_register_pgid_entry("proxy", _proxy_proc.pid)
|
_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():
|
def _pipe_stderr():
|
||||||
if not _proxy_proc.stderr:
|
if not _proxy_proc.stderr:
|
||||||
return
|
return
|
||||||
for line in _proxy_proc.stderr:
|
for line in _proxy_proc.stderr:
|
||||||
logfn(f"[proxy] {line.rstrip()}")
|
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()
|
threading.Thread(target=_pipe_stderr, daemon=True).start()
|
||||||
|
|
||||||
@@ -1655,6 +1778,10 @@ def check_codex_auth():
|
|||||||
return ("unknown", "No output from codex login status")
|
return ("unknown", "No output from codex login status")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return ("not_installed", "codex not found")
|
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:
|
except Exception as e:
|
||||||
return ("error", str(e))
|
return ("error", str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ model_catalog_json = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
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", [
|
("3.12.1", "2026-05-27", [
|
||||||
"Fix Antigravity adapter (PR #15): simplified model resolution",
|
"Fix Antigravity adapter (PR #15): simplified model resolution",
|
||||||
"Removed broken schema sanitization, restored headers",
|
"Removed broken schema sanitization, restored headers",
|
||||||
@@ -483,6 +492,9 @@ def safe_name(name):
|
|||||||
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
||||||
return f"{base}-{digest}"
|
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):
|
def label_for_backend(backend_type):
|
||||||
return {
|
return {
|
||||||
"openai-compat": "OpenAI-compatible",
|
"openai-compat": "OpenAI-compatible",
|
||||||
@@ -1032,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 = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\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'\n[model_providers."{endpoint["name"]}"]\n',
|
||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
||||||
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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 = "{_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'service_tier = "default"\n',
|
||||||
f'approvals_reviewer = "user"\n',
|
f'approvals_reviewer = "user"\n',
|
||||||
]
|
]
|
||||||
write_secure_text(CONFIG, "".join(lines))
|
write_secure_text(profile_path, "".join(profile_lines))
|
||||||
|
|
||||||
def _toml_safe(val):
|
def _toml_safe(val):
|
||||||
val = str(val).replace('"', '\\"')
|
val = str(val).replace('"', '\\"')
|
||||||
@@ -1067,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 = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'review_model = "{_toml_safe(selected_model)}"\n',
|
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\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'\n[model_providers."{endpoint["name"]}"]\n',
|
||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
||||||
@@ -1081,15 +1099,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
|||||||
f'request_max_retries = 1\n',
|
f'request_max_retries = 1\n',
|
||||||
f'stream_max_retries = 0\n',
|
f'stream_max_retries = 0\n',
|
||||||
f'stream_idle_timeout_ms = 600000\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'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'service_tier = "fast"\n',
|
f'service_tier = "fast"\n',
|
||||||
f'approvals_reviewer = "user"\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):
|
def _gen_model_catalog(endpoint, selected_model=None):
|
||||||
default_model = selected_model or endpoint.get("default_model")
|
default_model = selected_model or endpoint.get("default_model")
|
||||||
@@ -1966,6 +1988,9 @@ class LauncherWin(Gtk.Window):
|
|||||||
oauth_btn = Gtk.Button(label="OAuth Secrets")
|
oauth_btn = Gtk.Button(label="OAuth Secrets")
|
||||||
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
|
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
|
||||||
hdr.pack_end(oauth_btn, False, False, 0)
|
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
|
# verification status bar
|
||||||
self._cli_info = _detect_codex_cli()
|
self._cli_info = _detect_codex_cli()
|
||||||
@@ -2416,6 +2441,18 @@ class LauncherWin(Gtk.Window):
|
|||||||
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
|
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
|
||||||
subprocess.Popen([sys.executable, _py], start_new_session=True)
|
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):
|
def _backup_profile(self):
|
||||||
chooser = Gtk.FileChooserDialog(
|
chooser = Gtk.FileChooserDialog(
|
||||||
title="Backup Codex Profile",
|
title="Backup Codex Profile",
|
||||||
@@ -2859,7 +2896,7 @@ class LauncherWin(Gtk.Window):
|
|||||||
cmd_parts.extend(["codex", "-c", f"model={model}",
|
cmd_parts.extend(["codex", "-c", f"model={model}",
|
||||||
"-s", sandbox, "-a", approval])
|
"-s", sandbox, "-a", approval])
|
||||||
else:
|
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])
|
"-s", sandbox, "-a", approval])
|
||||||
|
|
||||||
self.log(f"Running: {' '.join(cmd_parts)}")
|
self.log(f"Running: {' '.join(cmd_parts)}")
|
||||||
@@ -5854,5 +5891,510 @@ class BenchmarkWindow(Gtk.Window):
|
|||||||
|
|
||||||
GLib.idle_add(_show)
|
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 & 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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from codex_launcher_lib import (
|
|||||||
PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH,
|
PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH,
|
||||||
ANTIGRAVITY_MODELS,
|
ANTIGRAVITY_MODELS,
|
||||||
safe_name, label_for_backend, normalize_model_id, normalize_base_url,
|
safe_name, label_for_backend, normalize_model_id, normalize_base_url,
|
||||||
|
_profile_slug,
|
||||||
parse_model_list, now_utc_iso, apply_provider_preset,
|
parse_model_list, now_utc_iso, apply_provider_preset,
|
||||||
load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools,
|
load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools,
|
||||||
get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle,
|
get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle,
|
||||||
@@ -2073,6 +2074,164 @@ class BenchmarkWindow:
|
|||||||
self._dlg.after(0, _show)
|
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
|
# 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="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="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="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
|
# Detection status — one row per item so long paths don't truncate
|
||||||
self._cli_info = detect_codex_cli()
|
self._cli_info = detect_codex_cli()
|
||||||
@@ -2407,6 +2567,9 @@ class LauncherWin:
|
|||||||
def _open_benchmark(self):
|
def _open_benchmark(self):
|
||||||
BenchmarkWindow(self._root)
|
BenchmarkWindow(self._root)
|
||||||
|
|
||||||
|
def _open_updater(self):
|
||||||
|
UpdateDesktopWindow(self._root)
|
||||||
|
|
||||||
def _open_proxy_log_dir(self):
|
def _open_proxy_log_dir(self):
|
||||||
log_dir = str(PROXY_CONFIG_DIR)
|
log_dir = str(PROXY_CONFIG_DIR)
|
||||||
req_log = PROXY_CONFIG_DIR / "requests.log"
|
req_log = PROXY_CONFIG_DIR / "requests.log"
|
||||||
@@ -3184,7 +3347,7 @@ class LauncherWin:
|
|||||||
if ep["backend_type"] == "native":
|
if ep["backend_type"] == "native":
|
||||||
cmd_parts.extend(["codex", "-c", f"model={model}"])
|
cmd_parts.extend(["codex", "-c", f"model={model}"])
|
||||||
else:
|
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)}")
|
self.log(f"Running: {' '.join(cmd_parts)}")
|
||||||
if IS_WINDOWS:
|
if IS_WINDOWS:
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ model_catalog_json = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
("3.13.0", "2026-05-27", [
|
||||||
|
"Codex Desktop Updater: auto-update from ilysenko/codex-desktop-linux",
|
||||||
|
"Fix Antigravity: prod endpoint first, model resolution, OAUTH_PROVIDER derivation",
|
||||||
|
"Fix Codex CLI 0.134.0 profile system: separate .config.toml files",
|
||||||
|
"Fix compaction: max_input_items 60->200 for 1M-token Antigravity models",
|
||||||
|
"Antigravity E2E test suite: bash test-antigravity.sh",
|
||||||
|
]),
|
||||||
("3.12.1", "2026-05-27", [
|
("3.12.1", "2026-05-27", [
|
||||||
"Fix Antigravity adapter (PR #15): simplify model resolution",
|
"Fix Antigravity adapter (PR #15): simplify model resolution",
|
||||||
"Removed broken schema sanitization, restored correct headers",
|
"Removed broken schema sanitization, restored correct headers",
|
||||||
@@ -714,6 +721,9 @@ def safe_name(name):
|
|||||||
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
||||||
return f"{base}-{digest}"
|
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):
|
def label_for_backend(backend_type):
|
||||||
return {
|
return {
|
||||||
@@ -1104,10 +1114,9 @@ def write_config_for_native(endpoint, selected_model):
|
|||||||
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
||||||
|
|
||||||
mc_str = str(mc_path).replace("\\", "/")
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
@@ -1115,16 +1124,21 @@ def write_config_for_native(endpoint, selected_model):
|
|||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
||||||
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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 = "{_toml_safe(selected_model)}"\n',
|
||||||
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
f'service_tier = "default"\n',
|
f'service_tier = "default"\n',
|
||||||
f'approvals_reviewer = "user"\n',
|
f'approvals_reviewer = "user"\n',
|
||||||
]
|
]
|
||||||
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
write_secure_text(profile_path, "".join(profile_lines))
|
||||||
merged = _merge_toml(existing, "".join(new_config))
|
|
||||||
write_secure_text(CONFIG, merged)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
||||||
@@ -1133,10 +1147,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 = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||||
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
||||||
|
|
||||||
mc_str = str(mc_path).replace("\\", "/")
|
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 = "{_toml_safe(selected_model)}"\n',
|
||||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
@@ -1144,16 +1157,21 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
|||||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
||||||
f'experimental_bearer_token = "codex-launcher-local"\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 = "{_toml_safe(selected_model)}"\n',
|
||||||
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||||
f'model_catalog_json = "{mc_str}"\n',
|
f'model_catalog_json = "{mc_str}"\n',
|
||||||
f'service_tier = "fast"\n',
|
f'service_tier = "fast"\n',
|
||||||
f'approvals_reviewer = "user"\n',
|
f'approvals_reviewer = "user"\n',
|
||||||
]
|
]
|
||||||
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
write_secure_text(profile_path, "".join(profile_lines))
|
||||||
merged = _merge_toml(existing, "".join(new_config))
|
|
||||||
write_secure_text(CONFIG, merged)
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# Model fetching
|
# Model fetching
|
||||||
|
|||||||
@@ -1031,6 +1031,10 @@ def _init_runtime():
|
|||||||
TARGET_URL = CONFIG["target_url"].rstrip("/")
|
TARGET_URL = CONFIG["target_url"].rstrip("/")
|
||||||
API_KEY = CONFIG["api_key"]
|
API_KEY = CONFIG["api_key"]
|
||||||
OAUTH_PROVIDER = CONFIG.get("oauth_provider") or ""
|
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"]
|
MODELS = CONFIG["models"]
|
||||||
CC_VERSION = CONFIG.get("cc_version", "")
|
CC_VERSION = CONFIG.get("cc_version", "")
|
||||||
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
|
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
|
||||||
@@ -2007,10 +2011,10 @@ _PROVIDER_POLICIES = {
|
|||||||
"openadapter": {"reasoning_mode": "off", "max_tokens": 32768, "strip_reasoning": True,
|
"openadapter": {"reasoning_mode": "off", "max_tokens": 32768, "strip_reasoning": True,
|
||||||
"tool_output_limit": 1000, "max_input_items": 10, "compaction": "aggressive",
|
"tool_output_limit": 1000, "max_input_items": 10, "compaction": "aggressive",
|
||||||
"synthetic_tool_results": True},
|
"synthetic_tool_results": True},
|
||||||
"cloudcode-pa": {"compaction": "aggressive", "context_size": 1000000,
|
"cloudcode-pa": {"compaction": "conservative", "context_size": 1000000,
|
||||||
"tool_output_limit": 6000, "max_input_items": 60},
|
"tool_output_limit": 8000, "max_input_items": 200},
|
||||||
"googleapis": {"compaction": "balanced", "context_size": 1000000,
|
"googleapis": {"compaction": "conservative", "context_size": 1000000,
|
||||||
"tool_output_limit": 6000, "max_input_items": 80},
|
"tool_output_limit": 8000, "max_input_items": 250},
|
||||||
}
|
}
|
||||||
|
|
||||||
def provider_policy(target_url=None, backend=None):
|
def provider_policy(target_url=None, backend=None):
|
||||||
@@ -5544,6 +5548,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
return chat_body
|
return chat_body
|
||||||
|
|
||||||
def _handle_antigravity_v2(self, body, model, stream, tracker=None):
|
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", "")
|
input_data = body.get("input", "")
|
||||||
_schema = _load_schema(model=model)
|
_schema = _load_schema(model=model)
|
||||||
if _schema and not _schema.supports_vision:
|
if _schema and not _schema.supports_vision:
|
||||||
@@ -5810,11 +5836,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
}
|
}
|
||||||
wrapped["request"]["sessionId"] = f"{uuid.uuid4().hex}{int(time.time()*1000)}"
|
wrapped["request"]["sessionId"] = f"{uuid.uuid4().hex}{int(time.time()*1000)}"
|
||||||
|
|
||||||
# Use endpoint order from repo4/opencode-antigravity-auth: daily sandbox → autopush sandbox → prod
|
|
||||||
_antigravity_endpoints = [
|
_antigravity_endpoints = [
|
||||||
|
"https://cloudcode-pa.googleapis.com",
|
||||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||||
"https://cloudcode-pa.googleapis.com",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
body_b = json.dumps(wrapped).encode()
|
body_b = json.dumps(wrapped).encode()
|
||||||
@@ -5861,7 +5886,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
pass
|
pass
|
||||||
if e.code == 400:
|
if e.code == 400:
|
||||||
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}})
|
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)}})
|
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}})
|
||||||
if err_class in ("quota_exhausted", "rate_limited"):
|
if err_class in ("quota_exhausted", "rate_limited"):
|
||||||
pool = _google_antigravity_pool
|
pool = _google_antigravity_pool
|
||||||
|
|||||||
188
test-antigravity.sh
Normal file
188
test-antigravity.sh
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# test-antigravity.sh — End-to-end Antigravity proxy test
|
||||||
|
#
|
||||||
|
# Tests: token validity → direct REST probe → proxy adapter
|
||||||
|
#
|
||||||
|
# Usage: bash ~/.local/bin/test-antigravity.sh [--verbose]
|
||||||
|
# Exit: 0 = all pass, 1 = some fail
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERBOSE=0
|
||||||
|
for arg in "$@"; do case "$arg" in --verbose|-v) VERBOSE=1 ;; esac; done
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; 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"); }
|
||||||
|
|
||||||
|
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) ───"
|
||||||
|
|
||||||
|
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=""
|
||||||
|
PROXY_PORT=$TEST_PORT PROXY_API_KEY=$PROXY_API_KEY PROXY_BACKEND=gemini-oauth-antigravity \
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── 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
|
||||||
8505
translate-proxy.py
Executable file
8505
translate-proxy.py
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user