Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,33 @@ on:
branches: [main]

jobs:
lint:
name: lint + type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install package + dev extras
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Ruff lint (blocking)
run: python -m ruff check codex_shim/ tests/
- name: Mypy type check (report-only)
# The codebase is not yet fully typed; surface issues without failing CI.
run: python -m mypy codex_shim/ || true

test:
name: pytest (${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -27,5 +47,5 @@ jobs:
python -m pip install -e ".[dev]"
- name: Compile check
run: python -m compileall codex_shim/ -q
- name: Run tests
run: python -m pytest tests/ -q
- name: Run tests with coverage
run: python -m pytest tests/ -q --cov=codex_shim --cov-report=term-missing --cov-fail-under=70
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ venv/
build/
dist/
*.egg-info/
.coverage
.coverage.*
coverage.xml
htmlcov/
.mypy_cache/
.ruff_cache/

# Editor / OS
.DS_Store
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,15 @@ codex-shim patch-app
codex-shim restore-app
```

`patch-app` rewrites the Codex Desktop `app.asar` using a **pinned** ASAR packer
(`@electron/asar@4.2.0`) rather than an unpinned `npx --yes asar`, so a registry
compromise of the latest `asar` release cannot inject code into the bundle. To
use a mirror or a newer audited release, set `CODEX_SHIM_ASAR_PACKAGE`, e.g.:

```bash
CODEX_SHIM_ASAR_PACKAGE="@electron/asar@4.2.0" codex-shim patch-app
```

If Codex still crashes after `patch-app`, restore with `codex-shim restore-app`
and re-check the manual patch needles against the installed Desktop build.

Expand Down Expand Up @@ -956,8 +965,16 @@ server is reachable.
- 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.
- The full-request debug dump (`.codex-shim/last_request.json`) is **disabled by
default** because it contains the whole conversation body. Enable it only for
debugging with `CODEX_SHIM_DEBUG_DUMP=1`; when enabled, the file is written
private to your user (`0600`) inside a `0700` directory.
- ChatGPT passthrough reads `~/.codex/auth.json` at request time and forwards
the access token only to ChatGPT's Codex endpoint.
- Non-streaming upstream calls use a finite wall-clock timeout (default 300s) so
a stalled or unresponsive provider can't hang a worker indefinitely; streaming
(SSE) calls keep an unbounded read deadline but a bounded connect phase.
Override the non-streaming cap with `CODEX_SHIM_UPSTREAM_TIMEOUT` (seconds).
- If you put a prompt-catching proxy in front of the shim, that proxy controls
what it logs. Redact or hash large/private prompt bodies there.

Expand Down
33 changes: 31 additions & 2 deletions codex_shim/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@
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"
# Pin the ASAR packer used to rewrite the Codex Desktop app bundle. An unpinned
# `npx --yes asar` resolves whatever the registry currently serves and runs it
# immediately with the user's privileges against a trusted application bundle —
# a supply-chain hazard. Pin to an exact, auditable version (overridable via
# CODEX_SHIM_ASAR_PACKAGE for users who must use a mirror or a newer release).
DEFAULT_ASAR_PACKAGE = "@electron/asar@4.2.0"
ASAR_PACKAGE_ENV = "CODEX_SHIM_ASAR_PACKAGE"
MODEL_PICKER_NEEDLE = re.compile(
r"(?P<lhs>(?:let )?\w+=)"
r"(?:\w+\.useHiddenModels|\w+)"
Expand Down Expand Up @@ -751,6 +758,27 @@ def _quit_codex_app() -> None:
pass


def _asar_package_spec() -> str:
"""The pinned `name@version` ASAR package spec used for patching.

Defaults to ``DEFAULT_ASAR_PACKAGE``; can be overridden via the
``CODEX_SHIM_ASAR_PACKAGE`` environment variable for mirrors or upgrades.
"""
return os.environ.get(ASAR_PACKAGE_ENV, "").strip() or DEFAULT_ASAR_PACKAGE


def _asar_command(*args: str) -> list[str]:
"""Build a pinned ``npx`` invocation of the ASAR packer.

``--package <pinned spec>`` forces npx to resolve and run the exact audited
release (``DEFAULT_ASAR_PACKAGE``) regardless of what is on ``PATH`` or in the
cache, instead of an unpinned ``npx --yes asar`` that runs whatever the
registry currently serves. ``--yes`` only auto-confirms that pinned package.
"""
spec = _asar_package_spec()
return ["npx", "--yes", "--package", spec, "asar", *args]


def patch_codex_app() -> int:
if sys.platform != "darwin":
print("patch-app is macOS-only; Windows MSIX Codex Desktop cannot be patched with this ASAR helper.", file=sys.stderr)
Expand Down Expand Up @@ -793,12 +821,13 @@ def patch_codex_app() -> int:
shutil.rmtree(workdir)
workdir.mkdir(parents=True)

subprocess.run(["npx", "--yes", "asar", "extract", str(app_asar), str(workdir)], check=True)
print(f"Using pinned ASAR packer {_asar_package_spec()} (override with {ASAR_PACKAGE_ENV}).")
subprocess.run(_asar_command("extract", str(app_asar), str(workdir)), check=True)
changed = _patch_codex_desktop_bundles(workdir)
if changed is None:
return 1
if changed:
subprocess.run(["npx", "--yes", "asar", "pack", str(workdir), str(app_asar)], check=True)
subprocess.run(_asar_command("pack", str(workdir), str(app_asar)), check=True)
_update_app_asar_integrity(app_asar, info_plist)
_resign_codex_app(codex_app)
return 0
Expand Down
68 changes: 60 additions & 8 deletions codex_shim/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import json
import os
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
Expand All @@ -41,7 +42,53 @@
DEFAULT_MAX_TOKENS = 600

_CACHE_MAX = 256
_cache: dict[str, str] = {}


class RouterCache:
"""Bounded LRU cache mapping a task signal to a chosen candidate slug.

Replaces the previous module-level ``dict`` whose only eviction was a bulk
``clear()`` at capacity (flip-flopping between full and empty, and causing a
thundering herd of classifier calls). Each ``ShimServer`` owns its own
instance so separate servers/sessions don't share routing state, and the
cache is invalidated on model-config changes (e.g. picker model switch).

Note: keys use Python's built-in ``hash()`` of the task text, which is not
stable across process restarts (PYTHONHASHSEED); the cache is therefore only
valid for a single process lifetime.
"""

def __init__(self, maxsize: int = _CACHE_MAX) -> None:
self.maxsize = maxsize
self._store: "OrderedDict[str, str]" = OrderedDict()

def get(self, key: str) -> Optional[str]:
if key not in self._store:
return None
self._store.move_to_end(key)
return self._store[key]

def put(self, key: str, value: str) -> None:
self._store[key] = value
self._store.move_to_end(key)
while len(self._store) > self.maxsize:
self._store.popitem(last=False)

def clear(self) -> None:
self._store.clear()

def __len__(self) -> int:
return len(self._store)

def __contains__(self, key: object) -> bool:
return key in self._store


# Process-wide default cache, used when a caller does not supply its own.
# Kept for backwards compatibility with callers/tests that invoke resolve_auto
# without an explicit cache. New call sites (ShimServer) pass a per-instance
# cache so configuration changes can invalidate routing decisions.
_cache = RouterCache()

# An async callable that takes (system_prompt, user_content) and returns the
# classifier model's raw reply text.
Expand Down Expand Up @@ -468,8 +515,9 @@ def _cache_key(signal: dict[str, Any]) -> str:
return "%s|%s" % (signal["has_images"], hash(signal["task"]))


def reset_cache() -> None:
_cache.clear()
def reset_cache(cache: Optional[RouterCache] = None) -> None:
"""Clear a router cache (the process-wide default unless one is given)."""
(cache if cache is not None else _cache).clear()


# ---------------------------------------------------------------------------
Expand All @@ -482,9 +530,15 @@ async def resolve_auto(
classify: Optional[ClassifyFn],
*,
log: Optional[Callable[[str], None]] = None,
cache: Optional[RouterCache] = None,
) -> tuple[Optional[str], dict[str, Any]]:
"""Return the concrete candidate slug the Auto Router selects for this
request (or ``None`` if nothing is routable). Never raises."""
request (or ``None`` if nothing is routable). Never raises.

``cache`` lets the caller supply a per-instance :class:`RouterCache`; when
omitted the process-wide default is used."""

cache = cache if cache is not None else _cache

def _log(message: str) -> None:
if log:
Expand All @@ -499,7 +553,7 @@ def _log(message: str) -> None:
signal = task_signal(body)
key = _cache_key(signal)
if config.cache:
cached = _cache.get(key)
cached = cache.get(key)
if cached and any(c.slug == cached for c in candidates):
_log("[router] cache-hit -> %s" % cached)
return cached, {"reason": "cache", "scores": {}}
Expand All @@ -525,9 +579,7 @@ def _log(message: str) -> None:
why = "empty scores; fallback"
score = 0.0
if config.cache and pick:
if len(_cache) >= _CACHE_MAX:
_cache.clear()
_cache[key] = pick
cache.put(key, pick)
_log(
"[router] -> %s (score=%.2f; %s) scores=%s"
% (pick, score, why, json.dumps({c.slug: round(scores.get(c.slug, 0.0), 2) for c in candidates}))
Expand Down
Loading
Loading