v3.11.0: merge cobra PR, smart-continue, hot-reload, XML extraction
- Merge PR #5 from cobra91: concurrency semaphore, auto-continue, SO_REUSEADDR, proxy-stderr.log, stream diagnostics, timeout handler, restart proxy fix - Tool call argument normalizer, smart-continue loop, XML extraction - API key hot-reload with mtime tracking + /admin/ endpoints - GUI hot-reload on endpoint edit with upstream verification - Synthetic tool-results disabled (caused deepseek-v4-pro truncation) - Version bump 3.10.12 -> 3.11.0, rebuild .deb
This commit is contained in:
@@ -26,6 +26,19 @@ model_catalog_json = ""
|
||||
"""
|
||||
|
||||
CHANGELOG = [
|
||||
("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 <tool_call> patterns from text, injects as real calls",
|
||||
"Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
|
||||
"API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
|
||||
"GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
|
||||
"Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
|
||||
]),
|
||||
("3.10.4", "2026-05-25", [
|
||||
"OAuth Secrets editor in GUI — update client ID/secret without editing files",
|
||||
"Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
|
||||
@@ -361,7 +374,7 @@ PROVIDER_PRESETS = {
|
||||
},
|
||||
"Google Antigravity (OAuth)": {
|
||||
"backend_type": "gemini-oauth-antigravity",
|
||||
"base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"base_url": "https://cloudcode-pa.googleapis.com",
|
||||
"oauth_provider": "google-antigravity",
|
||||
"models": [
|
||||
"Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
|
||||
@@ -1782,6 +1795,64 @@ class AIMonitoringWindow(Gtk.Window):
|
||||
# Main window
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _oauth_discover_project(access_token, token_path, tokens):
|
||||
project_id = ""
|
||||
try:
|
||||
lr = urllib.request.Request(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
data=json.dumps({}).encode(),
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1"})
|
||||
lresp = urllib.request.urlopen(lr, timeout=15)
|
||||
ldata = json.loads(lresp.read())
|
||||
p = ldata.get("cloudaicompanionProject", "")
|
||||
if isinstance(p, dict):
|
||||
project_id = p.get("id", "")
|
||||
elif isinstance(p, str):
|
||||
project_id = p
|
||||
except Exception:
|
||||
pass
|
||||
if not project_id:
|
||||
return ""
|
||||
try:
|
||||
test_url = f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={project_id}"
|
||||
test_req = urllib.request.Request(test_url,
|
||||
headers={"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1"})
|
||||
urllib.request.urlopen(test_req, timeout=10)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 403 and "SERVICE_DISABLED" in (e.read().decode()[:500]):
|
||||
print(f"[oauth] project {project_id} has API disabled, searching for valid project...", file=sys.stderr)
|
||||
try:
|
||||
list_req = urllib.request.Request(
|
||||
"https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
|
||||
headers={"Authorization": f"Bearer {access_token}"})
|
||||
list_resp = urllib.request.urlopen(list_req, timeout=15)
|
||||
projects = json.loads(list_resp.read()).get("projects", [])
|
||||
for proj in projects:
|
||||
pid = proj.get("projectId", "")
|
||||
if not pid or pid == project_id:
|
||||
continue
|
||||
try:
|
||||
t2 = urllib.request.Request(
|
||||
f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={pid}",
|
||||
headers={"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1"})
|
||||
urllib.request.urlopen(t2, timeout=10)
|
||||
project_id = pid
|
||||
print(f"[oauth] found working project: {pid}", file=sys.stderr)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
tokens["project_id"] = project_id
|
||||
with open(token_path, "w") as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
return project_id
|
||||
|
||||
class LauncherWin(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Codex Launcher")
|
||||
@@ -1798,7 +1869,7 @@ class LauncherWin(Gtk.Window):
|
||||
# header row
|
||||
hdr = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(hdr, False, False, 0)
|
||||
lbl = Gtk.Label(label="<b>Codex Launcher v3.10.7</b>")
|
||||
lbl = Gtk.Label(label="<b>Codex Launcher v3.10.9</b>")
|
||||
lbl.set_use_markup(True)
|
||||
hdr.pack_start(lbl, False, False, 0)
|
||||
changelog_btn = Gtk.Button(label="Changelog")
|
||||
@@ -2832,63 +2903,163 @@ class LauncherWin(Gtk.Window):
|
||||
_stop_proxy()
|
||||
Gtk.main_quit()
|
||||
|
||||
def _google_reoauth(self, provider):
|
||||
secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
|
||||
try:
|
||||
with open(secrets_path) as f:
|
||||
secrets = json.load(f)
|
||||
except Exception:
|
||||
secrets = {}
|
||||
def _google_reoauth(self, provider, parent_dlg=None):
|
||||
import http.server
|
||||
is_antigravity = provider == "google-antigravity"
|
||||
sec_key = "antigravity" if is_antigravity else "gemini_cli"
|
||||
sec = secrets.get(sec_key, {})
|
||||
client_id = sec.get("client_id", "")
|
||||
client_secret = sec.get("client_secret", "")
|
||||
if not client_id or not client_secret:
|
||||
_sp = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
|
||||
try:
|
||||
with open(_sp) as _f:
|
||||
_secrets_data = json.load(_f)
|
||||
except Exception:
|
||||
_secrets_data = {}
|
||||
sec = _secrets_data.get(sec_key, {})
|
||||
CLIENT_ID = sec.get("client_id", "")
|
||||
CLIENT_SECRET = sec.get("client_secret", "")
|
||||
if not CLIENT_ID or not CLIENT_SECRET:
|
||||
self._show_error_dialog("Missing OAuth secrets",
|
||||
f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.")
|
||||
return
|
||||
token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"
|
||||
token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_file}")
|
||||
redirect = "urn:ietf:wg:oauth:2.0:oob"
|
||||
auth_url = (f"https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}"
|
||||
f"&redirect_uri={urllib.parse.quote(redirect)}"
|
||||
f"&response_type=code&scope={urllib.parse.quote('https://www.googleapis.com/auth/cloud-platform')}"
|
||||
f"&access_type=offline&prompt=consent")
|
||||
webbrowser.open(auth_url)
|
||||
code_dlg = Gtk.Dialog(title=f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}", parent=self, modal=True)
|
||||
code_dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||
code_dlg.add_button("Exchange", Gtk.ResponseType.OK)
|
||||
code_dlg.set_default_size(500, 180)
|
||||
ca = code_dlg.get_content_area()
|
||||
provider_kind = "antigravity" if is_antigravity else "cli"
|
||||
|
||||
if is_antigravity:
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
]
|
||||
port = 51121
|
||||
redirect_uri = f"http://localhost:{port}/oauth-callback"
|
||||
callback_path = "/oauth-callback"
|
||||
else:
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
redirect_uri = f"http://127.0.0.1:{port}/oauth2callback"
|
||||
callback_path = "/oauth2callback"
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
|
||||
scope_str = " ".join(SCOPES)
|
||||
auth_url = (
|
||||
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
||||
f"client_id={CLIENT_ID}"
|
||||
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
|
||||
f"&response_type=code"
|
||||
f"&scope={urllib.parse.quote(scope_str)}"
|
||||
f"&access_type=offline"
|
||||
f"&prompt=select_account%20consent"
|
||||
f"&state={state}"
|
||||
f"&code_challenge={challenge}"
|
||||
f"&code_challenge_method=S256"
|
||||
)
|
||||
|
||||
oauth_dlg = Gtk.Dialog(title=f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}", parent=parent_dlg or self, modal=True)
|
||||
oauth_dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||
oauth_dlg.set_default_size(520, 200)
|
||||
ca = oauth_dlg.get_content_area()
|
||||
ca.set_margin_start(12)
|
||||
ca.set_margin_end(12)
|
||||
ca.set_spacing(6)
|
||||
ca.pack_start(Gtk.Label(label="Browser opened for Google OAuth.\nPaste the authorization code below:", xalign=0), False, False, 0)
|
||||
code_entry = Gtk.Entry()
|
||||
code_entry.set_placeholder_text("4/0AX...")
|
||||
ca.pack_start(code_entry, False, False, 4)
|
||||
ca.pack_start(Gtk.Label(label=f"<b>Re-authenticating {'Antigravity' if is_antigravity else 'Gemini CLI'}</b>", use_markup=True, xalign=0), False, False, 0)
|
||||
link_lbl = Gtk.Label(label="Click here to open Google authorization", use_markup=True, xalign=0)
|
||||
link_lbl.set_markup(f'<a href="{auth_url}">Click here to open Google authorization</a>')
|
||||
ca.pack_start(link_lbl, False, False, 4)
|
||||
status_lbl = Gtk.Label(label="Waiting for browser callback...", xalign=0)
|
||||
ca.pack_start(status_lbl, False, False, 4)
|
||||
ca.show_all()
|
||||
if code_dlg.run() == Gtk.ResponseType.OK:
|
||||
code = code_entry.get_text().strip()
|
||||
if code:
|
||||
|
||||
code_holder = [None]
|
||||
error_holder = [None]
|
||||
|
||||
class OAuthHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self2):
|
||||
qs = urllib.parse.urlparse(self2.path).query
|
||||
params = urllib.parse.parse_qs(qs)
|
||||
if "code" in params:
|
||||
if params.get("state", [None])[0] != state:
|
||||
self2.send_response(400)
|
||||
self2.end_headers()
|
||||
self2.wfile.write(b"CSRF state mismatch")
|
||||
error_holder[0] = "CSRF state mismatch"
|
||||
return
|
||||
code_holder[0] = params["code"][0]
|
||||
self2.send_response(302)
|
||||
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
|
||||
self2.end_headers()
|
||||
else:
|
||||
error_holder[0] = params.get("error", ["unknown"])[0]
|
||||
self2.send_response(302)
|
||||
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
|
||||
self2.end_headers()
|
||||
def log_message(self2, fmt, *args):
|
||||
pass
|
||||
|
||||
try:
|
||||
bind_host = "localhost" if is_antigravity else "127.0.0.1"
|
||||
server = http.server.HTTPServer((bind_host, port), OAuthHandler)
|
||||
except OSError:
|
||||
status_lbl.set_text(f"Port {port} in use — close other apps and retry.")
|
||||
oauth_dlg.run()
|
||||
oauth_dlg.destroy()
|
||||
return
|
||||
|
||||
def _wait():
|
||||
deadline = time.time() + 120
|
||||
while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
|
||||
server.handle_request()
|
||||
server.server_close()
|
||||
if code_holder[0]:
|
||||
try:
|
||||
tok_req = urllib.request.Request("https://oauth2.googleapis.com/token",
|
||||
data=urllib.parse.urlencode({
|
||||
"code": code, "client_id": client_id, "client_secret": client_secret,
|
||||
"redirect_uri": redirect, "grant_type": "authorization_code"
|
||||
}).encode(),
|
||||
tok_data = urllib.parse.urlencode({
|
||||
"code": code_holder[0], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri, "grant_type": "authorization_code",
|
||||
"code_verifier": verifier,
|
||||
}).encode()
|
||||
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=tok_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
tok_resp = urllib.request.urlopen(tok_req, timeout=30)
|
||||
tok_data = json.loads(tok_resp.read())
|
||||
tok_data["_updated"] = time.time()
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
tokens = json.loads(resp.read())
|
||||
tokens["client_id"] = CLIENT_ID
|
||||
tokens["client_secret"] = CLIENT_SECRET
|
||||
tokens["provider_kind"] = provider_kind
|
||||
tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
|
||||
os.makedirs(os.path.dirname(token_path), exist_ok=True)
|
||||
with open(token_path, "w") as f:
|
||||
json.dump(tok_data, f, indent=2)
|
||||
self._log(f"[oauth] Refreshed {provider} token → {token_path}")
|
||||
json.dump(tokens, f, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens)
|
||||
def _on_success():
|
||||
status_lbl.set_text(f"Authorization successful! Project: {project_id or 'none'}")
|
||||
GLib.timeout_add_seconds(2, lambda: oauth_dlg.destroy())
|
||||
return False
|
||||
GLib.idle_add(_on_success)
|
||||
except Exception as e:
|
||||
self._show_error_dialog("Token exchange failed", str(e)[:300])
|
||||
code_dlg.destroy()
|
||||
def _on_err(exc=str(e)):
|
||||
status_lbl.set_text(f"Token exchange failed: {exc[:200]}")
|
||||
return False
|
||||
GLib.idle_add(_on_err)
|
||||
else:
|
||||
def _on_fail(err=error_holder[0]):
|
||||
status_lbl.set_text(f"Failed: {err or 'No code received'}")
|
||||
return False
|
||||
GLib.idle_add(_on_fail)
|
||||
|
||||
webbrowser.open(auth_url)
|
||||
threading.Thread(target=_wait, daemon=True).start()
|
||||
oauth_dlg.run()
|
||||
oauth_dlg.destroy()
|
||||
|
||||
def _codebuff_reoauth(self):
|
||||
self._codebuff_oauth_standalone()
|
||||
@@ -3019,7 +3190,7 @@ class LauncherWin(Gtk.Window):
|
||||
hdr_row.pack_start(Gtk.Label(label=f"\n<b>{section_label}</b>", use_markup=True, xalign=0), True, True, 0)
|
||||
reauth_btn = Gtk.Button(label="Re-OAuth")
|
||||
reauth_btn.set_size_request(80, -1)
|
||||
reauth_btn.connect("clicked", lambda b, p=oauth_prov: self._google_reoauth(p))
|
||||
reauth_btn.connect("clicked", lambda b, p=oauth_prov: self._google_reoauth(p, dlg))
|
||||
hdr_row.pack_end(reauth_btn, False, False, 0)
|
||||
import_btn = Gtk.Button(label="Import JSON")
|
||||
import_btn.set_size_request(100, -1)
|
||||
@@ -3868,32 +4039,8 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
json.dump(tokens, f, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
_oauth_log(f"Token saved to {token_path}")
|
||||
project_id = ""
|
||||
try:
|
||||
_oauth_log("Discovering project ID via loadCodeAssist...")
|
||||
lr = urllib.request.Request(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
data=json.dumps({}).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {tokens['access_token']}",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
})
|
||||
lresp = urllib.request.urlopen(lr, timeout=15)
|
||||
ldata = json.loads(lresp.read())
|
||||
p = ldata.get("cloudaicompanionProject", "")
|
||||
if isinstance(p, dict):
|
||||
project_id = p.get("id", "")
|
||||
elif isinstance(p, str):
|
||||
project_id = p
|
||||
_oauth_log(f"Project ID: {project_id or '(none)'}")
|
||||
if project_id:
|
||||
tokens["project_id"] = project_id
|
||||
with open(token_path, "w") as f2:
|
||||
json.dump(tokens, f2, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
except Exception as pe:
|
||||
_oauth_log(f"loadCodeAssist failed (non-fatal): {pe}")
|
||||
project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens)
|
||||
_oauth_log(f"Project ID: {project_id or '(none)'}")
|
||||
if is_antigravity:
|
||||
found_models = [
|
||||
"gemini-2.5-flash", "gemini-2.5-pro",
|
||||
@@ -3915,7 +4062,7 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
for mc in probe_candidates:
|
||||
try:
|
||||
pr = urllib.request.Request(
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent",
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:generateContent",
|
||||
data=json.dumps({
|
||||
"project": project_id,
|
||||
"model": mc,
|
||||
@@ -4264,10 +4411,54 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
data["default"] = name
|
||||
|
||||
save_endpoints(data)
|
||||
self._hot_reload_proxy_key(new_ep)
|
||||
self._parent_mgr._rebuild()
|
||||
self._parent_mgr._parent._on_endpoints_updated()
|
||||
self.destroy()
|
||||
|
||||
def _hot_reload_proxy_key(self, ep):
|
||||
try:
|
||||
ep_name = ep.get("name", "")
|
||||
proxy_port = None
|
||||
import glob as _glob
|
||||
for cfg_file in _glob.glob(str(PROXY_CONFIG_DIR / "proxy-*.json")):
|
||||
try:
|
||||
with open(cfg_file) as f:
|
||||
pcfg = json.load(f)
|
||||
if ep_name.lower().replace(" ", "-") in cfg_file.lower():
|
||||
proxy_port = pcfg.get("port")
|
||||
pcfg["api_key"] = ep.get("api_key", "")
|
||||
with open(cfg_file, "w") as f:
|
||||
json.dump(pcfg, f, indent=2)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if proxy_port:
|
||||
import urllib.request as _ur
|
||||
try:
|
||||
url = f"http://127.0.0.1:{proxy_port}/admin/reload"
|
||||
resp = _ur.urlopen(url, timeout=3)
|
||||
result = json.loads(resp.read())
|
||||
reloaded = result.get("reloaded", False)
|
||||
preview = result.get("api_key_preview", "?")
|
||||
self._parent_mgr._parent.log(
|
||||
f"[hot-reload] key {'updated' if reloaded else 'unchanged'}: {preview}")
|
||||
if reloaded:
|
||||
verify_url = f"http://127.0.0.1:{proxy_port}/admin/verify-key"
|
||||
vresp = _ur.urlopen(verify_url, timeout=10)
|
||||
vresult = json.loads(vresp.read())
|
||||
valid = vresult.get("valid", False)
|
||||
if valid:
|
||||
self._parent_mgr._parent.log(
|
||||
f"[hot-reload] key verified OK ({vresult.get('models', '?')} models)")
|
||||
else:
|
||||
self._parent_mgr._parent.log(
|
||||
f"[hot-reload] WARNING: key verification failed: {vresult.get('error', 'unknown')}")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _show_error(self, msg):
|
||||
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
|
||||
d.run(); d.destroy()
|
||||
|
||||
Reference in New Issue
Block a user