diff --git a/CHANGELOG.md b/CHANGELOG.md index cacf7f3a..0454ba7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project does not yet follow semantic versioning (pre-1.0). ### Added +- `codex-shim doctor`, a read-only local diagnostics command covering Python, + dependencies, Codex CLI availability, settings, runtime files, daemon health, + passthrough readiness, proxy loopback bypass, and Codex config wiring with + stable OK/WARN/FAIL/INFO output and summary exit-code handling. +- `docs/subscription-integration.md`, covering ChatGPT/Codex and + Cursor/Composer subscription passthrough setup, troubleshooting, limitations, + and privacy notes. - Auto Router (`codex_shim/router.py`): an optional `Auto (smart routing)` picker entry (slug `codex-auto`) that routes each task to the cheapest configured model that can handle it. A cheap classifier model scores every candidate @@ -79,6 +86,16 @@ and this project does not yet follow semantic versioning (pre-1.0). ### Fixed +- Protected the state-changing picker `/api/switch` endpoint with a + per-process picker token so third-party pages cannot trigger model switches + or Desktop restarts through the loopback server. +- Image detail normalization in `responses_to_chat`: Codex Desktop's + `detail: "original"` on `input_image` items is mapped to `"high"` for + OpenAI Chat Completions providers; unknown detail values fall back to `"auto"`. +- `codex-shim patch-app` regex needles now match both legacy inline picker + filters in `model-queries-*.js` and newer extracted helpers in + `models-and-reasoning-efforts-*.js`, with APPLIED markers for idempotent + re-runs. - Anthropic route requests now send only `x-api-key` (plus `anthropic-version`) for authentication and no longer also attach `Authorization: Bearer `. Some Anthropic-compatible gateways reject requests that carry both headers. diff --git a/README.md b/README.md index 4c410796..3a5f0d38 100644 --- a/README.md +++ b/README.md @@ -887,6 +887,7 @@ codex-shim generate regenerate catalog/config without starting daemon codex-shim start regenerate catalog and start local shim daemon codex-shim enable start daemon and write managed ~/.codex/config.toml block codex-shim status health check + model count +codex-shim doctor read-only local diagnostics report codex-shim stop stop daemon codex-shim disable remove managed config block and stop daemon codex-shim restart stop, regenerate, and start daemon @@ -906,8 +907,8 @@ codex-model [list|] shortcut for `codex-shim model …` Global flags: -- `--settings `: used by catalog/model/start/app/codex flows. -- `--port `: used by daemon/provider flows. +- `--settings `: used by catalog/model/start/app/codex/doctor flows. +- `--port `: used by daemon/provider/doctor flows. `patch-app` and `restore-app` always target `/Applications/Codex.app`, do not use `--settings`, and exit with a clear error on Windows/Linux. @@ -927,10 +928,15 @@ restarting the CLI: `name = "..."` in `~/.codex/config.toml` so the Codex Desktop UI shows the selected model's display name (e.g. "Kimi K2.6") instead of the generic "Codex Shim" label, and optionally relaunches Codex Desktop - (`open -a Codex` on macOS, `taskkill` + `Codex.exe` on Windows). + (`open -a Codex` on macOS, `taskkill` + `Codex.exe` on Windows). This + state-changing picker endpoint requires the per-process + `X-Codex-Shim-Picker-Token` header embedded in `/picker`. All picker routes are behind the same `Host`-header allowlist as the rest of -the shim, so a visited web page cannot drive them via DNS rebinding. +the shim, so a visited web page cannot drive them via DNS rebinding. The +state-changing `/api/switch` endpoint also requires a per-process picker token, +so third-party pages cannot trigger model switches just because the loopback +server is reachable. --- @@ -944,6 +950,9 @@ the shim, so a visited web page cannot drive them via DNS rebinding. drives the shim with your credentials. If you deliberately bind to a non-loopback host, add the host(s) you reach it by to `CODEX_SHIM_ALLOWED_HOSTS` (comma-separated). +- The model picker protects its state-changing `/api/switch` endpoint with a + per-process picker token, so cross-site pages cannot switch the active model + or request a Desktop restart without loading the picker page. - API keys stay in your settings file; the generated catalog does not contain them. - Request logs are summary-level by default and avoid full prompt/API-key dumps. @@ -974,10 +983,18 @@ the shim, so a visited web page cannot drive them via DNS rebinding. ### Shim will not start ```bash +codex-shim doctor codex-shim status tail -n 80 .codex-shim/shim.log ``` +`codex-shim doctor` prints a read-only diagnostics report grouped by section +(Python, dependencies, Codex CLI, settings, runtime files, daemon health, +passthrough availability, proxy bypass, and Codex config). It never writes +configuration, starts/stops the daemon, calls model providers, or prints API +keys/tokens. It exits 1 only when a hard `FAIL` is detected; warnings are meant +as local setup hints. + Common causes: - Python is older than 3.11. diff --git a/codex_shim/cli.py b/codex_shim/cli.py index 2fca0473..ee439851 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -1,16 +1,21 @@ from __future__ import annotations import argparse +from collections import Counter +from dataclasses import dataclass +import importlib.util import os from pathlib import Path import ctypes import signal +import shutil import subprocess import sys import time import hashlib import json import plistlib +import re import struct from urllib.request import urlopen @@ -26,6 +31,7 @@ DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, + DEFAULT_CODEX_AUTH, PROVIDER_NAME, ModelSettings, available_model_slugs, @@ -63,15 +69,27 @@ INFO_PLIST_BACKUP_NAME = "Info.plist.before-codex-shim-model-picker-patch" SYSTEM_CODEX_APP = Path("/Applications/Codex.app") USER_CODEX_APP = Path.home() / "Applications" / "Codex.app" -MODEL_PICKER_NEEDLE = "let u=c.useHiddenModels&&o!==`amazonBedrock`,d;" -MODEL_PICKER_REPLACEMENT = "let u=!1,d;" -SIDEBAR_RECENT_THREADS_NEEDLE = ( - "listRecentThreads({cursor:e,limit:t}){return this.params.requestClient.sendRequest(`thread/list`," - "{limit:t,cursor:e,sortKey:this.recentConversationSortKey,modelProviders:null,archived:!1,sourceKinds:ke})}" +MODEL_PICKER_NEEDLE = re.compile( + r"(?P(?:let )?\w+=)" + r"(?:\w+\.useHiddenModels|\w+)" + r"&&\w+!==`amazonBedrock`" + r"(?P[,;])" +) +MODEL_PICKER_REPLACEMENT = r"\g!1\g" +MODEL_PICKER_APPLIED = re.compile( + r"(?:let )?\w+=!1[,;][^\n]{0,300}\.forEach" +) + +SIDEBAR_RECENT_THREADS_NEEDLE = re.compile( + r"listRecentThreads\(\{cursor:e,limit:t(?:,useStateDbOnly:\w+(?:=!\d)?)?\}\)\{return this\.params\.requestClient\.sendRequest\(`thread/list`," + r"\{limit:t,cursor:e,sortKey:this\.recentConversationSortKey,modelProviders:null,archived:!1,sourceKinds:(\w+)(?:,useStateDbOnly:\w+)?\}\)\}" ) SIDEBAR_RECENT_THREADS_REPLACEMENT = ( - "listRecentThreads({cursor:e,limit:t}){return this.params.requestClient.sendRequest(`thread/list`," - "{limit:t,cursor:e,sortKey:this.recentConversationSortKey,modelProviders:[],archived:!1,sourceKinds:ke})}" + r"listRecentThreads({cursor:e,limit:t}){return this.params.requestClient.sendRequest(`thread/list`," + r"{limit:t,cursor:e,sortKey:this.recentConversationSortKey,modelProviders:[],archived:!1,sourceKinds:\1})}" +) +SIDEBAR_RECENT_THREADS_APPLIED = re.compile( + r"\.recentConversationSortKey,modelProviders:\[\],archived:!1,sourceKinds:\w+" ) @@ -88,6 +106,7 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("disable") sub.add_parser("restart") sub.add_parser("status") + sub.add_parser("doctor", help="Print a read-only local diagnostics report.") sub.add_parser("patch-app", help="Patch Codex Desktop picker/sidebar handling for custom shim models.") sub.add_parser("restore-app", help="Restore Codex Desktop app.asar from the pre-patch backup.") @@ -134,6 +153,8 @@ def main(argv: list[str] | None = None) -> int: return start(args.settings, args.port) if args.command == "status": return status(args.port) + if args.command == "doctor": + return doctor(args.settings, args.port) if args.command == "patch-app": return patch_codex_app() if args.command == "restore-app": @@ -185,6 +206,324 @@ def _active_router(models, settings_path: Path): return None +@dataclass(frozen=True) +class DoctorCheck: + section: str + status: str # OK | WARN | FAIL | INFO + message: str + detail: str = "" + + +def doctor(settings_path: Path, port: int) -> int: + """Print a read-only diagnostics report for the local codex-shim setup.""" + expanded = Path(settings_path).expanduser() + checks: list[DoctorCheck] = [] + checks.extend(_doctor_python()) + checks.extend(_doctor_dependencies()) + checks.extend(_doctor_codex_cli()) + checks.extend(_doctor_settings(expanded)) + checks.extend(_doctor_runtime_files()) + checks.extend(_doctor_daemon(port)) + checks.extend(_doctor_chatgpt()) + checks.extend(_doctor_cursor()) + checks.extend(_doctor_proxy_env()) + checks.extend(_doctor_codex_config()) + _print_doctor_report(checks) + return 1 if any(check.status == "FAIL" for check in checks) else 0 + + +def _doctor_python() -> list[DoctorCheck]: + version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + status = "OK" if sys.version_info >= (3, 11) else "FAIL" + detail = "" if status == "OK" else "codex-shim requires Python 3.11+" + return [ + DoctorCheck("Python", status, f"version: {version}", detail), + DoctorCheck("Python", "OK", f"executable: {sys.executable}"), + ] + + +def _doctor_dependencies() -> list[DoctorCheck]: + if importlib.util.find_spec("aiohttp") is None: + return [ + DoctorCheck( + "Dependencies", + "FAIL", + "aiohttp is not importable", + "Try: python3 -m pip install -e .", + ) + ] + return [DoctorCheck("Dependencies", "OK", "aiohttp importable")] + + +def _doctor_codex_cli() -> list[DoctorCheck]: + found = shutil.which("codex") + if not found: + return [ + DoctorCheck( + "Codex CLI", + "WARN", + "codex not found on PATH", + "Install and authenticate Codex before using codex-shim app/codex flows.", + ) + ] + checks = [DoctorCheck("Codex CLI", "OK", f"found: {found}")] + try: + result = subprocess.run([found, "--version"], capture_output=True, text=True, timeout=5) + except (OSError, subprocess.TimeoutExpired) as exc: + checks.append(DoctorCheck("Codex CLI", "WARN", "could not run codex --version", str(exc))) + return checks + output = (result.stdout or result.stderr).strip().splitlines() + version = output[0].strip() if output else "unknown" + if len(version) > 200: + version = version[:197] + "..." + if result.returncode == 0: + checks.append(DoctorCheck("Codex CLI", "OK", f"version: {version}")) + else: + checks.append(DoctorCheck("Codex CLI", "WARN", "codex --version failed", version)) + return checks + + +def _doctor_settings(settings_path: Path) -> list[DoctorCheck]: + section = "Settings" + path = settings_path.expanduser() + if not path.exists(): + detail = "Create ~/.codex-shim/models.json or run codex login for ChatGPT passthrough-only use." + return [DoctorCheck(section, "WARN", f"settings file not found: {path}", detail)] + checks = [DoctorCheck(section, "OK", f"path: {path}")] + try: + models = _load_models(path) + except SystemExit as exc: + message = str(exc) + if "not valid JSON" in message or "invalid JSON" in message: + return [DoctorCheck(section, "FAIL", f"invalid JSON: {path}", message)] + return [DoctorCheck(section, "FAIL", f"could not load settings: {path}", message)] + except Exception as exc: + return [DoctorCheck(section, "FAIL", f"could not load settings: {path}", str(exc))] + + usable = usable_byok_models(models) + missing_count = len(models) - len(usable) + checks.append(DoctorCheck(section, "OK", f"configured models: {len(models)}")) + checks.append(DoctorCheck(section, "OK", f"usable BYOK models: {len(usable)}")) + if missing_count: + checks.append(DoctorCheck(section, "WARN", f"models missing API keys: {missing_count}")) + else: + checks.append(DoctorCheck(section, "OK", "models missing API keys: 0")) + providers = Counter(model.provider for model in models) + provider_text = ", ".join(f"{provider}={count}" for provider, count in sorted(providers.items())) or "none" + checks.append(DoctorCheck(section, "INFO", f"providers: {provider_text}")) + + router_config = router_module.load_router_config(path) + if router_config is None: + checks.append(DoctorCheck(section, "INFO", "auto router configured: false")) + else: + active = _active_router(models, path) + if active is not None: + checks.append(DoctorCheck(section, "OK", f"auto router active: {active.slug}")) + elif router_config.effective_enabled: + checks.append( + DoctorCheck( + section, + "WARN", + f"auto router configured but inactive: {router_config.slug}", + "Ensure at least one router candidate matches a usable model slug.", + ) + ) + else: + checks.append(DoctorCheck(section, "INFO", f"auto router configured but disabled: {router_config.slug}")) + return checks + + +def _doctor_runtime_files() -> list[DoctorCheck]: + checks: list[DoctorCheck] = [] + if CATALOG_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "OK", f"catalog: {CATALOG_PATH}")) + try: + data = json.loads(CATALOG_PATH.read_text()) + models = data.get("models", []) if isinstance(data, dict) else [] + count = len(models) if isinstance(models, list) else 0 + checks.append(DoctorCheck("Runtime files", "OK", f"catalog models: {count}")) + except (OSError, json.JSONDecodeError) as exc: + checks.append( + DoctorCheck("Runtime files", "WARN", f"catalog JSON is not readable: {CATALOG_PATH}", str(exc)) + ) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"catalog missing: {CATALOG_PATH}")) + if CONFIG_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "OK", f"config: {CONFIG_PATH}")) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"config missing: {CONFIG_PATH}")) + if PID_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "INFO", f"pid file: {PID_PATH}")) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"pid file missing: {PID_PATH}")) + if LOG_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "INFO", f"log file: {LOG_PATH}")) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"log file missing: {LOG_PATH}")) + return checks + + +def _doctor_daemon(port: int) -> list[DoctorCheck]: + checks = [DoctorCheck("Shim daemon", "INFO", f"health URL: http://{DEFAULT_HOST}:{port}/health")] + pid = _read_pid() + if pid is None: + checks.append(DoctorCheck("Shim daemon", "INFO", f"pid file missing or unreadable: {PID_PATH}")) + elif _pid_running(pid): + checks.append(DoctorCheck("Shim daemon", "OK", f"pid {pid} is running")) + else: + checks.append(DoctorCheck("Shim daemon", "WARN", f"pid {pid} is not running")) + + health = _health(port) + if health is None: + checks.append(DoctorCheck("Shim daemon", "WARN", "health endpoint unavailable")) + return checks + model_count = _health_model_count(health.get("models")) + if health.get("ok") is True: + checks.append(DoctorCheck("Shim daemon", "OK", f"health ok: {model_count} models")) + else: + checks.append(DoctorCheck("Shim daemon", "WARN", f"health not ok: {model_count} models")) + for key in ("chatgpt_passthrough", "cursor_passthrough", "auto_router"): + if key in health: + checks.append(DoctorCheck("Shim daemon", "INFO", f"{key}: {_bool_text(health.get(key))}")) + return checks + + +def _health_model_count(value) -> int: + if isinstance(value, int): + return value + if isinstance(value, list): + return len(value) + return 0 + + +def _doctor_chatgpt() -> list[DoctorCheck]: + if _env_flag("CODEX_SHIM_DISABLE_CHATGPT"): + return [DoctorCheck("ChatGPT passthrough", "INFO", "disabled via CODEX_SHIM_DISABLE_CHATGPT")] + auth_path = Path(DEFAULT_CODEX_AUTH).expanduser() + if chatgpt_passthrough_available(): + return [DoctorCheck("ChatGPT passthrough", "OK", f"available via {auth_path}")] + if auth_path.exists(): + detail = "Run `codex login` again if you want ChatGPT/Codex passthrough." + else: + detail = "Run `codex login` if you want ChatGPT/Codex passthrough." + return [DoctorCheck("ChatGPT passthrough", "WARN", "unavailable", detail)] + + +def _doctor_cursor() -> list[DoctorCheck]: + if _env_flag("CODEX_SHIM_DISABLE_CURSOR"): + return [DoctorCheck("Cursor passthrough", "INFO", "disabled via CODEX_SHIM_DISABLE_CURSOR")] + bin_override = os.environ.get("CURSOR_AGENT_BIN", "").strip() + agent_bin = bin_override or shutil.which("cursor-agent") + checks: list[DoctorCheck] = [] + if agent_bin: + checks.append(DoctorCheck("Cursor passthrough", "INFO", f"cursor-agent: {agent_bin}")) + else: + checks.append(DoctorCheck("Cursor passthrough", "WARN", "cursor-agent not found on PATH")) + if cursor_passthrough_available(): + checks.append(DoctorCheck("Cursor passthrough", "OK", "cursor-agent logged in")) + for slug in sorted(cursor_passthrough_display_names()): + checks.append(DoctorCheck("Cursor passthrough", "INFO", f"exposed model: {slug}")) + else: + checks.append( + DoctorCheck( + "Cursor passthrough", + "WARN", + "unavailable", + "Run `cursor-agent login` if you want Cursor passthrough.", + ) + ) + return checks + + +def _doctor_proxy_env() -> list[DoctorCheck]: + required = {"127.0.0.1", "localhost", "::1"} + values: set[str] = set() + for key in ("NO_PROXY", "no_proxy"): + raw = os.environ.get(key, "") + for part in raw.split(","): + value = part.strip().lower() + if value: + values.add(value) + if "*" in values or required <= values: + return [DoctorCheck("Proxy", "OK", "loopback hosts covered by NO_PROXY/no_proxy")] + return [ + DoctorCheck( + "Proxy", + "WARN", + "NO_PROXY/no_proxy does not include all loopback hosts", + "Recommended: 127.0.0.1,localhost,::1", + ) + ] + + +def _doctor_codex_config() -> list[DoctorCheck]: + path = Path(CODEX_CONFIG_PATH).expanduser() + if not path.exists(): + return [ + DoctorCheck( + "Codex config", + "INFO", + "shim provider is not currently installed", + "Run `codex-shim app .` or `codex-shim enable` to wire Codex to the shim.", + ) + ] + checks = [DoctorCheck("Codex config", "OK", f"config exists: {path}")] + try: + text = path.read_text() + except OSError as exc: + return [DoctorCheck("Codex config", "WARN", f"could not read config: {path}", str(exc))] + provider_configured = ( + f'model_provider = "{PROVIDER_NAME}"' in text or f"[model_providers.{PROVIDER_NAME}]" in text + ) + if provider_configured: + checks.append(DoctorCheck("Codex config", "OK", "shim provider configured")) + else: + checks.append( + DoctorCheck( + "Codex config", + "INFO", + "shim provider is not currently installed", + "Run `codex-shim app .` or `codex-shim enable` to wire Codex to the shim.", + ) + ) + current = _current_managed_model() + if current: + checks.append(DoctorCheck("Codex config", "OK", f"active shim model: {current}")) + else: + checks.append(DoctorCheck("Codex config", "INFO", "active shim model: none")) + return checks + + +def _print_doctor_report(checks: list[DoctorCheck]) -> None: + current_section = None + for check in checks: + if check.section != current_section: + if current_section is not None: + print() + print(check.section) + current_section = check.section + print(f" {check.status:<5} {check.message}") + if check.detail: + for line in check.detail.splitlines(): + print(f" {line}") + counts = Counter(check.status for check in checks) + summary_status = "FAIL" if counts["FAIL"] else "OK" + print() + print("Summary") + print( + f" {summary_status:<5} " + f"{counts['OK']} ok, {counts['WARN']} warn, {counts['FAIL']} fail, {counts['INFO']} info" + ) + + +def _env_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _bool_text(value) -> str: + return "true" if bool(value) else "false" + + def generate(settings_path: Path, port: int) -> None: models = _load_models(settings_path) try: @@ -525,24 +864,30 @@ def _patch_codex_desktop_bundles(workdir: Path) -> bool | None: patches = [ ( "model picker allowlist filter", - ["model-queries-*.js", "*.js"], + [ + "models-and-reasoning-efforts-*.js", + "model-queries-*.js", + "*.js", + ], MODEL_PICKER_NEEDLE, MODEL_PICKER_REPLACEMENT, + MODEL_PICKER_APPLIED, ), ( "shim-mode sidebar provider filter", ["app-server-manager-signals-*.js", "*.js"], SIDEBAR_RECENT_THREADS_NEEDLE, SIDEBAR_RECENT_THREADS_REPLACEMENT, + SIDEBAR_RECENT_THREADS_APPLIED, ), ] changed = False - for label, globs, needle, replacement in patches: - bundle_file = _find_js_bundle(workdir, globs, needle, replacement) + for label, globs, needle, replacement, applied in patches: + bundle_file = _find_js_bundle(workdir, globs, needle, applied) if bundle_file is None: print(f"Could not find the expected {label} in Codex Desktop.", file=sys.stderr) return None - result = _replace_once(bundle_file, needle, replacement) + result = _replace_once(bundle_file, needle, replacement, applied) if result is None: print(f"Could not patch the expected {label} in Codex Desktop.", file=sys.stderr) return None @@ -554,7 +899,12 @@ def _patch_codex_desktop_bundles(workdir: Path) -> bool | None: return changed -def _find_js_bundle(workdir: Path, globs: list[str], needle: str, replacement: str) -> Path | None: +def _find_js_bundle( + workdir: Path, + globs: list[str], + needle: re.Pattern[str], + applied: re.Pattern[str], +) -> Path | None: assets_dir = workdir / "webview" / "assets" if not assets_dir.exists(): return None @@ -563,19 +913,26 @@ def _find_js_bundle(workdir: Path, globs: list[str], needle: str, replacement: s candidates.extend(p for p in sorted(assets_dir.glob(pattern)) if p not in candidates) for path in candidates: text = _read_text_lossy(path) - if needle in text or replacement in text: + if needle.search(text) or applied.search(text): return path return None -def _replace_once(path: Path, needle: str, replacement: str) -> bool | None: +def _replace_once( + path: Path, + needle: re.Pattern[str], + replacement: str, + applied: re.Pattern[str], +) -> bool | None: text = _read_text_lossy(path) - if replacement in text: - return False - count = text.count(needle) - if count != 1: + matches = needle.findall(text) + if not matches: + if applied.search(text): + return False + return None + if len(matches) != 1: return None - path.write_text(text.replace(needle, replacement, 1)) + path.write_text(needle.sub(replacement, text, count=1)) return True @@ -627,7 +984,7 @@ def _app_asar_is_patched(app_asar: Path) -> bool: text = app_asar.read_bytes().decode("utf-8", errors="ignore") except OSError: return False - return MODEL_PICKER_REPLACEMENT in text and SIDEBAR_RECENT_THREADS_REPLACEMENT in text + return MODEL_PICKER_APPLIED.search(text) is not None and SIDEBAR_RECENT_THREADS_APPLIED.search(text) is not None def _resign_codex_app(codex_app: Path = SYSTEM_CODEX_APP) -> None: diff --git a/codex_shim/server.py b/codex_shim/server.py index 0fd86fa5..05ff269e 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -3,6 +3,7 @@ import argparse import json import re +import secrets import sys import time import uuid @@ -58,6 +59,7 @@ DEBUG_DIR = Path(__file__).resolve().parents[1] / ".codex-shim" CODEX_CONFIG_PATH = Path.home() / ".codex" / "config.toml" +PICKER_TOKEN_HEADER = "X-Codex-Shim-Picker-Token" class ShimServer: @@ -65,6 +67,7 @@ def __init__(self, settings_path: Path = DEFAULT_SETTINGS, host: str = DEFAULT_H self.settings = ModelSettings(settings_path) self.host = host self.timeout = ClientTimeout(total=None, sock_connect=120, sock_read=None) + self.picker_token = secrets.token_urlsafe(32) def app(self) -> web.Application: allowed_hosts = build_allowed_hosts(self.host) @@ -84,7 +87,7 @@ def app(self) -> web.Application: return app async def picker_page(self, _request: web.Request) -> web.Response: - return web.Response(text=_picker_html(), content_type="text/html") + return web.Response(text=_picker_html(self.picker_token), content_type="text/html") async def api_models(self, _request: web.Request) -> web.Response: current = _current_managed_model() @@ -130,7 +133,13 @@ async def api_models(self, _request: web.Request) -> web.Response: ) return web.json_response(data) + def _valid_picker_token(self, request: web.Request) -> bool: + token = request.headers.get(PICKER_TOKEN_HEADER, "") + return secrets.compare_digest(token, self.picker_token) + async def switch_model(self, request: web.Request) -> web.Response: + if not self._valid_picker_token(request): + return web.json_response({"error": "forbidden"}, status=403) try: body = await request.json() except json.JSONDecodeError: @@ -2116,8 +2125,9 @@ def _do_restart() -> None: _threading.Thread(target=_do_restart, daemon=True).start() -def _picker_html() -> str: - return ''' +def _picker_html(picker_token: str) -> str: + token_json = json.dumps(picker_token).replace("<", "\\u003c") + html = ''' @@ -2174,6 +2184,7 @@ def _picker_html() -> str:

Codex needs to restart to use the new model

''' + return ( + html.replace("@@TOKEN_JSON@@", token_json, 1).replace("@@PICKER_HEADER@@", PICKER_TOKEN_HEADER, 1) + ) def main(argv: list[str] | None = None) -> None: diff --git a/codex_shim/translate.py b/codex_shim/translate.py index d510ec26..6fe071e3 100644 --- a/codex_shim/translate.py +++ b/codex_shim/translate.py @@ -575,6 +575,12 @@ def _chat_image_part(part: dict[str, Any]) -> dict[str, Any] | None: return None image_url: dict[str, Any] = {"url": url} detail = part.get("detail") or part.get("image_detail") + if detail and detail not in ("low", "auto", "high", "xhigh"): + # Codex Desktop sends "original" which is not a standard OpenAI Chat + # Completions value — providers like Kimi K2.6 reject it (400). + # Map it to "high" (the closest standard equivalent). Any unknown + # detail value falls back to "auto". + detail = "high" if detail == "original" else "auto" if detail: image_url["detail"] = detail return {"type": "image_url", "image_url": image_url} diff --git a/docs/subscription-integration.md b/docs/subscription-integration.md new file mode 100644 index 00000000..347f39f2 --- /dev/null +++ b/docs/subscription-integration.md @@ -0,0 +1,221 @@ +# Subscription passthrough integrations + +`codex-shim` can expose subscription-backed models without storing Dashboard +API keys in `~/.codex-shim/models.json`: + +- **ChatGPT/Codex passthrough** uses the Codex access token created by + `codex login` and forwards native `/v1/responses` requests to ChatGPT's Codex + backend. +- **Cursor/Composer passthrough** uses the local `cursor-agent` OAuth session + created by `cursor-agent login` and exposes Composer 2.5 as `composer-2-5`. + +Both integrations are optional, auth-gated, and advertised only when the local +login state is usable. They are different from BYOK routes: you do not add a +Dashboard API key for these subscription flows. + +--- + +## Quick check + +```bash +codex-shim doctor +codex-shim list +codex-shim status +``` + +Useful health fields: + +```json +{ + "chatgpt_passthrough": true, + "cursor_passthrough": true +} +``` + +`codex-shim doctor` is the safest first diagnostic because it does not start or +stop the daemon, write config, call model providers, or print token contents. + +--- + +## ChatGPT/Codex passthrough + +### What it does + +When `~/.codex/auth.json` exists and contains `tokens.access_token`, the shim +adds ChatGPT/Codex model slugs to discovery surfaces such as: + +- `codex-shim list` +- `/health` +- `/v1/models` +- the generated `.codex-shim/custom_model_catalog.json` + +Current fallback slugs include `gpt-5.5` and related GPT/Codex slugs. The shim +keeps Codex's native Responses payload shape and forwards it to: + +```text +https://chatgpt.com/backend-api/codex/responses +``` + +It sends the Codex access token as `Authorization: Bearer ...` and, when +present, the account id from `auth.json`. The token is not written into the +custom model catalog. + +### Setup + +```bash +codex login +codex-shim generate +codex-shim list +``` + +If `gpt-5.5` appears, you can select it from the Codex picker or run: + +```bash +codex-shim model use gpt-5.5 +``` + +For passthrough-only use, `~/.codex-shim/models.json` may be missing. The shim +can still generate a catalog containing subscription-backed entries when the +Codex auth file is valid. + +### Disable + +```bash +export CODEX_SHIM_DISABLE_CHATGPT=1 +``` + +After disabling, regenerate or restart the shim if you need discovery surfaces +to stop listing ChatGPT passthrough entries immediately. + +### Troubleshooting + +- Run `codex login` again if `codex-shim doctor` reports ChatGPT passthrough as + unavailable. +- Confirm the auth file exists at `~/.codex/auth.json`. Do not paste or upload + the file; it contains tokens. +- If the model picker still does not show GPT/Codex slugs, run + `codex-shim generate` and check `codex-shim list` before debugging Desktop + picker behavior. +- If `/health` reports `chatgpt_passthrough: false`, the daemon process may have + been started before login or with `CODEX_SHIM_DISABLE_CHATGPT` set. + +--- + +## Cursor/Composer passthrough + +### What it does + +When `cursor-agent status` reports an active login, the shim exposes Composer +2.5 as: + +```text +composer-2-5 +``` + +Requests to that slug are converted into a prompt for `cursor-agent --print` +using your local CLI OAuth session. This is subscription passthrough, not +Dashboard API-key billing. + +### Setup + +```bash +cursor-agent login +cursor-agent status +codex-shim generate +codex-shim list +``` + +Then select `Composer 2.5` in the picker or run: + +```bash +codex-shim model use composer-2-5 +``` + +The helper script is optional, but convenient: + +```bash +scripts/codex-shim-install-cursor-composer +``` + +It regenerates the local catalog/config and sets `composer-2-5` as the active +model when `cursor-agent status` reports an active login. + +### Binary and workspace overrides + +If `cursor-agent` is not on `PATH`, point the shim at it explicitly: + +```bash +export CURSOR_AGENT_BIN=/path/to/cursor-agent +``` + +By default, the cursor-agent child process runs in the current working +directory. Override that with: + +```bash +export CODEX_SHIM_CURSOR_WORKSPACE=/path/to/workspace +``` + +### Disable + +```bash +export CODEX_SHIM_DISABLE_CURSOR=1 +``` + +### Important: do not use Dashboard API keys for this flow + +Do **not** configure Composer through `cursor-api.standardagents.ai` unless you +intentionally want Dashboard API-key billing (`crsr_...`). For subscription +passthrough, the shim relies on `cursor-agent login` instead. + +The shim also removes `CURSOR_API_KEY` from the child `cursor-agent` environment +so a stale shell variable cannot override your CLI OAuth login. + +### Current limitations + +- The bridge is prompt-based because `cursor-agent --print` is a CLI interface, + not a native OpenAI/Anthropic provider endpoint. +- Image inputs are described/omitted in the prompt bridge rather than forwarded + as a native multimodal API payload. +- Tool-call fidelity is lower than native ChatGPT/Codex passthrough or BYOK + providers that support structured tool calls directly. + +### Troubleshooting + +- Run `cursor-agent status` first. If it says you are not logged in, run + `cursor-agent login`. +- Run `codex-shim doctor` and check the `Cursor passthrough` section. +- Check `/health`; `cursor_passthrough: true` means the daemon can expose + Composer. +- If the daemon was already running when you logged in, restart it so discovery + endpoints and generated catalog/config are refreshed. +- If `CURSOR_AGENT_BIN` is set, verify it points to an executable + `cursor-agent` binary. + +--- + +## Security and privacy notes + +- The generated catalog does not contain ChatGPT tokens or Cursor OAuth tokens. +- `codex-shim doctor` reports auth availability and paths, but does not print + token contents. +- ChatGPT passthrough reads `~/.codex/auth.json` at request time and forwards + the access token only to ChatGPT's Codex backend. +- Cursor passthrough spawns `cursor-agent` locally and sends the constructed + prompt to that CLI process through stdin. +- Do not share `~/.codex/auth.json`, shell history containing tokens, or any + request dump/log that may contain private prompts. + +--- + +## Subscription passthrough vs BYOK models + +| Flow | Credential source | Slug examples | Upstream shape | +|---|---|---|---| +| ChatGPT/Codex passthrough | `codex login` / `~/.codex/auth.json` | `gpt-5.5` | Native Codex Responses backend | +| Cursor/Composer passthrough | `cursor-agent login` | `composer-2-5` | `cursor-agent --print` bridge | +| BYOK OpenAI-compatible | `api_key` or `api_key_env` in settings | your configured slug | `/chat/completions` | +| BYOK Anthropic-compatible | `api_key` or `api_key_env` in settings | your configured slug | `/messages` | + +Use subscription passthrough when you want to spend subscription quota through +the local authenticated CLI. Use BYOK models when you want to route to a provider +endpoint and API key you control directly. diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 00000000..52667a31 --- /dev/null +++ b/tests/test_cli_doctor.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from codex_shim import cli + + +@pytest.fixture(autouse=True) +def doctor_safe_environment(monkeypatch, tmp_path): + monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "custom_model_catalog.json") + monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.toml") + monkeypatch.setattr(cli, "PID_PATH", tmp_path / "shim.pid") + monkeypatch.setattr(cli, "LOG_PATH", tmp_path / "shim.log") + monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", tmp_path / "codex-config.toml") + monkeypatch.setattr(cli, "DEFAULT_CODEX_AUTH", tmp_path / "auth.json") + monkeypatch.setattr("codex_shim.settings.DEFAULT_CURSOR_API_KEY_FILE", tmp_path / "missing-cursor-api-key") + monkeypatch.delenv("CURSOR_API_KEY", raising=False) + monkeypatch.delenv("CURSOR_AGENT_BIN", raising=False) + monkeypatch.delenv("CODEX_SHIM_DISABLE_CHATGPT", raising=False) + monkeypatch.delenv("CODEX_SHIM_DISABLE_CURSOR", raising=False) + monkeypatch.delenv("CODEX_SHIM_DISABLE_ROUTER", raising=False) + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost,::1") + monkeypatch.delenv("no_proxy", raising=False) + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda name: object()) + monkeypatch.setattr(cli.shutil, "which", lambda command: None) + monkeypatch.setattr(cli, "_health", lambda port: None) + monkeypatch.setattr(cli, "_read_pid", lambda: None) + monkeypatch.setattr(cli, "_pid_running", lambda pid: False) + monkeypatch.setattr( + cli, + "available_model_slugs", + lambda models: {model.slug for model in models if model.api_key.strip()}, + ) + monkeypatch.setattr(cli, "chatgpt_passthrough_available", lambda: False) + monkeypatch.setattr(cli, "cursor_passthrough_available", lambda: False) + + +def _settings(path, models, router=None): + data = {"models": models} + if router is not None: + data["router"] = router + path.write_text(json.dumps(data)) + return path + + +def test_doctor_command_returns_zero_with_healthy_mocked_environment(monkeypatch, tmp_path, capsys): + settings = _settings( + tmp_path / "models.json", + [ + { + "model": "claude-upstream", + "display_name": "Claude Upstream", + "provider": "anthropic", + "base_url": "https://example.invalid/v1", + "api_key": "secret-anthropic", + }, + { + "model": "gpt-upstream", + "display_name": "GPT Upstream", + "provider": "generic-chat-completion-api", + "base_url": "https://example.invalid/v1", + "api_key": "secret-openai", + }, + ], + router={ + "enabled": True, + "slug": "codex-auto", + "classifier": "gpt-upstream", + "candidates": [{"slug": "claude-upstream"}], + }, + ) + monkeypatch.setattr( + cli.shutil, + "which", + lambda command: {"codex": "/usr/local/bin/codex", "cursor-agent": "/usr/local/bin/cursor-agent"}.get(command), + ) + monkeypatch.setattr( + cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="codex-cli 0.133.0-alpha.1\n", stderr=""), + ) + monkeypatch.setattr(cli, "_read_pid", lambda: 12345) + monkeypatch.setattr(cli, "_pid_running", lambda pid: pid == 12345) + monkeypatch.setattr( + cli, + "_health", + lambda port: { + "ok": True, + "models": 4, + "chatgpt_passthrough": True, + "cursor_passthrough": True, + "auto_router": True, + }, + ) + monkeypatch.setattr(cli, "chatgpt_passthrough_available", lambda: True) + monkeypatch.setattr(cli, "cursor_passthrough_available", lambda: True) + + code = cli.main(["--settings", str(settings), "--port", "8765", "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + for section in ( + "Python", + "Dependencies", + "Codex CLI", + "Settings", + "Runtime files", + "Shim daemon", + "ChatGPT passthrough", + "Cursor passthrough", + "Proxy", + "Codex config", + "Summary", + ): + assert section in out + assert "health ok: 4 models" in out + assert "auto router active: codex-auto" in out + assert "secret-anthropic" not in out + assert "secret-openai" not in out + + +def test_invalid_settings_json_returns_one(tmp_path, capsys): + settings = tmp_path / "broken.json" + settings.write_text('{"models": [') + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 1 + assert "invalid JSON" in out + assert "Summary" in out + + +def test_missing_settings_file_warns_without_failing(tmp_path, capsys): + settings = tmp_path / "missing.json" + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "settings file not found" in out + assert "Summary" in out + + +def test_missing_api_key_is_warn(tmp_path, capsys): + settings = _settings( + tmp_path / "models.json", + [ + { + "model": "missing-key-model", + "display_name": "Missing Key Model", + "provider": "generic-chat-completion-api", + "base_url": "https://example.invalid/v1", + } + ], + ) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "models missing API keys: 1" in out + + +def test_no_proxy_complete_is_ok(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost,::1") + monkeypatch.delenv("no_proxy", raising=False) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "loopback hosts covered by NO_PROXY/no_proxy" in out + assert "NO_PROXY/no_proxy does not include all loopback hosts" not in out + + +def test_no_proxy_missing_is_warn(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "NO_PROXY/no_proxy does not include all loopback hosts" in out + + +def test_daemon_health_output(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setattr( + cli, + "_health", + lambda port: { + "ok": True, + "models": 2, + "chatgpt_passthrough": True, + "cursor_passthrough": False, + "auto_router": True, + }, + ) + + code = cli.main(["--settings", str(settings), "--port", "8765", "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "health ok: 2 models" in out + assert "chatgpt_passthrough: true" in out + assert "cursor_passthrough: false" in out + assert "auto_router: true" in out + + +def test_codex_cli_not_found_is_warn_not_fail(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setattr(cli.shutil, "which", lambda command: None) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "codex not found on PATH" in out + + +def test_aiohttp_missing_is_fail(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setattr( + cli.importlib.util, + "find_spec", + lambda name: None if name == "aiohttp" else object(), + ) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 1 + assert "aiohttp is not importable" in out + assert "FAIL" in out + + +def test_codex_config_installed_reports_provider_and_active_model(tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + cli.CODEX_CONFIG_PATH.write_text( + "\n".join( + [ + cli.MANAGED_BEGIN, + 'model = "gpt-5.5"', + 'model_provider = "codex_shim"', + cli.MANAGED_END, + "[model_providers.codex_shim]", + 'name = "codex_shim"', + ] + ) + ) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "shim provider configured" in out + assert "active shim model: gpt-5.5" in out + + +def test_codex_config_uninstalled_is_info(tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "shim provider is not currently installed" in out diff --git a/tests/test_router_integration.py b/tests/test_router_integration.py index b3bec39f..1c593843 100644 --- a/tests/test_router_integration.py +++ b/tests/test_router_integration.py @@ -17,7 +17,7 @@ from codex_shim import router from codex_shim import server as server_module -from codex_shim.server import ShimServer +from codex_shim.server import PICKER_TOKEN_HEADER, ShimServer @pytest.fixture(autouse=True) @@ -554,12 +554,22 @@ async def test_candidate_without_credentials_is_skipped(tmp_path): # --------------------------------------------------------------------------- async def test_switch_model_accepts_auto_slug(tmp_path, monkeypatch): captured = {} - monkeypatch.setattr(server_module, "_set_active_model", lambda slug, display=None: captured.update({"slug": slug, "display": display})) + monkeypatch.setattr( + server_module, + "_set_active_model", + lambda slug, display=None: captured.update({"slug": slug, "display": display}), + ) state = {} upstream = await make_upstream(state) - shim = await _shim(_settings(tmp_path, str(upstream.make_url("/v1")))) + server = ShimServer(_settings(tmp_path, str(upstream.make_url("/v1")))) + shim = TestClient(TestServer(server.app())) + await shim.start_server() - resp = await shim.post("/api/switch", json={"slug": "codex-auto", "restart_codex": False}) + resp = await shim.post( + "/api/switch", + json={"slug": "codex-auto", "restart_codex": False}, + headers={PICKER_TOKEN_HEADER: server.picker_token}, + ) assert resp.status == 200 data = await resp.json() assert data["ok"] is True and data["model"] == "codex-auto" diff --git a/tests/test_server.py b/tests/test_server.py index a05a9ac3..695155a8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -8,6 +8,7 @@ from codex_shim import server as server_module from codex_shim.server import ( + PICKER_TOKEN_HEADER, ResponsesStreamState, ShimServer, _current_managed_model, @@ -1125,11 +1126,24 @@ def _stub_codex_config(monkeypatch, tmp_path, *, model: str = "kimi-k26") -> "Pa return config +def _picker_headers(shim: ShimServer) -> dict[str, str]: + return {PICKER_TOKEN_HEADER: shim.picker_token} + + def test_picker_html_renders_self_contained_page(): - html = _picker_html() + html = _picker_html("test-token") assert html.startswith("") assert "/api/models" in html assert "/api/switch" in html + assert PICKER_TOKEN_HEADER in html + assert 'const PICKER_TOKEN = "test-token";' in html + + +def test_picker_html_json_escapes_token(): + token = 'tok"\'' + html = _picker_html(token) + assert 'const PICKER_TOKEN = "tok\\"\'\\u003c/script>";' in html + assert "