diff --git a/CHANGELOG.md b/CHANGELOG.md index d56be91..b2be983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v2.6.1 (2026-05-20) + +- **Google OAuth rebuilt to emulate Gemini CLI** + - Uses Google's public OAuth client_id (same as gemini-cli) + - No `client_secret.json` needed — zero setup required + - PKCE (S256 code challenge) + CSRF state protection + - Scopes: cloud-platform, generative-language, userinfo.email, userinfo.profile + - Redirects to Google's success/failure pages (same as gemini-cli) + - Just click "OAuth Login" → browser opens → authorize → done + - Token file permissions set to 0600 for security + ## v2.6.0 (2026-05-20) - **Usage Dashboard** — per-provider tracking with visual cards diff --git a/codex-launcher_2.6.0_all.deb b/codex-launcher_2.6.0_all.deb deleted file mode 100644 index ee62bdf..0000000 Binary files a/codex-launcher_2.6.0_all.deb and /dev/null differ diff --git a/codex-launcher_2.6.1_all.deb b/codex-launcher_2.6.1_all.deb new file mode 100644 index 0000000..0a72c25 Binary files /dev/null and b/codex-launcher_2.6.1_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 19a9099..18e54da 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -25,6 +25,13 @@ model_catalog_json = "" """ CHANGELOG = [ + ("2.6.1", "2026-05-20", [ + "Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed", + "Uses Google's public OAuth client_id (same as gemini-cli)", + "PKCE + CSRF state protection for secure auth", + "Just click OAuth Login → browser opens → authorize → done", + "Includes cloud-platform scope for Gemini Code Assist compatibility", + ]), ("2.6.0", "2026-05-20", [ "Usage Dashboard — per-provider request/token/latency tracking", "Visual cards with success rate bars, model breakdown, error tracking", @@ -640,7 +647,7 @@ class LauncherWin(Gtk.Window): # header row hdr = Gtk.Box(spacing=8) vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v2.6.0") + lbl = Gtk.Label(label="Codex Launcher v2.6.1") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") @@ -1824,84 +1831,41 @@ class EditEndpointDialog(Gtk.Dialog): def _google_oauth_flow(self): token_path = os.path.expanduser("~/.cache/codex-proxy/google-oauth-token.json") - default_cs_path = os.path.expanduser("~/.cache/codex-proxy/client_secret.json") - client_secret_path = None - if os.path.exists(default_cs_path): - client_secret_path = default_cs_path - else: - dlg = Gtk.Dialog(title="Google OAuth Setup", parent=self, modal=True) - dlg.add_button("Open Google Console", 1) - dlg.add_button("Browse for client_secret.json", 2) - dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.set_default_size(500, 320) - area = dlg.get_content_area() - area.set_margin_start(16) - area.set_margin_end(16) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(8) - area.pack_start(Gtk.Label(label="One-time Google OAuth Setup", use_markup=True), False, False, 0) - steps = Gtk.Label(label=( - "1. Create project at Google Cloud Console\n" - " (enable 'Generative Language API')\n" - "2. APIs & Services → Credentials\n" - " → Create OAuth 2.0 Client ID (Desktop app)\n" - "3. Add redirect URI: http://localhost:8085\n" - "4. Download client_secret.json\n\n" - "Then click 'Browse for client_secret.json'\n" - f"It will be auto-copied to: {default_cs_path}" - ), xalign=0) - area.pack_start(steps, False, False, 4) - area.show_all() - r = dlg.run() - dlg.destroy() - if r == 1: - subprocess.Popen(["xdg-open", "https://console.cloud.google.com/apis/credentials"]) - return - if r == 2: - chooser = Gtk.FileChooserDialog( - title="Select client_secret.json", - parent=self, - action=Gtk.FileChooserAction.OPEN, - ) - chooser.add_button("Cancel", Gtk.ResponseType.CANCEL) - chooser.add_button("Open", Gtk.ResponseType.OK) - filt = Gtk.FileFilter() - filt.set_name("JSON files") - filt.add_pattern("*.json") - chooser.add_filter(filt) - chooser.set_current_folder(os.path.expanduser("~/Downloads")) - if chooser.run() == Gtk.ResponseType.OK: - src = chooser.get_filename() - chooser.destroy() - if src and os.path.exists(src): - import shutil as _shutil - os.makedirs(os.path.dirname(default_cs_path), exist_ok=True) - _shutil.copy2(src, default_cs_path) - client_secret_path = default_cs_path - else: - chooser.destroy() - if not client_secret_path: - return + CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + CLIENT_SECRET = "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w" + SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/generative-language.retriever", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + import http.server, hashlib, secrets, socket - with open(client_secret_path) as f: - cs = json.load(f) - installed = cs.get("installed", cs.get("web", {})) - client_id = installed["client_id"] - client_secret = installed["client_secret"] - redirect_uri = "http://localhost:8085" - scope = "https://www.googleapis.com/auth/generative-language.retriever" + port = 8085 + state = secrets.token_hex(32) + verifier = secrets.token_urlsafe(32) + challenge = hashlib.sha256(verifier.encode()).digest() + challenge_b64 = urllib.parse.quote_plus(__import__('base64').urlsafe_b64encode(challenge).rstrip(b'=').decode()) + redirect_uri = f"http://127.0.0.1:{port}/oauth2callback" + scope_str = " ".join(SCOPES) auth_url = ( f"https://accounts.google.com/o/oauth2/v2/auth?" - f"client_id={client_id}&redirect_uri={urllib.parse.quote(redirect_uri)}" - f"&response_type=code&scope={urllib.parse.quote(scope)}&access_type=offline&prompt=consent" + 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=consent" + f"&state={state}" + f"&code_challenge={challenge_b64}" + f"&code_challenge_method=S256" ) - dlg = Gtk.Dialog(title="Google OAuth", parent=self, modal=True) + dlg = Gtk.Dialog(title="Google OAuth (Gemini Mode)", parent=self, modal=True) dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.set_default_size(520, 260) + dlg.set_default_size(520, 280) area = dlg.get_content_area() area.set_margin_start(16) area.set_margin_end(16) @@ -1909,15 +1873,16 @@ class EditEndpointDialog(Gtk.Dialog): area.set_margin_bottom(12) area.set_spacing(8) - area.pack_start(Gtk.Label(label="Step 1: Authorize with Google", use_markup=True, xalign=0), False, False, 0) - link_lbl = Gtk.Label(label=f'Click here to open Google authorization', use_markup=True) + area.pack_start(Gtk.Label(label="Sign in with Google", use_markup=True, xalign=0), False, False, 0) + area.pack_start(Gtk.Label(label="Emulating Gemini CLI OAuth — no client_secret.json needed.", xalign=0), False, False, 0) + + link_lbl = Gtk.Label() + link_lbl.set_markup(f'Click here to open Google authorization') + link_lbl.set_line_wrap(True) area.pack_start(link_lbl, False, False, 4) - area.pack_start(Gtk.Label(label="Opening browser automatically…", xalign=0), False, False, 0) - - area.pack_start(Gtk.Label(label="Waiting for authorization…", use_markup=True, xalign=0), False, False, 12) - self._oauth_status = Gtk.Label(label="Listening on http://localhost:8085 …", xalign=0) - area.pack_start(self._oauth_status, False, False, 0) + self._oauth_status = Gtk.Label(label="Opening browser…", xalign=0) + area.pack_start(self._oauth_status, False, False, 4) spinner = Gtk.Spinner() spinner.start() @@ -1925,36 +1890,39 @@ class EditEndpointDialog(Gtk.Dialog): area.show_all() - import http.server code_holder = [None] error_holder = [None] + received_state = [None] class OAuthHandler(http.server.BaseHTTPRequestHandler): def do_GET(self2): qs = urllib.parse.urlparse(self2.path).query params = urllib.parse.parse_qs(qs) + received_state[0] = params.get("state", [None])[0] if "code" in params: + if received_state[0] != state: + self2.send_response(400) + self2.send_header("Content-Type", "text/html") + self2.end_headers() + self2.wfile.write(b"" + b"

CSRF state mismatch.

") + error_holder[0] = "CSRF state mismatch" + return code_holder[0] = params["code"][0] - self2.send_response(200) - self2.send_header("Content-Type", "text/html") + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini") self2.end_headers() - self2.wfile.write(b"" - b"

✓ Authorization successful!

" - b"

You can close this tab and return to Codex Launcher.

") else: error_holder[0] = params.get("error", ["unknown"])[0] - self2.send_response(400) - self2.send_header("Content-Type", "text/html") + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") self2.end_headers() - self2.wfile.write(b"" - b"

Authorization failed.

" - b"

Please close this tab and try again.

") def log_message(self2, *a): pass try: - server = http.server.HTTPServer(("127.0.0.1", 8085), OAuthHandler) + server = http.server.HTTPServer(("127.0.0.1", port), OAuthHandler) except OSError: - self._oauth_status.set_text("Port 8085 already in use — close other apps and retry.") + self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.") spinner.stop() dlg.run(); dlg.destroy() return @@ -1962,16 +1930,16 @@ class EditEndpointDialog(Gtk.Dialog): def wait_for_code(): server.handle_request() server.server_close() - GLib.idle_add(self._google_oauth_complete, dlg, code_holder, error_holder, - client_id, client_secret, redirect_uri, token_path, spinner) + GLib.idle_add(self._google_oauth_complete_gemini, dlg, code_holder, error_holder, + CLIENT_ID, CLIENT_SECRET, redirect_uri, token_path, spinner, verifier) threading.Thread(target=wait_for_code, daemon=True).start() subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) dlg.run() dlg.destroy() - def _google_oauth_complete(self, dlg, code_holder, error_holder, - client_id, client_secret, redirect_uri, token_path, spinner): + def _google_oauth_complete_gemini(self, dlg, code_holder, error_holder, + client_id, client_secret, redirect_uri, token_path, spinner, verifier): spinner.stop() if error_holder[0]: self._oauth_status.set_markup(f'Error: {error_holder[0]}') @@ -1988,6 +1956,7 @@ class EditEndpointDialog(Gtk.Dialog): "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=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}) @@ -1996,10 +1965,12 @@ class EditEndpointDialog(Gtk.Dialog): tokens["client_id"] = client_id tokens["client_secret"] = client_secret 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(tokens, f, indent=2) + os.chmod(token_path, 0o600) self._entry_key.set_text(tokens.get("access_token", "")) - self._oauth_status.set_markup('✓ Authorization successful! Token saved.') + self._oauth_status.set_markup('Authorization successful! Token saved.') dlg.set_title("Google OAuth — Success") except Exception as e: self._oauth_status.set_markup(f'Token exchange failed: {e}')