From e49cc163eac0e28804263d17be3f01e6c9d51a75 Mon Sep 17 00:00:00 2001 From: "Rodi (Digital Shadow)" Date: Sun, 26 Apr 2026 18:04:29 -0700 Subject: [PATCH 1/4] Handle Telegram TLS CA failures --- .env.example | 4 ++ README.md | 8 +++ codex_relay.py | 110 ++++++++++++++++++++++++++++++++++++++-- scripts/configure.py | 115 +++++++++++++++++++++++++++++++++++++++++- scripts/smoke_test.py | 61 ++++++++++++++++++++++ 5 files changed, 294 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index e2a8009..338c58d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_ALLOWED_USER_ID= TELEGRAM_ALLOWED_CHAT_ID= +# Optional. Use only when your Python install or corporate proxy needs a +# specific CA bundle to verify https://api.telegram.org. +CODEX_RELAY_CA_FILE= + # Telegram messages can make local edits through Codex. Defaults to your home folder. CODEX_RELAY_USER_NAME= CODEX_RELAY_ASSISTANT_NAME=Codex diff --git a/README.md b/README.md index 6a90c10..9fbb485 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,14 @@ cd codex-relay The installer verifies the bot token, gives you a one-time `/start` code, allow-lists your private Telegram DM, installs the LaunchAgent, and runs `doctor.sh`. +If token verification fails with `CERTIFICATE_VERIFY_FAILED`, rerun after fixing Python's CA bundle: + +```bash +open "/Applications/Python 3.x/Install Certificates.command" +``` + +Codex Relay also retries Telegram HTTPS calls with the active Python CA path, `certifi` when installed, and common macOS/Homebrew CA bundles. On managed networks that use HTTPS inspection, set `CODEX_RELAY_CA_FILE=/path/to/your-ca.pem` in `.env` instead of disabling TLS verification. + Then DM your bot: ```text diff --git a/codex_relay.py b/codex_relay.py index e595e02..09dabbc 100755 --- a/codex_relay.py +++ b/codex_relay.py @@ -10,6 +10,7 @@ import re import signal import shutil +import ssl import subprocess import sys import tempfile @@ -54,6 +55,18 @@ "image/png": ".png", "image/webp": ".webp", } +CA_FILE_KEYS = ("CODEX_RELAY_CA_FILE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE") +COMMON_CA_FILES = ( + "/etc/ssl/cert.pem", + "/opt/homebrew/etc/openssl@3/cert.pem", + "/opt/homebrew/etc/ca-certificates/cert.pem", + "/usr/local/etc/openssl@3/cert.pem", + "/usr/local/etc/ca-certificates/cert.pem", +) + + +class TelegramTLSCertificateError(RuntimeError): + """Raised when Python cannot verify Telegram's HTTPS certificate.""" def load_dotenv(path: Path = ENV_PATH) -> None: @@ -175,6 +188,91 @@ def parse_id_set(*names: str) -> set[int]: return values +def candidate_ca_files() -> list[Path]: + candidates: list[str] = [] + for key in CA_FILE_KEYS: + value = os.environ.get(key, "").strip() + if value: + candidates.append(value) + + paths = ssl.get_default_verify_paths() + candidates.extend([paths.cafile or "", paths.openssl_cafile or ""]) + + try: + import certifi # type: ignore[import-not-found] + + candidates.append(certifi.where()) + except Exception: + pass + + candidates.extend(COMMON_CA_FILES) + + seen: set[Path] = set() + usable: list[Path] = [] + for raw in candidates: + if not raw: + continue + path = Path(raw).expanduser() + if path in seen or not path.is_file(): + continue + seen.add(path) + usable.append(path) + return usable + + +def is_certificate_error(exc: BaseException) -> bool: + reason = getattr(exc, "reason", exc) + if isinstance(reason, ssl.SSLError): + return True + return "CERTIFICATE_VERIFY_FAILED" in str(reason) + + +def python_org_certificate_command() -> str: + version = f"{sys.version_info.major}.{sys.version_info.minor}" + command = Path(f"/Applications/Python {version}/Install Certificates.command") + if command.exists(): + return f'open "{command}"' + return 'open "/Applications/Python 3.x/Install Certificates.command"' + + +def certificate_error_message(exc: BaseException, tried: list[Path]) -> str: + tried_text = ", ".join(str(path) for path in tried) or "none found" + return ( + "Could not verify Telegram's HTTPS certificate.\n" + f"Original error: {getattr(exc, 'reason', exc)}\n" + f"Tried CA bundles: {tried_text}\n\n" + "Fix one of these, then rerun ./scripts/install.sh:\n" + f"- python.org macOS Python: run {python_org_certificate_command()}\n" + "- Homebrew/system Python: make sure /etc/ssl/cert.pem or Homebrew ca-certificates exists.\n" + "- Corporate or security proxy: set CODEX_RELAY_CA_FILE=/path/to/your-ca.pem in .env.\n\n" + "Do not bypass TLS verification for a bot token." + ) + + +def telegram_urlopen(request: urllib.request.Request, timeout: int): + try: + return urllib.request.urlopen(request, timeout=timeout) + except urllib.error.URLError as exc: + if not is_certificate_error(exc): + raise + first_error: BaseException = exc + + tried: list[Path] = [] + for ca_file in candidate_ca_files(): + tried.append(ca_file) + try: + context = ssl.create_default_context(cafile=str(ca_file)) + return urllib.request.urlopen(request, timeout=timeout, context=context) + except urllib.error.URLError as exc: + if not is_certificate_error(exc): + raise + first_error = exc + except ssl.SSLError as exc: + first_error = exc + + raise TelegramTLSCertificateError(certificate_error_message(first_error, tried)) from first_error + + class TelegramAPI: def __init__(self, token: str) -> None: self.token = token @@ -184,11 +282,13 @@ def call(self, method: str, params: Optional[dict[str, Any]] = None) -> dict[str data = urllib.parse.urlencode(params or {}).encode() request = urllib.request.Request(self.base + method, data=data, method="POST") try: - with urllib.request.urlopen(request, timeout=70) as response: + with telegram_urlopen(request, timeout=70) as response: payload = json.loads(response.read().decode()) except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace")[:600] raise RuntimeError(f"Telegram HTTP {exc.code}: {body}") from exc + except TelegramTLSCertificateError as exc: + raise RuntimeError(str(exc)) from exc except urllib.error.URLError as exc: raise RuntimeError(f"Telegram network error: {exc.reason}") from exc if not payload.get("ok"): @@ -252,11 +352,13 @@ def send_photo( method="POST", ) try: - with urllib.request.urlopen(request, timeout=70) as response: + with telegram_urlopen(request, timeout=70) as response: payload = json.loads(response.read().decode()) except urllib.error.HTTPError as exc: body_text = exc.read().decode(errors="replace")[:600] raise RuntimeError(f"Telegram HTTP {exc.code}: {body_text}") from exc + except TelegramTLSCertificateError as exc: + raise RuntimeError(str(exc)) from exc except urllib.error.URLError as exc: raise RuntimeError(f"Telegram network error: {exc.reason}") from exc if not payload.get("ok"): @@ -278,7 +380,7 @@ def download_file(self, file_path: str, max_bytes: Optional[int] = None) -> byte method="GET", ) try: - with urllib.request.urlopen(request, timeout=70) as response: + with telegram_urlopen(request, timeout=70) as response: announced = response.headers.get("Content-Length") if max_bytes is not None and announced: try: @@ -302,6 +404,8 @@ def download_file(self, file_path: str, max_bytes: Optional[int] = None) -> byte except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace")[:600] raise RuntimeError(f"Telegram file download HTTP {exc.code}: {body}") from exc + except TelegramTLSCertificateError as exc: + raise RuntimeError(str(exc)) from exc except urllib.error.URLError as exc: raise RuntimeError(f"Telegram file download error: {exc.reason}") from exc diff --git a/scripts/configure.py b/scripts/configure.py index 5cbc5d1..1370766 100755 --- a/scripts/configure.py +++ b/scripts/configure.py @@ -8,6 +8,7 @@ import os import secrets import shutil +import ssl import subprocess import sys import time @@ -20,6 +21,18 @@ ROOT = Path(__file__).resolve().parents[1] ENV_PATH = ROOT / ".env" +CA_FILE_KEYS = ("CODEX_RELAY_CA_FILE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE") +COMMON_CA_FILES = ( + "/etc/ssl/cert.pem", + "/opt/homebrew/etc/openssl@3/cert.pem", + "/opt/homebrew/etc/ca-certificates/cert.pem", + "/usr/local/etc/openssl@3/cert.pem", + "/usr/local/etc/ca-certificates/cert.pem", +) + + +class TelegramTLSCertificateError(RuntimeError): + """Raised when Python cannot verify Telegram's HTTPS certificate.""" def private_write(path: Path, text: str) -> None: @@ -47,6 +60,7 @@ def save_env(values: dict[str, str]) -> None: "TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USER_ID", "TELEGRAM_ALLOWED_CHAT_ID", + "CODEX_RELAY_CA_FILE", "CODEX_RELAY_USER_NAME", "CODEX_RELAY_ASSISTANT_NAME", "CODEX_RELAY_ASSISTANT_PERSONALITY", @@ -74,6 +88,98 @@ def save_env(values: dict[str, str]) -> None: private_write(ENV_PATH, "\n".join(lines) + "\n") +def apply_env_certificate_settings(values: dict[str, str]) -> None: + for key in CA_FILE_KEYS: + if values.get(key) and key not in os.environ: + os.environ[key] = values[key] + + +def candidate_ca_files() -> list[Path]: + candidates: list[str] = [] + for key in CA_FILE_KEYS: + value = os.environ.get(key, "").strip() + if value: + candidates.append(value) + + paths = ssl.get_default_verify_paths() + candidates.extend([paths.cafile or "", paths.openssl_cafile or ""]) + + try: + import certifi # type: ignore[import-not-found] + + candidates.append(certifi.where()) + except Exception: + pass + + candidates.extend(COMMON_CA_FILES) + + seen: set[Path] = set() + usable: list[Path] = [] + for raw in candidates: + if not raw: + continue + path = Path(raw).expanduser() + if path in seen or not path.is_file(): + continue + seen.add(path) + usable.append(path) + return usable + + +def is_certificate_error(exc: BaseException) -> bool: + reason = getattr(exc, "reason", exc) + if isinstance(reason, ssl.SSLError): + return True + return "CERTIFICATE_VERIFY_FAILED" in str(reason) + + +def python_org_certificate_command() -> str: + version = f"{sys.version_info.major}.{sys.version_info.minor}" + command = Path(f"/Applications/Python {version}/Install Certificates.command") + if command.exists(): + return f'open "{command}"' + return 'open "/Applications/Python 3.x/Install Certificates.command"' + + +def certificate_error_message(exc: BaseException, tried: list[Path]) -> str: + tried_text = ", ".join(str(path) for path in tried) or "none found" + return ( + "Could not verify Telegram's HTTPS certificate.\n" + f"Original error: {getattr(exc, 'reason', exc)}\n" + f"Tried CA bundles: {tried_text}\n\n" + "Fix one of these, then rerun ./scripts/install.sh:\n" + f"- python.org macOS Python: run {python_org_certificate_command()}\n" + "- Homebrew/system Python: make sure /etc/ssl/cert.pem or Homebrew ca-certificates exists.\n" + "- Corporate or security proxy: export CODEX_RELAY_CA_FILE=/path/to/your-ca.pem " + "or put that line in .env.\n\n" + "Do not bypass TLS verification for a bot token." + ) + + +def telegram_urlopen(request: urllib.request.Request, timeout: int): + try: + return urllib.request.urlopen(request, timeout=timeout) + except urllib.error.URLError as exc: + if not is_certificate_error(exc): + raise + first_error: BaseException = exc + + tried: list[Path] = [] + for ca_file in candidate_ca_files(): + tried.append(ca_file) + try: + context = ssl.create_default_context(cafile=str(ca_file)) + return urllib.request.urlopen(request, timeout=timeout, context=context) + except urllib.error.URLError as exc: + if not is_certificate_error(exc): + raise + first_error = exc + except ssl.SSLError as exc: + first_error = exc + + raise TelegramTLSCertificateError(certificate_error_message(first_error, tried)) from first_error + + def telegram_call(token: str, method: str, params: Optional[dict[str, str]] = None) -> dict: data = urllib.parse.urlencode(params or {}).encode() request = urllib.request.Request( @@ -81,7 +187,7 @@ def telegram_call(token: str, method: str, params: Optional[dict[str, str]] = No data=data, method="POST", ) - with urllib.request.urlopen(request, timeout=20) as response: + with telegram_urlopen(request, timeout=20) as response: payload = json.loads(response.read().decode()) if not payload.get("ok"): raise RuntimeError(str(payload)) @@ -147,6 +253,8 @@ def wait_for_start( offset = latest_update_offset(token) except urllib.error.HTTPError as exc: raise SystemExit(f"Telegram rejected the token: HTTP {exc.code}") from exc + except TelegramTLSCertificateError as exc: + raise SystemExit(str(exc)) from exc print("Authorize only your private Telegram DM.") if deep_link: print(f"Open {deep_link}") @@ -161,6 +269,8 @@ def wait_for_start( updates = telegram_call(token, "getUpdates", params).get("result", []) except urllib.error.HTTPError as exc: raise SystemExit(f"Telegram rejected the token: HTTP {exc.code}") from exc + except TelegramTLSCertificateError as exc: + raise SystemExit(str(exc)) from exc for update in updates: offset = int(update["update_id"]) + 1 match = enrollment_match(update, nonce) @@ -172,12 +282,15 @@ def wait_for_start( def main() -> int: values = load_env() + apply_env_certificate_settings(values) codex_bin = values.get("CODEX_BIN") or detect_codex() token = prompt_token(values.get("TELEGRAM_BOT_TOKEN", "")) try: bot = telegram_call(token, "getMe")["result"] except urllib.error.HTTPError as exc: raise SystemExit(f"Telegram rejected the token: HTTP {exc.code}") from exc + except TelegramTLSCertificateError as exc: + raise SystemExit(str(exc)) from exc username = bot.get("username") or "" user_id, chat_id = wait_for_start( token, diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 507da40..4a794e7 100755 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -5,6 +5,7 @@ import tempfile import os +import ssl import sys import threading import json @@ -12,6 +13,7 @@ import importlib.util import contextlib import io +import urllib.error from pathlib import Path from typing import Optional @@ -218,6 +220,35 @@ def fake_telegram_call(_token: str, _method: str, params: Optional[dict[str, str configure.secrets.token_hex = original_token_hex configure.telegram_call = original_telegram_call + old_configure_urlopen = configure.urllib.request.urlopen + old_configure_context = configure.ssl.create_default_context + old_configure_ca = os.environ.get("CODEX_RELAY_CA_FILE") + try: + with tempfile.NamedTemporaryFile() as ca_file: + os.environ["CODEX_RELAY_CA_FILE"] = ca_file.name + calls = [] + + def fake_configure_urlopen(*_args: object, **kwargs: object) -> FakeResponse: + calls.append(kwargs) + if "context" not in kwargs: + raise urllib.error.URLError( + ssl.SSLError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") + ) + return FakeResponse([b'{"ok": true, "result": {"username": "bot"}}']) + + configure.ssl.create_default_context = lambda cafile=None: ("context", cafile) + configure.urllib.request.urlopen = fake_configure_urlopen + payload = configure.telegram_call("token", "getMe") + assert_true(payload["result"]["username"] == "bot", "expected configure CA fallback") + assert_true(len(calls) == 2, "expected configure to retry TLS with CA bundle") + finally: + configure.urllib.request.urlopen = old_configure_urlopen + configure.ssl.create_default_context = old_configure_context + if old_configure_ca is None: + os.environ.pop("CODEX_RELAY_CA_FILE", None) + else: + os.environ["CODEX_RELAY_CA_FILE"] = old_configure_ca + fake_enroll = FakeTelegram() relay.handle_message( fake_enroll, @@ -262,7 +293,36 @@ def fake_telegram_call(_token: str, _method: str, params: Optional[dict[str, str assert_true("deep check: /tools" in health, "expected health to point at deep check") old_urlopen = relay.urllib.request.urlopen + old_context = relay.ssl.create_default_context try: + with tempfile.NamedTemporaryFile() as ca_file: + old_ca = os.environ.get("CODEX_RELAY_CA_FILE") + try: + os.environ["CODEX_RELAY_CA_FILE"] = ca_file.name + calls = [] + + def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: + calls.append(kwargs) + if "context" not in kwargs: + raise urllib.error.URLError( + ssl.SSLError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") + ) + return FakeResponse([b'{"ok": true, "result": {"id": 1}}']) + + relay.ssl.create_default_context = lambda cafile=None: ("context", cafile) + relay.urllib.request.urlopen = fake_relay_tls_urlopen + assert_true( + relay.TelegramAPI("token").call("getMe")["result"]["id"] == 1, + "expected runtime CA fallback", + ) + assert_true(len(calls) == 2, "expected runtime to retry TLS with CA bundle") + finally: + if old_ca is None: + os.environ.pop("CODEX_RELAY_CA_FILE", None) + else: + os.environ["CODEX_RELAY_CA_FILE"] = old_ca + + relay.ssl.create_default_context = old_context relay.urllib.request.urlopen = lambda *_args, **_kwargs: FakeResponse([b"ok"]) assert_true(relay.TelegramAPI("token").download_file("file.jpg", max_bytes=2) == b"ok", "expected bounded download") relay.urllib.request.urlopen = lambda *_args, **_kwargs: FakeResponse([b"abc"]) @@ -281,6 +341,7 @@ def fake_telegram_call(_token: str, _method: str, params: Optional[dict[str, str raise SystemExit("expected oversized content-length failure") finally: relay.urllib.request.urlopen = old_urlopen + relay.ssl.create_default_context = old_context job = relay.RelayJob(123, "main", 2) relay.register_job(job) From bf95a6b3c610b6f2994523aef3cb61ffd94e6c0b Mon Sep 17 00:00:00 2001 From: "Rodi (Digital Shadow)" Date: Sun, 26 Apr 2026 19:10:14 -0700 Subject: [PATCH 2/4] Add optional Gemini natural relay --- .env.example | 11 ++ README.md | 21 ++ codex_relay.py | 434 +++++++++++++++++++++++++++++++++++++++++- scripts/configure.py | 12 ++ scripts/smoke_test.py | 71 +++++++ 5 files changed, 548 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 338c58d..cbacad5 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,17 @@ CODEX_RELAY_CA_FILE= CODEX_RELAY_USER_NAME= CODEX_RELAY_ASSISTANT_NAME=Codex CODEX_RELAY_ASSISTANT_PERSONALITY= + +# Optional Gemini mobile assistant layer. Leave the key empty to disable it. +# When configured, natural Telegram requests can be translated into relay actions, +# and Codex replies can be polished for readability. +CODEX_RELAY_GEMINI_API_KEY= +CODEX_RELAY_GEMINI_ENABLED=true +CODEX_RELAY_GEMINI_MODEL=gemini-3.1-flash-lite-preview +CODEX_RELAY_GEMINI_NATURAL_COMMANDS=true +CODEX_RELAY_GEMINI_POLISH=true +CODEX_RELAY_GEMINI_TIMEOUT_SECONDS=20 + CODEX_TELEGRAM_WORKDIR= CODEX_BIN=/Applications/Codex.app/Contents/Resources/codex CODEX_TELEGRAM_SANDBOX=danger-full-access diff --git a/README.md b/README.md index 9fbb485..f312b83 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,26 @@ open "/Applications/Python 3.x/Install Certificates.command" Codex Relay also retries Telegram HTTPS calls with the active Python CA path, `certifi` when installed, and common macOS/Homebrew CA bundles. On managed networks that use HTTPS inspection, set `CODEX_RELAY_CA_FILE=/path/to/your-ca.pem` in `.env` instead of disabling TLS verification. + +### Optional Gemini Assist + +After the first install, you can add a Gemini API key to make Telegram behave more like a mobile control harness for Codex: + +```bash +CODEX_RELAY_GEMINI_API_KEY=your-gemini-api-key +CODEX_RELAY_GEMINI_MODEL=gemini-3.1-flash-lite-preview +CODEX_RELAY_GEMINI_NATURAL_COMMANDS=true +CODEX_RELAY_GEMINI_POLISH=true +``` + +Then run: + +```bash +./scripts/install_launch_agent.sh +``` + +With Gemini assist enabled, natural messages can map to relay actions before Codex runs. For example, `set my dir to /code/codex-relay and run a security audit` can set the active folder and start a Codex audit job. Codex still performs the repo work; Gemini only plans safe relay actions and optionally rewrites Codex's final answer to be easier to read on a phone. Messages that look like tokens, passwords, private keys, or `.env` content bypass Gemini and go straight to Codex. Use `/gemini` in Telegram to check the status. + Then DM your bot: ```text @@ -123,6 +143,7 @@ The demo proves the repo can run its smoke path without a Telegram token, local /brief terse replies for this thread /verbose detailed replies for this thread /update show local update command +/gemini optional Gemini assist status /reset clear the current Codex session /ping bridge check ``` diff --git a/codex_relay.py b/codex_relay.py index 09dabbc..8a39d8d 100755 --- a/codex_relay.py +++ b/codex_relay.py @@ -34,12 +34,43 @@ DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 DEFAULT_IMAGE_RETENTION_DAYS = 7 MAX_IMAGES_PER_MESSAGE = 4 +DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite-preview" +DEFAULT_GEMINI_TIMEOUT_SECONDS = 20 DEFAULT_REASONING_EFFORT = "xhigh" REASONING_EFFORTS = {"low", "medium", "high", "xhigh"} DEFAULT_REPLY_STYLE = "brief" REPLY_STYLES = {"brief", "verbose"} SESSION_RE = re.compile(r"session id:\s*([0-9a-fA-F-]{36})", re.IGNORECASE) THREAD_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,39}$") +GEMINI_ACTIONS = { + "none", + "set_workdir", + "new_thread", + "use_thread", + "reset_thread", + "run_codex", + "show_status", + "show_help", +} +GEMINI_SENSITIVE_TERMS = { + ".env", + "api key", + "apikey", + "authorization:", + "bearer ", + "gemini_api_key", + "openai_api_key", + "password", + "private key", + "secret", + "ssh key", + "telegram_bot_token", + "token", + "x-goog-api-key", +} +GEMINI_SECRET_VALUE_RE = re.compile( + r"(sk-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{20,}|[0-9]{8,10}:[A-Za-z0-9_-]{30,})" +) STARTED_AT = time.time() THREADS_LOCK = threading.Lock() SHUTDOWN_EVENT = threading.Event() @@ -835,8 +866,12 @@ def resolve_workdir(raw: str, current: str) -> Path: raise ValueError("Give me a folder, like `/cd Projects/my-repo`.") if value == ".": path = Path(current) - elif value.startswith("/") or value.startswith("~"): + elif value.startswith("~"): path = Path(value).expanduser() + elif value.startswith("/"): + path = Path(value) + if not path.exists() and (value == "/code" or value.startswith("/code/")): + path = Path.home() / value.lstrip("/") else: path = Path.home() / value path = path.resolve() @@ -869,6 +904,365 @@ def authorized( return False +def gemini_api_key() -> str: + return ( + os.environ.get("CODEX_RELAY_GEMINI_API_KEY", "").strip() + or os.environ.get("GEMINI_API_KEY", "").strip() + ) + + +def gemini_configured() -> bool: + return bool(gemini_api_key()) + + +def gemini_enabled() -> bool: + if not gemini_configured(): + return False + return env_bool("CODEX_RELAY_GEMINI_ENABLED", True) + + +def gemini_natural_commands_enabled() -> bool: + return gemini_enabled() and env_bool("CODEX_RELAY_GEMINI_NATURAL_COMMANDS", True) + + +def gemini_polish_enabled() -> bool: + return gemini_enabled() and env_bool("CODEX_RELAY_GEMINI_POLISH", True) + + +def gemini_model() -> str: + return os.environ.get("CODEX_RELAY_GEMINI_MODEL", DEFAULT_GEMINI_MODEL).strip() or DEFAULT_GEMINI_MODEL + + +def gemini_allows_text(*values: str) -> bool: + combined = "\n".join(value for value in values if value) + if not combined.strip(): + return True + lowered = combined.lower() + if any(term in lowered for term in GEMINI_SENSITIVE_TERMS): + return False + return GEMINI_SECRET_VALUE_RE.search(combined) is None + + +def gemini_timeout() -> int: + return max(1, env_int("CODEX_RELAY_GEMINI_TIMEOUT_SECONDS", DEFAULT_GEMINI_TIMEOUT_SECONDS)) + + +def gemini_response_text(payload: dict[str, Any]) -> str: + candidates = payload.get("candidates") or [] + if not candidates: + raise RuntimeError("Gemini returned no candidates") + parts = ((candidates[0].get("content") or {}).get("parts") or []) + text_parts = [str(part.get("text") or "") for part in parts if part.get("text")] + text = "".join(text_parts).strip() + if not text: + raise RuntimeError("Gemini returned no text") + return text + + +def gemini_generate(prompt: str, response_schema: Optional[dict[str, Any]] = None) -> str: + key = gemini_api_key() + if not key: + raise RuntimeError("Gemini API key is not configured") + model = gemini_model() + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + + urllib.parse.quote(model, safe="") + + ":generateContent" + ) + generation_config: dict[str, Any] = {"temperature": 0.2} + if response_schema is not None: + generation_config.update( + { + "responseMimeType": "application/json", + "responseJsonSchema": response_schema, + } + ) + body = { + "contents": [ + { + "role": "user", + "parts": [{"text": prompt}], + } + ], + "generationConfig": generation_config, + } + request = urllib.request.Request( + url, + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "x-goog-api-key": key, + }, + method="POST", + ) + try: + with telegram_urlopen(request, timeout=gemini_timeout()) as response: + payload = json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"Gemini HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Gemini network error: {exc.reason}") from exc + return gemini_response_text(payload) + + +GEMINI_PLAN_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "reply": { + "type": "string", + "description": "Short optional note to send before any Codex job starts.", + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": sorted(GEMINI_ACTIONS), + "description": "The relay action to run.", + }, + "value": { + "type": "string", + "description": "Thread name, folder path, or empty string, depending on action type.", + }, + "prompt": { + "type": "string", + "description": "Prompt to send to Codex for run_codex actions.", + }, + }, + "required": ["type"], + }, + }, + }, + "required": ["actions"], +} + + +def gemini_plan_prompt( + text: str, + active_name: str, + thread: dict[str, Any], + threads: dict[str, dict[str, Any]], +) -> str: + thread_lines = [] + for name in sorted(threads)[:20]: + item = threads[name] + status = "started" if item.get("session_id") else "new" + thread_lines.append(f"- {name}: {status}; folder={item.get('workdir') or default_workdir()}") + thread_text = "\n".join(thread_lines) or "- main: new" + return f"""You are Codex Relay's mobile control planner. + +Translate a private Telegram message into safe relay actions. The relay controls Codex locally; Codex does all repo/file/test/security work. You only choose relay actions. + +Current active thread: {active_name} +Current folder: {thread.get('workdir') or default_workdir()} +Known threads: +{thread_text} + +Allowed actions: +- set_workdir: set the active thread folder. For user shorthand like /code/name or code/name, return code/name or ~/code/name, not the filesystem root /code unless they clearly mean an absolute path. +- new_thread: create and switch to a named thread. +- use_thread: switch to an existing named thread. +- reset_thread: clear the current Codex session id. +- run_codex: start a Codex job with a clear prompt. Use this for audits, edits, summaries, research in a repo, diagnostics, and normal work. +- show_status: show current relay status. +- show_help: show relay command help. +- none: use only when the message is not actionable. + +Rules: +- Return JSON only. +- Prefer one set_workdir followed by one run_codex for messages like "set my dir to X and run Y". +- Do not invent unsupported slash commands. +- Do not request or expose secrets, tokens, raw logs, auth files, or private transcripts. +- If the user asks for destructive, public, payment, account, medical, legal, or financial actions, still send the request to Codex as run_codex and let Codex stop at the confirmation boundary. +- Keep run_codex prompts direct and complete enough for Codex to execute. + +Telegram message: +{text} +""" + + +def validate_gemini_plan(raw: Any, original_text: str) -> dict[str, Any]: + if not isinstance(raw, dict): + return {"reply": "", "actions": []} + actions: list[dict[str, str]] = [] + for item in raw.get("actions") or []: + if not isinstance(item, dict): + continue + action_type = str(item.get("type") or "").strip() + if action_type not in GEMINI_ACTIONS or action_type == "none": + continue + action = { + "type": action_type, + "value": str(item.get("value") or "").strip()[:500], + "prompt": str(item.get("prompt") or "").strip()[:6000], + } + if action_type == "run_codex" and not action["prompt"]: + action["prompt"] = original_text + actions.append(action) + if len(actions) >= 4: + break + return {"reply": str(raw.get("reply") or "").strip()[:1000], "actions": actions} + + +def gemini_plan_for_message( + text: str, + active_name: str, + thread: dict[str, Any], + threads: dict[str, dict[str, Any]], +) -> dict[str, Any]: + prompt = gemini_plan_prompt(text, active_name, thread, threads) + response_text = gemini_generate(prompt, GEMINI_PLAN_SCHEMA) + return validate_gemini_plan(json.loads(response_text), text) + + +def gemini_polish_answer(prompt_text: str, answer: str, thread: dict[str, Any]) -> str: + if not gemini_polish_enabled() or not answer.strip(): + return answer + if not gemini_allows_text(prompt_text, answer): + return answer + prompt = f"""Rewrite this Codex Relay reply for a private Telegram chat. + +Goal: make it more human, readable, and easy to act on from a phone. + +Rules: +- Preserve every factual claim, command, path, file name, warning, blocker, and verification result. +- Do not add new facts or pretend extra work happened. +- Do not reveal or request secrets. +- Keep it concise. Use short paragraphs or bullets only when they improve scanability. + +Original user request: +{prompt_text} + +Active folder: +{thread.get('workdir') or default_workdir()} + +Codex reply: +{answer} +""" + try: + polished = gemini_generate(prompt).strip() + except Exception: + return answer + return polished or answer + + +def execute_gemini_plan( + api: TelegramAPI, + chat_id: int, + message_id: Optional[int], + threads_path: Path, + plan: dict[str, Any], + original_text: str, +) -> bool: + actions = plan.get("actions") or [] + if not actions: + return False + notes: list[str] = [] + for action in actions: + action_type = action.get("type") + value = str(action.get("value") or "").strip() + if action_type == "show_help": + api.send_message(chat_id, command_help(), message_id) + return True + if action_type == "show_status": + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + api.send_message(chat_id, status_text(thread, chat_id), message_id) + return True + if action_type == "new_thread": + try: + name = normalize_thread_name(value) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + with THREADS_LOCK: + data, _active_name, _thread = active_state(threads_path, chat_id) + thread = ensure_thread(data, chat_id, name) + thread["session_id"] = "" + thread["updated_at"] = now_iso() + set_active_thread(data, chat_id, name) + write_threads(threads_path, data) + notes.append(f"New thread: {name}") + continue + if action_type == "use_thread": + try: + name = normalize_thread_name(value) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + with THREADS_LOCK: + data, _active_name, _thread = active_state(threads_path, chat_id) + threads = chat_threads(data, chat_id) + if name not in threads: + api.send_message(chat_id, f"No thread named `{name}`. Use `/new {name}`.", message_id) + return True + set_active_thread(data, chat_id, name) + write_threads(threads_path, data) + notes.append(f"Using thread: {name}") + continue + if action_type == "reset_thread": + with THREADS_LOCK: + data, active_name, thread = active_state(threads_path, chat_id) + busy = busy_thread_message(chat_id, active_name) + if busy: + api.send_message(chat_id, busy, message_id) + return True + with THREADS_LOCK: + data, active_name, thread = active_state(threads_path, chat_id) + thread["session_id"] = "" + thread["updated_at"] = now_iso() + write_threads(threads_path, data) + notes.append(f"Reset thread: {active_name}") + continue + if action_type == "set_workdir": + with THREADS_LOCK: + data, active_name, thread = active_state(threads_path, chat_id) + current_workdir = str(thread.get("workdir") or default_workdir()) + busy = busy_thread_message(chat_id, active_name) + if busy: + api.send_message(chat_id, busy, message_id) + return True + try: + path = resolve_workdir(value, current_workdir) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + with THREADS_LOCK: + data, active_name, thread = active_state(threads_path, chat_id) + thread["workdir"] = str(path) + thread["updated_at"] = now_iso() + write_threads(threads_path, data) + notes.append(f"Folder set:\n{path}") + continue + if action_type == "run_codex": + prompt = str(action.get("prompt") or "").strip() or original_text + with THREADS_LOCK: + _data, active_name, thread = active_state(threads_path, chat_id) + busy = busy_thread_message(chat_id, active_name) + if busy: + api.send_message(chat_id, busy, message_id) + return True + preface = "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])) + if preface: + api.send_message(chat_id, preface, message_id) + start_background_job( + api, + chat_id, + threads_path, + active_name, + thread, + prompt, + reply_to_message_id=message_id, + ) + return True + if notes or plan.get("reply"): + api.send_message(chat_id, "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])), message_id) + return True + return False + + def relay_user_name() -> str: return os.environ.get("CODEX_RELAY_USER_NAME", "the user").strip() or "the user" @@ -1187,6 +1581,7 @@ def command_help() -> str: "/verbose - detailed replies for this thread", "/update - show local update command", "/capabilities - show what this remote can do", + "/gemini - show optional Gemini assist status", "/try - show good first prompts", "/tools - probe Codex tool access", "/reset - restart the current thread", @@ -1235,6 +1630,12 @@ def health_text() -> str: "not running", ), ("reply style", True, reply_style_default(), ""), + ( + "gemini", + True, + f"enabled; {gemini_model()}" if gemini_enabled() else "disabled", + "", + ), ( "model", True, @@ -1268,6 +1669,7 @@ def status_text(thread: dict[str, Any], chat_id: Optional[int] = None) -> str: f"group chats: {'enabled' if env_bool('CODEX_TELEGRAM_ALLOW_GROUP_CHATS', False) else 'disabled'}", f"typing interval: {max(1, env_int('CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS', 4))}s", "telegram images: enabled", + f"gemini assist: {'enabled' if gemini_enabled() else 'disabled'}", f"running jobs: {len(running)}", ] lines.extend(last_run_lines(thread)) @@ -1488,6 +1890,8 @@ def run_job_worker( append_history_event( history_event_from_stats(chat_id, thread_name, thread, job, stats) ) + if stats.get("last_status") == "ok": + answer = gemini_polish_answer(prompt_text, answer, thread) api.send_message(chat_id, answer) except Exception as exc: append_history_event( @@ -1847,6 +2251,19 @@ def handle_message( api.send_message(chat_id, try_text(), message_id) return + if command in {"/gemini", "/assistant"}: + lines = [ + "Gemini assist:", + f"- status: {'enabled' if gemini_enabled() else 'disabled'}", + f"- model: {gemini_model()}", + f"- natural commands: {'enabled' if gemini_natural_commands_enabled() else 'disabled'}", + f"- polish: {'enabled' if gemini_polish_enabled() else 'disabled'}", + ] + if not gemini_configured(): + lines.append("- setup: add CODEX_RELAY_GEMINI_API_KEY to .env and run ./scripts/install_launch_agent.sh") + api.send_message(chat_id, "\n".join(lines), message_id) + return + if command == "/update": api.send_message(chat_id, update_text(), message_id) return @@ -1907,6 +2324,17 @@ def handle_message( api.send_message(chat_id, "Unknown command. Use /help.", message_id) return + if gemini_natural_commands_enabled() and not image_specs and gemini_allows_text(text): + try: + with THREADS_LOCK: + _data, active_name, thread = active_state(threads_path, chat_id) + threads = dict(chat_threads(_data, chat_id)) + plan = gemini_plan_for_message(text, active_name, thread, threads) + if execute_gemini_plan(api, chat_id, message_id, threads_path, plan, text): + return + except Exception: + pass + with THREADS_LOCK: _data, active_name, thread = active_state(threads_path, chat_id) image_paths: list[Path] = [] @@ -1949,6 +2377,10 @@ def check_config() -> int: print(f"model={os.environ.get('CODEX_TELEGRAM_MODEL', 'gpt-5.5')}") print(f"reasoning_effort={env_choice('CODEX_TELEGRAM_REASONING_EFFORT', DEFAULT_REASONING_EFFORT, REASONING_EFFORTS)}") print(f"reply_style={reply_style_default()}") + print(f"gemini_enabled={gemini_enabled()}") + print(f"gemini_model={gemini_model()}") + print(f"gemini_natural_commands={gemini_natural_commands_enabled()}") + print(f"gemini_polish={gemini_polish_enabled()}") print(f"approval={os.environ.get('CODEX_TELEGRAM_APPROVAL', 'never')}") print(f"timeout_seconds={env_int('CODEX_TELEGRAM_TIMEOUT_SECONDS', DEFAULT_TIMEOUT_SECONDS)}") print(f"reply_threading={env_bool('CODEX_TELEGRAM_REPLY_TO_MESSAGES', False)}") diff --git a/scripts/configure.py b/scripts/configure.py index 1370766..2410282 100755 --- a/scripts/configure.py +++ b/scripts/configure.py @@ -64,6 +64,12 @@ def save_env(values: dict[str, str]) -> None: "CODEX_RELAY_USER_NAME", "CODEX_RELAY_ASSISTANT_NAME", "CODEX_RELAY_ASSISTANT_PERSONALITY", + "CODEX_RELAY_GEMINI_API_KEY", + "CODEX_RELAY_GEMINI_ENABLED", + "CODEX_RELAY_GEMINI_MODEL", + "CODEX_RELAY_GEMINI_NATURAL_COMMANDS", + "CODEX_RELAY_GEMINI_POLISH", + "CODEX_RELAY_GEMINI_TIMEOUT_SECONDS", "CODEX_TELEGRAM_WORKDIR", "CODEX_BIN", "CODEX_TELEGRAM_SANDBOX", @@ -306,6 +312,12 @@ def main() -> int: "CODEX_TELEGRAM_WORKDIR": values.get("CODEX_TELEGRAM_WORKDIR") or str(Path.home()), "CODEX_RELAY_ASSISTANT_NAME": values.get("CODEX_RELAY_ASSISTANT_NAME") or "Codex", "CODEX_RELAY_ASSISTANT_PERSONALITY": values.get("CODEX_RELAY_ASSISTANT_PERSONALITY") or "", + "CODEX_RELAY_GEMINI_API_KEY": values.get("CODEX_RELAY_GEMINI_API_KEY") or "", + "CODEX_RELAY_GEMINI_ENABLED": values.get("CODEX_RELAY_GEMINI_ENABLED") or "true", + "CODEX_RELAY_GEMINI_MODEL": values.get("CODEX_RELAY_GEMINI_MODEL") or "gemini-3.1-flash-lite-preview", + "CODEX_RELAY_GEMINI_NATURAL_COMMANDS": values.get("CODEX_RELAY_GEMINI_NATURAL_COMMANDS") or "true", + "CODEX_RELAY_GEMINI_POLISH": values.get("CODEX_RELAY_GEMINI_POLISH") or "true", + "CODEX_RELAY_GEMINI_TIMEOUT_SECONDS": values.get("CODEX_RELAY_GEMINI_TIMEOUT_SECONDS") or "20", "CODEX_BIN": codex_bin, "CODEX_TELEGRAM_SANDBOX": values.get("CODEX_TELEGRAM_SANDBOX") or "danger-full-access", "CODEX_TELEGRAM_MODEL": values.get("CODEX_TELEGRAM_MODEL") or "gpt-5.5", diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 4a794e7..4ee8a3f 100755 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -286,6 +286,7 @@ def fake_configure_urlopen(*_args: object, **kwargs: object) -> FakeResponse: assert_true("reply style: brief" in status, "expected reply style status") assert_true("group chats: disabled" in status, "expected group chat status") assert_true("reasoning effort: xhigh" in status, "expected default xhigh reasoning status") + assert_true("gemini assist: disabled" in status, "expected Gemini status") assert_true("running jobs: 0" in status, "expected running job count") assert_true("last run: ok; 1.2s; 1 image" in status, "expected last-run latency status") health = relay.health_text() @@ -424,6 +425,55 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: data["threads_by_chat"]["123"]["main"]["reply_style"] == "brief", "expected /brief to persist style", ) + natural_project = Path(tmp) / "natural-project" + natural_project.mkdir() + original_gemini_plan_for_message = relay.gemini_plan_for_message + original_start_background_job = relay.start_background_job + old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = "fake-gemini-key" + natural_jobs = [] + + def fake_gemini_plan_for_message(*_args: object) -> dict[str, object]: + return { + "actions": [ + {"type": "set_workdir", "value": str(natural_project)}, + {"type": "run_codex", "prompt": "Run a security audit."}, + ] + } + + def fake_natural_start_background_job(*args: object, **kwargs: object) -> None: + natural_jobs.append((args, kwargs)) + + relay.gemini_plan_for_message = fake_gemini_plan_for_message + relay.start_background_job = fake_natural_start_background_job + try: + relay.handle_message( + fake_style, + { + "message_id": 5, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "set my dir to this project and run a security audit", + }, + {1}, + {123}, + threads_path, + ) + finally: + relay.gemini_plan_for_message = original_gemini_plan_for_message + relay.start_background_job = original_start_background_job + if old_gemini_key is None: + os.environ.pop("CODEX_RELAY_GEMINI_API_KEY", None) + else: + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = old_gemini_key + data = relay.read_threads(threads_path) + assert_true( + data["threads_by_chat"]["123"]["main"]["workdir"] == str(natural_project.resolve()), + "expected Gemini natural command to update workdir", + ) + assert_true(natural_jobs, "expected Gemini natural command to start Codex job") + assert_true(natural_jobs[-1][0][5] == "Run a security audit.", "expected planned Codex prompt") + relay.handle_message( fake_style, { @@ -555,6 +605,27 @@ def fake_start_background_job(*args: object, **kwargs: object) -> None: assert_true("exit 9" in answer, "expected exit code in sanitized failure") assert_true(stats["last_status"] == "failed", "expected failed status") + old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") + original_gemini_generate = relay.gemini_generate + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = "fake-gemini-key" + relay.gemini_generate = lambda *_args, **_kwargs: "Polished answer" + try: + assert_true( + relay.gemini_polish_answer("prompt", "raw answer", {"workdir": tmp}) == "Polished answer", + "expected Gemini answer polish", + ) + assert_true( + relay.gemini_polish_answer("prompt", "SECRET_TOKEN=value", {"workdir": tmp}) == "SECRET_TOKEN=value", + "expected Gemini polish to skip sensitive text", + ) + assert_true(not relay.gemini_allows_text("set OPENAI_API_KEY=sk-12345678901234567890"), "expected Gemini secret guard") + finally: + relay.gemini_generate = original_gemini_generate + if old_gemini_key is None: + os.environ.pop("CODEX_RELAY_GEMINI_API_KEY", None) + else: + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = old_gemini_key + slow_codex = Path(tmp) / "slow-codex" slow_codex.write_text( "#!/bin/sh\n" From b97ac91a01cb6cebd22f947be52d2ddeee2e9e8f Mon Sep 17 00:00:00 2001 From: "Rodi (Digital Shadow)" Date: Tue, 28 Apr 2026 18:36:47 -0700 Subject: [PATCH 3/4] Expand Telegram mobile harness --- .env.example | 16 +- PHONE_REMOTE.md | 18 +- README.md | 29 +- codex_relay.py | 2109 +++++++++++++++++++++++++++++-- scripts/configure.py | 25 +- scripts/doctor.sh | 13 +- scripts/install.sh | 2 + scripts/install_launch_agent.sh | 41 + scripts/recover.sh | 73 ++ scripts/smoke_test.py | 418 +++++- scripts/status_ui.sh | 2 +- 11 files changed, 2640 insertions(+), 106 deletions(-) create mode 100755 scripts/recover.sh diff --git a/.env.example b/.env.example index cbacad5..3e868fb 100644 --- a/.env.example +++ b/.env.example @@ -18,18 +18,28 @@ CODEX_RELAY_ASSISTANT_PERSONALITY= # Optional Gemini mobile assistant layer. Leave the key empty to disable it. # When configured, natural Telegram requests can be translated into relay actions, # and Codex replies can be polished for readability. +# After install, you can also send /gemini key YOUR_GEMINI_API_KEY in Telegram. CODEX_RELAY_GEMINI_API_KEY= CODEX_RELAY_GEMINI_ENABLED=true CODEX_RELAY_GEMINI_MODEL=gemini-3.1-flash-lite-preview +CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS=4096 CODEX_RELAY_GEMINI_NATURAL_COMMANDS=true CODEX_RELAY_GEMINI_POLISH=true CODEX_RELAY_GEMINI_TIMEOUT_SECONDS=20 +CODEX_RELAY_GEMINI_ERROR_NOTICES=true + +# Recovery, file transfer, and terminal controls work without Gemini. +CODEX_RELAY_RECOVERY_TIMEOUT_SECONDS=1200 +CODEX_RELAY_TERMINAL_BUFFER_CHARS=20000 +CODEX_RELAY_TERMINAL_READ_LIMIT=4000 +CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER=false CODEX_TELEGRAM_WORKDIR= CODEX_BIN=/Applications/Codex.app/Contents/Resources/codex CODEX_TELEGRAM_SANDBOX=danger-full-access CODEX_TELEGRAM_MODEL=gpt-5.5 -CODEX_TELEGRAM_REASONING_EFFORT=xhigh +# Default Codex thinking mode. Legacy CODEX_TELEGRAM_REASONING_EFFORT is still accepted. +CODEX_TELEGRAM_THINKING_MODE=xhigh CODEX_TELEGRAM_REPLY_STYLE=brief CODEX_TELEGRAM_APPROVAL=never CODEX_TELEGRAM_TIMEOUT_SECONDS=600 @@ -37,5 +47,9 @@ CODEX_TELEGRAM_REPLY_TO_MESSAGES=false CODEX_TELEGRAM_REPLY_UNAUTHORIZED=false CODEX_TELEGRAM_ALLOW_GROUP_CHATS=false CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS=4 +CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS=25 +CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS=60 +CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE=10 CODEX_TELEGRAM_MAX_IMAGE_BYTES=20971520 +CODEX_TELEGRAM_MAX_FILE_BYTES=20971520 CODEX_TELEGRAM_IMAGE_RETENTION_DAYS=7 diff --git a/PHONE_REMOTE.md b/PHONE_REMOTE.md index 3397ed8..ccd6506 100644 --- a/PHONE_REMOTE.md +++ b/PHONE_REMOTE.md @@ -10,7 +10,7 @@ This is better than VNC when the goal is to command Codex, not manually drive a Phone prompt -> Telegram bot -> Mac LaunchAgent -> Codex CLI -> Telegram reply ``` -The default normal prompt path uses your configured Codex model and reasoning effort through the local Codex app CLI. +The default normal prompt path uses your configured Codex model and active thinking mode through the local Codex app CLI. ## Prompts That Fit @@ -36,11 +36,18 @@ Best prompts include a folder, a stopping point, and whether public actions are /tools /try /jobs +/queue +/activity +/terminal +/file README.md +/recover /automations /history +/gemini key YOUR_GEMINI_API_KEY /new school /cd Documents +/think high check what class files look important this week /new portfolio @@ -51,9 +58,10 @@ make the README feel pinned-worthy ## Response Timing - `/ping`, `/alive`, `/health`, `/policy`, `/screenshot`, `/status`, `/where`, `/list`, `/new`, and `/cd` should feel quick. -- `/jobs`, `/cancel`, and `/history` should work while Codex is busy. +- `/jobs`, `/cancel`, `/history`, `/activity`, `/queue`, `/terminal`, and `/file` should work while Codex is busy. +- New normal requests can queue while the active thread is busy, including saved images. `/forget`, `/forgetphotos`, and `/queue next id` adjust that queue without Gemini. - Normal prompts wait for Codex to finish. -- Normal prompts use your configured Codex model and reasoning effort. +- Normal prompts use your configured Codex model and active thinking mode. - Image, browser, repo-editing, test-running, and desktop/app-control prompts can take tens of seconds or minutes. Desktop/app-control behavior depends on what your local Codex runtime exposes. - If the request is public or irreversible, ask Codex to draft and stop before posting, pushing, paying, deleting, or changing accounts. @@ -70,6 +78,8 @@ make the README feel pinned-worthy This is a local Codex runtime, not a visible Codex Mac app thread. It can use the same signed-in Codex CLI/plugin setup, but it does not mirror the desktop chat UI. -Telegram photos and image documents are saved in the private runtime state directory and attached to the next Codex prompt. +Telegram photos, photo albums, and image documents are saved in the private runtime state directory and attached to the next Codex prompt or queued request. + +Gemini is optional. Slash commands are the primary control surface and work without Gemini; the mobile harness is powered by Flash 3.1 Lite and routes natural language to the same relay actions when configured. After the first install, `/gemini key YOUR_GEMINI_API_KEY` saves the key privately and reloads the relay without using the Mac screen. It is unofficial, uses your normal Codex/OpenAI account limits, and can only use tools exposed by the local Codex runtime on that Mac. diff --git a/README.md b/README.md index f312b83..c55f7fe 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,20 @@ Codex Relay also retries Telegram HTTPS calls with the active Python CA path, `c ### Optional Gemini Assist -After the first install, you can add a Gemini API key to make Telegram behave more like a mobile control harness for Codex: +After the first install, you can enable the Gemini mobile harness powered by Flash 3.1 Lite from Telegram, so the phone setup does not require opening the Mac: + +```text +/gemini key YOUR_GEMINI_API_KEY +``` + +The relay saves the key privately, enables natural commands and answer polish, and reloads the setting in the running process. It also attempts to delete the Telegram message that contained the key. `/gemini on`, `/gemini off`, and `/gemini clear` can manage it later. + +You can still configure it manually in `.env`: ```bash CODEX_RELAY_GEMINI_API_KEY=your-gemini-api-key CODEX_RELAY_GEMINI_MODEL=gemini-3.1-flash-lite-preview +CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS=4096 CODEX_RELAY_GEMINI_NATURAL_COMMANDS=true CODEX_RELAY_GEMINI_POLISH=true ``` @@ -78,7 +87,7 @@ Then run: ./scripts/install_launch_agent.sh ``` -With Gemini assist enabled, natural messages can map to relay actions before Codex runs. For example, `set my dir to /code/codex-relay and run a security audit` can set the active folder and start a Codex audit job. Codex still performs the repo work; Gemini only plans safe relay actions and optionally rewrites Codex's final answer to be easier to read on a phone. Messages that look like tokens, passwords, private keys, or `.env` content bypass Gemini and go straight to Codex. Use `/gemini` in Telegram to check the status. +With Gemini assist enabled, natural messages can map to relay actions before Codex runs. For example, `set my dir to /code/codex-relay and run a security audit` can set the active folder and start a Codex audit job. Codex still performs the repo work; Gemini only plans safe relay actions and optionally rewrites Codex's final answer to be easier to read on a phone. Gemini API key setup is handled by the relay before Codex sees the message; other messages that look like tokens, passwords, private keys, or `.env` content bypass Gemini. Use `/gemini` in Telegram to check the status. Then DM your bot: @@ -131,8 +140,10 @@ The demo proves the repo can run its smoke path without a Telegram token, local /jobs running jobs and last run /cancel id stop a running job /history recent run receipts, no prompt/response logs +/activity running jobs, pending queue, terminals, safe history /automations inspect Codex automations through Codex /tools quick Codex tool probe +/recover run local relay recovery /try useful first prompts /new name new Codex thread /use name switch threads @@ -140,15 +151,25 @@ The demo proves the repo can run its smoke path without a Telegram token, local /where show active folder /cd path set active folder /home set folder to ~ +/think mode set Codex thinking mode for this thread +/queue show or add pending requests +/forget id remove pending request +/forgetphotos remove saved images from pending requests +/terminal persistent interactive terminal sessions +/file path send a local file back to Telegram /brief terse replies for this thread /verbose detailed replies for this thread /update show local update command -/gemini optional Gemini assist status +/gemini optional mobile assist status/setup /reset clear the current Codex session /ping bridge check ``` -Normal messages go to the active thread. Captions on Telegram images become the prompt; image files are saved privately and attached to Codex. +Normal messages go to the active thread. If the thread is busy, normal messages and downloaded image attachments are queued and start when the active job clears. Captions on Telegram images become the prompt; image files are saved privately and attached to Codex. Telegram photo albums are batched into one Codex job with up to `CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE` images. + +Thinking modes are `low`, `medium`, `high`, and `xhigh`. `/think default` returns a thread to the configured default. Queue, image cleanup, file transfer, terminal sessions, recovery, and thinking-mode commands all work without Gemini. With Gemini assist enabled, natural messages can route to those same slash-command capabilities. + +Terminal sessions are PTY-backed and persist while the relay process is running: `/terminal open setup -- gh auth login`, `/terminal read setup`, `/terminal enter setup yes`, and `/terminal kill setup`. File transfer uses `/file path` from the active thread folder and blocks obvious secret/runtime files by default. ## Try It diff --git a/codex_relay.py b/codex_relay.py index 8a39d8d..b33be7d 100755 --- a/codex_relay.py +++ b/codex_relay.py @@ -7,8 +7,11 @@ import datetime as dt import json import os +import pty import re import signal +import select +import shlex import shutil import ssl import subprocess @@ -31,26 +34,66 @@ DEFAULT_THREAD = "main" TOOL_PROBE_THREAD = "tool-probe" DEFAULT_TIMEOUT_SECONDS = 600 +DEFAULT_TELEGRAM_POLL_TIMEOUT_SECONDS = 25 +DEFAULT_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS = 60 DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 +DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024 DEFAULT_IMAGE_RETENTION_DAYS = 7 -MAX_IMAGES_PER_MESSAGE = 4 +DEFAULT_MEDIA_GROUP_GRACE_SECONDS = 1.2 +DEFAULT_TERMINAL_READ_LIMIT = 4000 +MAX_IMAGES_PER_MESSAGE = 10 DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite-preview" DEFAULT_GEMINI_TIMEOUT_SECONDS = 20 +DEFAULT_GEMINI_MAX_OUTPUT_TOKENS = 4096 +DEFAULT_PROGRESS_INTERVAL_SECONDS = 20 +MAX_PROGRESS_LINES = 6 +MAX_PENDING_REQUESTS = 8 +MAX_PENDING_PROMPT_CHARS = 6000 DEFAULT_REASONING_EFFORT = "xhigh" REASONING_EFFORTS = {"low", "medium", "high", "xhigh"} -DEFAULT_REPLY_STYLE = "brief" -REPLY_STYLES = {"brief", "verbose"} +THINKING_MODE_ALIASES = { + "default": "default", + "env": "default", + "fast": "low", + "low": "low", + "medium": "medium", + "normal": "medium", + "standard": "medium", + "high": "high", + "deep": "high", + "x-high": "xhigh", + "xhigh": "xhigh", + "extra": "xhigh", + "extra-high": "xhigh", + "max": "xhigh", + "maximum": "xhigh", +} +DEFAULT_REPLY_STYLE = "normal" +REPLY_STYLES = {"brief", "normal", "verbose"} SESSION_RE = re.compile(r"session id:\s*([0-9a-fA-F-]{36})", re.IGNORECASE) THREAD_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,39}$") GEMINI_ACTIONS = { "none", + "queue_request", + "remove_pending_request", + "remove_pending_images", + "replace_pending_request", + "prioritize_pending_request", + "set_thinking_mode", "set_workdir", "new_thread", "use_thread", "reset_thread", "run_codex", + "show_activity", "show_status", + "show_queue", "show_help", + "terminal_open", + "terminal_read", + "terminal_send", + "terminal_kill", + "send_file", } GEMINI_SENSITIVE_TERMS = { ".env", @@ -71,11 +114,14 @@ GEMINI_SECRET_VALUE_RE = re.compile( r"(sk-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{20,}|[0-9]{8,10}:[A-Za-z0-9_-]{30,})" ) +GEMINI_API_KEY_RE = re.compile(r"\b(AIza[0-9A-Za-z_-]{20,})\b") STARTED_AT = time.time() THREADS_LOCK = threading.Lock() SHUTDOWN_EVENT = threading.Event() WORKERS_LOCK = threading.Lock() WORKERS: list[threading.Thread] = [] +TERMINALS_LOCK = threading.Lock() +TERMINALS: dict[str, "TerminalSession"] = {} IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"} IMAGE_SUFFIX_BY_MIME = { "image/gif": ".gif", @@ -143,6 +189,29 @@ def write_private_bytes(path: Path, content: bytes) -> None: os.chmod(path, 0o600) +def update_private_env_file(path: Path, updates: dict[str, str]) -> None: + lines = path.read_text().splitlines() if path.exists() else [ + "# Codex Relay private config. Do not commit this file." + ] + out: list[str] = [] + seen: set[str] = set() + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + out.append(line) + continue + key = line.split("=", 1)[0].strip() + if key in updates: + out.append(f"{key}={updates[key]}") + seen.add(key) + else: + out.append(line) + for key, value in updates.items(): + if key not in seen: + out.append(f"{key}={value}") + write_private_text(path, "\n".join(out).rstrip() + "\n") + + def read_private_bytes(path: Path) -> bytes: with open(path, "rb") as handle: return handle.read() @@ -177,6 +246,72 @@ def env_choice(name: str, default: str, allowed: set[str]) -> str: return value +def normalize_thinking_mode(raw: str, allow_default: bool = False) -> str: + value = raw.strip().lower().replace("_", "-") + mode = THINKING_MODE_ALIASES.get(value, value) + if allow_default and mode == "default": + return mode + if mode in REASONING_EFFORTS: + return mode + choices = ", ".join(sorted(REASONING_EFFORTS)) + if allow_default: + choices += ", default" + raise ValueError(f"Thinking mode must be one of: {choices}") + + +def thinking_mode_default() -> str: + raw = ( + os.environ.get("CODEX_TELEGRAM_THINKING_MODE", "").strip() + or os.environ.get("CODEX_TELEGRAM_REASONING_EFFORT", "").strip() + or DEFAULT_REASONING_EFFORT + ) + try: + return normalize_thinking_mode(raw) + except ValueError: + raise SystemExit("CODEX_TELEGRAM_THINKING_MODE must be one of: low, medium, high, xhigh") + + +def thread_thinking_mode(thread: dict[str, Any]) -> str: + raw = str(thread.get("thinking_mode") or thread.get("reasoning_effort") or "").strip() + if not raw: + return thinking_mode_default() + try: + return normalize_thinking_mode(raw) + except ValueError: + return thinking_mode_default() + + +def thinking_mode_source(thread: dict[str, Any]) -> str: + return "thread" if str(thread.get("thinking_mode") or thread.get("reasoning_effort") or "").strip() else "config" + + +def thinking_mode_status(thread: dict[str, Any]) -> str: + return f"{thread_thinking_mode(thread)} ({thinking_mode_source(thread)})" + + +def set_thinking_mode_text(thread: dict[str, Any], raw: str) -> str: + mode = normalize_thinking_mode(raw, allow_default=True) + if mode == "default": + thread.pop("thinking_mode", None) + thread.pop("reasoning_effort", None) + thread["updated_at"] = now_iso() + return f"Thinking mode: {thinking_mode_default()} (config default)" + thread["thinking_mode"] = mode + thread.pop("reasoning_effort", None) + thread["updated_at"] = now_iso() + return f"Thinking mode: {mode}" + + +def thinking_mode_help_text(thread: dict[str, Any]) -> str: + return "\n".join( + [ + f"Thinking mode: {thinking_mode_status(thread)}", + "Use: /think low|medium|high|xhigh|default", + "Applies to the next Codex job in this thread.", + ] + ) + + def reply_style_default() -> str: return env_choice("CODEX_TELEGRAM_REPLY_STYLE", DEFAULT_REPLY_STYLE, REPLY_STYLES) @@ -280,6 +415,11 @@ def certificate_error_message(exc: BaseException, tried: list[Path]) -> str: ) +def telegram_timeout_error(exc: BaseException) -> bool: + text = str(exc).lower() + return "timed out" in text or "timeout" in text or "read operation timed out" in text + + def telegram_urlopen(request: urllib.request.Request, timeout: int): try: return urllib.request.urlopen(request, timeout=timeout) @@ -309,11 +449,16 @@ def __init__(self, token: str) -> None: self.token = token self.base = f"https://api.telegram.org/bot{token}/" - def call(self, method: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def call( + self, + method: str, + params: Optional[dict[str, Any]] = None, + timeout: int = 70, + ) -> dict[str, Any]: data = urllib.parse.urlencode(params or {}).encode() request = urllib.request.Request(self.base + method, data=data, method="POST") try: - with telegram_urlopen(request, timeout=70) as response: + with telegram_urlopen(request, timeout=timeout) as response: payload = json.loads(response.read().decode()) except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace")[:600] @@ -322,15 +467,18 @@ def call(self, method: str, params: Optional[dict[str, Any]] = None) -> dict[str raise RuntimeError(str(exc)) from exc except urllib.error.URLError as exc: raise RuntimeError(f"Telegram network error: {exc.reason}") from exc + except TimeoutError as exc: + raise RuntimeError(f"Telegram request timed out: {exc}") from exc if not payload.get("ok"): raise RuntimeError(f"Telegram API error: {payload}") return payload def send_message( self, chat_id: int, text: str, reply_to_message_id: Optional[int] = None - ) -> None: + ) -> Optional[int]: chunks = split_for_telegram(text) threaded_replies = env_bool("CODEX_TELEGRAM_REPLY_TO_MESSAGES", False) + first_message_id: Optional[int] = None for chunk in chunks: params: dict[str, Any] = { "chat_id": chat_id, @@ -339,12 +487,31 @@ def send_message( } if threaded_replies and reply_to_message_id is not None: params["reply_to_message_id"] = reply_to_message_id - self.call("sendMessage", params) + payload = self.call("sendMessage", params) + message_id = int_or_none((payload.get("result") or {}).get("message_id")) + if first_message_id is None: + first_message_id = message_id reply_to_message_id = None + return first_message_id + + def edit_message(self, chat_id: int, message_id: int, text: str) -> None: + chunk = split_for_telegram(text)[0] + self.call( + "editMessageText", + { + "chat_id": chat_id, + "message_id": message_id, + "text": chunk, + "disable_web_page_preview": "true", + }, + ) def send_chat_action(self, chat_id: int, action: str = "typing") -> None: self.call("sendChatAction", {"chat_id": chat_id, "action": action}) + def delete_message(self, chat_id: int, message_id: int) -> None: + self.call("deleteMessage", {"chat_id": chat_id, "message_id": message_id}) + def send_photo( self, chat_id: int, @@ -395,11 +562,72 @@ def send_photo( if not payload.get("ok"): raise RuntimeError(f"Telegram API error: {payload}") + def send_document( + self, + chat_id: int, + path: Path, + caption: str = "", + reply_to_message_id: Optional[int] = None, + ) -> None: + boundary = "codexrelay-" + uuid.uuid4().hex + fields: dict[str, str] = { + "chat_id": str(chat_id), + } + if caption: + fields["caption"] = caption + if env_bool("CODEX_TELEGRAM_REPLY_TO_MESSAGES", False) and reply_to_message_id is not None: + fields["reply_to_message_id"] = str(reply_to_message_id) + + body = bytearray() + for key, value in fields.items(): + body.extend(f"--{boundary}\r\n".encode()) + body.extend(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode()) + body.extend(value.encode()) + body.extend(b"\r\n") + body.extend(f"--{boundary}\r\n".encode()) + body.extend( + f'Content-Disposition: form-data; name="document"; filename="{path.name}"\r\n'.encode() + ) + body.extend(b"Content-Type: application/octet-stream\r\n\r\n") + with open(path, "rb") as handle: + body.extend(handle.read()) + body.extend(b"\r\n") + body.extend(f"--{boundary}--\r\n".encode()) + + request = urllib.request.Request( + self.base + "sendDocument", + data=bytes(body), + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + method="POST", + ) + try: + with telegram_urlopen(request, timeout=70) as response: + payload = json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + body_text = exc.read().decode(errors="replace")[:600] + raise RuntimeError(f"Telegram HTTP {exc.code}: {body_text}") from exc + except TelegramTLSCertificateError as exc: + raise RuntimeError(str(exc)) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Telegram network error: {exc.reason}") from exc + if not payload.get("ok"): + raise RuntimeError(f"Telegram API error: {payload}") + def get_updates(self, offset: Optional[int]) -> list[dict[str, Any]]: - params: dict[str, Any] = {"timeout": 50, "allowed_updates": json.dumps(["message"])} + poll_timeout = max(1, env_int("CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS", DEFAULT_TELEGRAM_POLL_TIMEOUT_SECONDS)) + http_timeout = max( + poll_timeout + 10, + env_int("CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS", DEFAULT_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS), + ) + params: dict[str, Any] = {"timeout": poll_timeout, "allowed_updates": json.dumps(["message"])} if offset is not None: params["offset"] = offset - return self.call("getUpdates", params).get("result", []) + try: + return self.call("getUpdates", params, timeout=http_timeout).get("result", []) + except RuntimeError as exc: + if telegram_timeout_error(exc): + return [] + raise def get_file(self, file_id: str) -> dict[str, Any]: return self.call("getFile", {"file_id": file_id}).get("result", {}) @@ -487,20 +715,47 @@ def _run(self) -> None: class RelayJob: - def __init__(self, chat_id: int, thread_name: str, image_count: int) -> None: + def __init__(self, chat_id: int, thread_name: str, image_count: int, request_text: str = "") -> None: self.id = uuid.uuid4().hex[:8] self.chat_id = chat_id self.thread_name = thread_name self.image_count = image_count + self.request_preview = prompt_preview(request_text, 140) if request_text else "" self.started_at = now_iso() self.started_monotonic = time.monotonic() self.cancel_event = threading.Event() self.process: Optional[subprocess.Popen[str]] = None + self.status_message_id: Optional[int] = None self.lock = threading.Lock() + self.phase = "queued" + self.progress_lines: list[str] = [] + self.progress_revision = 0 def set_process(self, process: subprocess.Popen[str]) -> None: with self.lock: self.process = process + self.phase = "codex running" + self.progress_revision += 1 + + def set_status_message(self, message_id: Optional[int]) -> None: + with self.lock: + self.status_message_id = message_id + + def add_progress(self, line: str) -> None: + clean = sanitize_progress_line(line) + if not clean: + return + with self.lock: + if self.progress_lines and self.progress_lines[-1] == clean: + return + self.progress_lines.append(clean) + self.progress_lines = self.progress_lines[-MAX_PROGRESS_LINES:] + self.phase = "codex active" + self.progress_revision += 1 + + def progress_snapshot(self) -> tuple[str, int, list[str], Optional[int]]: + with self.lock: + return self.phase, self.progress_revision, list(self.progress_lines), self.status_message_id def cancel(self) -> None: self.cancel_event.set() @@ -545,11 +800,659 @@ def find_job(chat_id: int, job_id: str) -> Optional[RelayJob]: return None +def ansi_stripped(value: str) -> str: + return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", value) + + +def sanitize_progress_line(line: str) -> str: + clean = ansi_stripped(line).replace("\r", " ").strip() + clean = re.sub(r"\s+", " ", clean) + clean = clean.replace("✓", "ok").replace("✔", "ok").replace("✗", "failed") + if not clean: + return "" + lowered = clean.lower() + if "session id:" in lowered: + return "" + if not gemini_allows_text(clean): + return "" + if len(clean) > 220: + clean = clean[:217].rstrip() + "..." + return clean + + +def progress_line_displayable(line: str) -> bool: + clean = line.strip() + if not clean: + return False + clean = re.sub(r"^[-*>\s]+", "", clean).strip() + if re.match(r"^[?]{1,3}\s+", clean): + return False + words = re.findall(r"[A-Za-z0-9_./:-]+", clean) + if len(words) <= 1 and len(clean) < 18: + return False + return True + + +def progress_interval_seconds() -> int: + return max(5, env_int("CODEX_TELEGRAM_PROGRESS_INTERVAL_SECONDS", DEFAULT_PROGRESS_INTERVAL_SECONDS)) + + +def progress_enabled(thread: dict[str, Any]) -> bool: + return str(thread.get("progress_updates") or "").lower() in {"1", "true", "yes", "on"} + + +def set_progress_updates_text(thread: dict[str, Any], enabled: bool) -> str: + thread["progress_updates"] = "true" if enabled else "false" + thread["updated_at"] = now_iso() + if enabled: + return f"Live job updates: on ({progress_interval_seconds()}s minimum interval)" + return "Live job updates: off" + + +def job_progress_text(job: RelayJob, force_detail: bool = False) -> str: + phase, _revision, lines, _message_id = job.progress_snapshot() + text = [f"Working: job {job.id}", f"thread: {job.thread_name}"] + if job.request_preview: + text.append(f"request: {job.request_preview}") + text.extend([f"status: {phase}", f"elapsed: {job.elapsed()}"]) + if job.image_count: + image_label = "image" if job.image_count == 1 else "images" + text.append(f"attachments: {job.image_count} {image_label}") + useful_lines = [line for line in lines if progress_line_displayable(line)] + if useful_lines: + shown = useful_lines if force_detail else useful_lines[-3:] + text.append("progress:") + text.extend(f"- {line}" for line in shown) + else: + text.append("progress: Codex is running; no useful status line yet.") + text.append("check: /jobs") + text.append(f"stop: /cancel {job.id}") + return "\n".join(text) + + +class ProgressPulse: + def __init__(self, api: TelegramAPI, chat_id: int, job: RelayJob, enabled: bool) -> None: + self.api = api + self.chat_id = chat_id + self.job = job + self.enabled = enabled + self.interval = progress_interval_seconds() + self.stop = threading.Event() + self.thread = threading.Thread(target=self._run, daemon=True) + + def __enter__(self) -> "ProgressPulse": + if self.enabled: + self.thread.start() + return self + + def __exit__(self, *_args: object) -> None: + self.stop.set() + if self.enabled: + self.thread.join(timeout=1) + + def _run(self) -> None: + last_revision = -1 + last_sent = 0.0 + while not self.stop.wait(1): + _phase, revision, lines, message_id = self.job.progress_snapshot() + if message_id is None or revision == last_revision or not lines: + continue + now = time.monotonic() + if now - last_sent < self.interval: + continue + try: + self.api.edit_message(self.chat_id, message_id, job_progress_text(self.job)) + last_revision = revision + last_sent = now + except Exception: + pass + + +TERMINAL_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}$") + + +def terminal_buffer_chars() -> int: + return max(2000, env_int("CODEX_RELAY_TERMINAL_BUFFER_CHARS", 20000)) + + +def terminal_read_limit() -> int: + return max(500, env_int("CODEX_RELAY_TERMINAL_READ_LIMIT", DEFAULT_TERMINAL_READ_LIMIT)) + + +def normalize_terminal_name(raw: str) -> str: + name = (raw.strip() or "main").lower() + if not TERMINAL_NAME_RE.fullmatch(name): + raise ValueError("Terminal name must be 1-32 letters, numbers, dots, dashes, or underscores.") + return name + + +def terminal_key(chat_id: int, name: str) -> str: + return f"{chat_id}:{name}" + + +def terminal_output_clean(text: str) -> str: + clean = ansi_stripped(text).replace("\r\n", "\n").replace("\r", "\n") + clean = re.sub(r"\n{4,}", "\n\n\n", clean) + return clean + + +class TerminalSession: + def __init__(self, chat_id: int, name: str, cwd: Path, command: str = "") -> None: + self.chat_id = chat_id + self.name = name + self.cwd = cwd + self.command = command + self.id = uuid.uuid4().hex[:8] + self.started_at = now_iso() + self.started_monotonic = time.monotonic() + self.lock = threading.Lock() + self.output = "" + self.master_fd = -1 + self.process: Optional[subprocess.Popen[str]] = None + self.reader: Optional[threading.Thread] = None + + def start(self) -> None: + shell = os.environ.get("SHELL", "/bin/zsh") or "/bin/zsh" + if self.command: + argv = [shell, "-lc", self.command] + else: + argv = [shell, "-l"] + master_fd, slave_fd = pty.openpty() + env = os.environ.copy() + env.setdefault("TERM", "xterm-256color") + env["CODEX_RELAY_TERMINAL"] = "1" + try: + process = subprocess.Popen( + argv, + cwd=self.cwd, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + text=False, + close_fds=True, + start_new_session=True, + env=env, + ) + finally: + os.close(slave_fd) + self.master_fd = master_fd + self.process = process + os.set_blocking(self.master_fd, False) + self.reader = threading.Thread(target=self._read_loop, name=f"codex-relay-terminal-{self.name}", daemon=True) + self.reader.start() + + def _append_output(self, chunk: str) -> None: + if not chunk: + return + with self.lock: + self.output += chunk + max_chars = terminal_buffer_chars() + if len(self.output) > max_chars: + self.output = self.output[-max_chars:] + + def _read_loop(self) -> None: + while True: + process = self.process + if process is None: + return + try: + ready, _write, _error = select.select([self.master_fd], [], [], 0.2) + if ready: + try: + data = os.read(self.master_fd, 4096) + except BlockingIOError: + data = b"" + except OSError: + data = b"" + if data: + self._append_output(data.decode(errors="replace")) + if process.poll() is not None: + try: + while True: + data = os.read(self.master_fd, 4096) + if not data: + break + self._append_output(data.decode(errors="replace")) + except OSError: + pass + return + except Exception: + return + + def alive(self) -> bool: + return self.process is not None and self.process.poll() is None + + def elapsed(self) -> str: + return duration_text(time.monotonic() - self.started_monotonic) + + def read(self, limit: Optional[int] = None) -> str: + with self.lock: + text = self.output + text = terminal_output_clean(text) + max_chars = limit or terminal_read_limit() + if len(text) > max_chars: + text = "(tail)\n" + text[-max_chars:].lstrip() + return text.strip() or "(no terminal output yet)" + + def send(self, text: str, newline: bool = False) -> None: + if not self.alive(): + raise RuntimeError(f"Terminal `{self.name}` is not running.") + payload = text + ("\n" if newline else "") + os.write(self.master_fd, payload.encode()) + + def kill(self) -> None: + process = self.process + if process is not None and process.poll() is None: + signal_process(process, signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + signal_process(process, signal.SIGKILL) + if self.master_fd >= 0: + try: + os.close(self.master_fd) + except OSError: + pass + self.master_fd = -1 + + +def terminal_get(chat_id: int, name: str) -> Optional[TerminalSession]: + with TERMINALS_LOCK: + session = TERMINALS.get(terminal_key(chat_id, name)) + if session and not session.alive(): + TERMINALS.pop(terminal_key(chat_id, name), None) + return session if session and session.alive() else None + + +def terminal_list(chat_id: int) -> list[TerminalSession]: + with TERMINALS_LOCK: + sessions = [session for session in TERMINALS.values() if session.chat_id == chat_id and session.alive()] + stale = [key for key, session in TERMINALS.items() if session.chat_id == chat_id and not session.alive()] + for key in stale: + TERMINALS.pop(key, None) + return sorted(sessions, key=lambda session: session.started_at) + + +def terminal_open(chat_id: int, name: str, cwd: Path, command: str = "") -> TerminalSession: + name = normalize_terminal_name(name) + existing = terminal_get(chat_id, name) + if existing: + raise ValueError(f"Terminal `{name}` is already running.") + session = TerminalSession(chat_id, name, cwd, command) + session.start() + with TERMINALS_LOCK: + TERMINALS[terminal_key(chat_id, name)] = session + return session + + +def terminal_kill(chat_id: int, selector: str) -> list[str]: + target = (selector.strip() or "main").lower() + killed: list[str] = [] + with TERMINALS_LOCK: + if target in {"all", "clear"}: + sessions = [session for session in TERMINALS.values() if session.chat_id == chat_id] + else: + name = normalize_terminal_name(target) + session = TERMINALS.get(terminal_key(chat_id, name)) + sessions = [session] if session else [] + for session in sessions: + session.kill() + killed.append(session.name) + with TERMINALS_LOCK: + TERMINALS.pop(terminal_key(chat_id, session.name), None) + return killed + + +def parse_terminal_open_arg(arg: str) -> tuple[str, str]: + value = arg.strip() + if not value: + return "main", "" + if " -- " in value: + left, command = value.split(" -- ", 1) + return normalize_terminal_name(left or "main"), command.strip() + parts = shlex.split(value) + if len(parts) == 1 and TERMINAL_NAME_RE.fullmatch(parts[0]): + return normalize_terminal_name(parts[0]), "" + return "main", value + + +def parse_terminal_target_and_text(chat_id: int, arg: str) -> tuple[str, str]: + value = arg.strip() + if not value: + raise ValueError("Give me text to send to the terminal.") + parts = value.split(None, 1) + if len(parts) == 2: + candidate = parts[0].strip() + if terminal_get(chat_id, candidate): + return normalize_terminal_name(candidate), parts[1] + return "main", value + + +def terminal_help_text() -> str: + return "\n".join( + [ + "Terminal commands:", + "/terminal open - start a main login shell", + "/terminal open name - start a named shell", + "/terminal open name -- command - start a named command", + "/terminal list - list running terminals", + "/terminal read [name] - show recent output", + "/terminal send [name] text - type without Enter", + "/terminal enter [name] text - type and press Enter", + "/terminal kill [name|all] - stop terminals", + "Alias: /term", + ] + ) + + +def terminal_list_text(chat_id: int) -> str: + sessions = terminal_list(chat_id) + if not sessions: + return "terminals: none" + lines = ["terminals:"] + for session in sessions: + cmd = f"; command: {session.command}" if session.command else "" + lines.append(f"- {session.name}: running {session.elapsed()}; cwd: {session.cwd}{cmd}") + return "\n".join(lines) + + +def terminal_command_text(chat_id: int, thread: dict[str, Any], arg: str) -> str: + subcommand, _space, rest = arg.strip().partition(" ") + subcommand = subcommand.lower() + if not subcommand or subcommand in {"help", "?"}: + return terminal_help_text() + if subcommand in {"list", "ls"}: + return terminal_list_text(chat_id) + if subcommand in {"open", "new", "start"}: + name, command = parse_terminal_open_arg(rest) + cwd = Path(str(thread.get("workdir") or default_workdir())).expanduser() + if not cwd.exists() or not cwd.is_dir(): + raise ValueError(f"Current thread folder is not usable: {cwd}") + session = terminal_open(chat_id, name, cwd, command) + time.sleep(0.2) + output = session.read(1000) + return f"Terminal `{session.name}` started in {cwd}.\n\n{output}" + if subcommand in {"read", "tail", "show"}: + name = normalize_terminal_name(rest or "main") + session = terminal_get(chat_id, name) + if not session: + return f"No running terminal: {name}" + return f"Terminal `{name}` output:\n\n{session.read()}" + if subcommand in {"send", "type", "write"}: + name, text = parse_terminal_target_and_text(chat_id, rest) + session = terminal_get(chat_id, name) + if not session: + return f"No running terminal: {name}" + session.send(text, newline=False) + return f"Sent to terminal `{name}`." + if subcommand in {"enter", "line", "input"}: + name, text = parse_terminal_target_and_text(chat_id, rest) + session = terminal_get(chat_id, name) + if not session: + return f"No running terminal: {name}" + session.send(text, newline=True) + time.sleep(0.2) + return f"Sent Enter to terminal `{name}`.\n\n{session.read(1200)}" + if subcommand in {"kill", "stop", "close"}: + killed = terminal_kill(chat_id, rest) + if not killed: + return "No matching terminal to kill." + return "Killed terminal: " + ", ".join(killed) + raise ValueError("Unknown terminal command. Use /terminal help.") + + +def pending_item_image_paths(item: dict[str, Any]) -> list[str]: + raw_paths = item.get("image_paths") or [] + if not isinstance(raw_paths, list): + return [] + paths: list[str] = [] + for raw in raw_paths: + value = str(raw or "").strip() + if value: + paths.append(value) + return paths[: max_images_per_message()] + + +def pending_requests(thread: dict[str, Any]) -> list[dict[str, Any]]: + raw_items = thread.get("pending_requests") or [] + if not isinstance(raw_items, list): + return [] + items: list[dict[str, Any]] = [] + for raw in raw_items: + if not isinstance(raw, dict): + continue + prompt = str(raw.get("prompt") or "").strip() + if not prompt: + continue + request_id = str(raw.get("id") or uuid.uuid4().hex[:8])[:16] + image_paths = pending_item_image_paths(raw) + items.append( + { + "id": request_id, + "prompt": prompt[:MAX_PENDING_PROMPT_CHARS], + "image_paths": image_paths, + "image_count": len(image_paths), + "created_at": str(raw.get("created_at") or now_iso()), + "updated_at": str(raw.get("updated_at") or raw.get("created_at") or now_iso()), + } + ) + return items[:MAX_PENDING_REQUESTS] + + +def save_pending_requests(thread: dict[str, Any], items: list[dict[str, Any]]) -> None: + thread["pending_requests"] = items[:MAX_PENDING_REQUESTS] + thread["updated_at"] = now_iso() + + +def prompt_preview(prompt: str, limit: int = 90) -> str: + clean = re.sub(r"\s+", " ", prompt).strip() + if len(clean) > limit: + clean = clean[: limit - 3].rstrip() + "..." + return clean + + +def normalize_pending_image_paths(image_paths: Optional[list[Path]]) -> list[str]: + if not image_paths: + return [] + paths: list[str] = [] + for path in image_paths[: max_images_per_message()]: + paths.append(str(path)) + return paths + + +def queue_pending_request( + thread: dict[str, Any], + prompt: str, + image_paths: Optional[list[Path]] = None, +) -> dict[str, Any]: + clean = prompt.strip()[:MAX_PENDING_PROMPT_CHARS] + if not clean: + raise ValueError("Give me a request to queue.") + items = pending_requests(thread) + if len(items) >= MAX_PENDING_REQUESTS: + raise ValueError(f"Pending queue is full ({MAX_PENDING_REQUESTS}).") + stored_images = normalize_pending_image_paths(image_paths) + item = { + "id": uuid.uuid4().hex[:8], + "prompt": clean, + "image_paths": stored_images, + "image_count": len(stored_images), + "created_at": now_iso(), + "updated_at": now_iso(), + } + items.append(item) + save_pending_requests(thread, items) + return item + + +def pending_selector(value: str) -> str: + return (value.strip().lower() or "latest").replace("#", "") + + +def safe_unlink_pending_image(raw_path: str) -> bool: + try: + root = attachments_dir().resolve() + path = Path(raw_path).expanduser().resolve() + if root not in path.parents: + return False + if path.is_file(): + path.unlink() + return True + except OSError: + return False + return False + + +def delete_pending_item_images(item: dict[str, Any]) -> int: + deleted = 0 + for raw_path in pending_item_image_paths(item): + if safe_unlink_pending_image(raw_path): + deleted += 1 + return deleted + + +def replace_pending_request(thread: dict[str, Any], selector: str, prompt: str) -> dict[str, Any]: + clean = prompt.strip()[:MAX_PENDING_PROMPT_CHARS] + if not clean: + raise ValueError("Give me replacement text for the pending request.") + items = pending_requests(thread) + if not items: + raise ValueError("No pending requests to modify.") + target = pending_selector(selector) + index = len(items) - 1 if target in {"latest", "last"} else -1 + if index == -1: + for idx, item in enumerate(items): + if item["id"].lower().startswith(target): + index = idx + break + if index == -1: + raise ValueError(f"No pending request matches: {selector}") + items[index]["prompt"] = clean + items[index]["updated_at"] = now_iso() + save_pending_requests(thread, items) + return items[index] + + +def remove_pending_images(thread: dict[str, Any], selector: str = "latest") -> tuple[list[dict[str, Any]], int]: + items = pending_requests(thread) + if not items: + return [], 0 + target = pending_selector(selector) + indexes: list[int] = [] + if target in {"all", "clear"}: + indexes = list(range(len(items))) + elif target in {"latest", "last"}: + indexes = [len(items) - 1] + else: + for idx, item in enumerate(items): + if item["id"].lower().startswith(target): + indexes = [idx] + break + if not indexes: + return [], 0 + changed: list[dict[str, Any]] = [] + deleted = 0 + for index in indexes: + item = items[index] + if pending_item_image_paths(item): + deleted += delete_pending_item_images(item) + item["image_paths"] = [] + item["image_count"] = 0 + item["updated_at"] = now_iso() + changed.append(item) + save_pending_requests(thread, items) + return changed, deleted + + +def prioritize_pending_request(thread: dict[str, Any], selector: str = "latest") -> dict[str, Any]: + items = pending_requests(thread) + if not items: + raise ValueError("No pending requests to reorder.") + target = pending_selector(selector) + index = len(items) - 1 if target in {"latest", "last"} else -1 + if index == -1: + for idx, item in enumerate(items): + if item["id"].lower().startswith(target): + index = idx + break + if index == -1: + raise ValueError(f"No pending request matches: {selector}") + item = items.pop(index) + items.insert(0, item) + item["updated_at"] = now_iso() + save_pending_requests(thread, items) + return item + + +def remove_pending_request(thread: dict[str, Any], selector: str = "latest") -> list[dict[str, Any]]: + items = pending_requests(thread) + if not items: + return [] + target = pending_selector(selector) + if target in {"all", "clear"}: + removed = items + for item in removed: + delete_pending_item_images(item) + save_pending_requests(thread, []) + return removed + index = len(items) - 1 if target in {"latest", "last"} else -1 + if index == -1: + for idx, item in enumerate(items): + if item["id"].lower().startswith(target): + index = idx + break + if index == -1: + return [] + removed = [items.pop(index)] + for item in removed: + delete_pending_item_images(item) + save_pending_requests(thread, items) + return removed + + +def pop_next_pending_request(thread: dict[str, Any]) -> Optional[dict[str, Any]]: + items = pending_requests(thread) + if not items: + return None + item = items.pop(0) + save_pending_requests(thread, items) + return item + + +def pending_paths_for_codex(item: dict[str, Any]) -> list[Path]: + paths: list[Path] = [] + for raw_path in pending_item_image_paths(item): + path = Path(raw_path).expanduser() + if path.is_file(): + paths.append(path) + return paths[: max_images_per_message()] + + +def pending_queue_text(thread: dict[str, Any]) -> str: + items = pending_requests(thread) + if not items: + return "pending: none" + lines = ["pending:"] + for item in items: + image_count = int_or_none(item.get("image_count")) or 0 + image_note = "" + if image_count: + image_label = "image" if image_count == 1 else "images" + image_note = f"; {image_count} {image_label}" + lines.append(f"- {item['id']}: {prompt_preview(item['prompt'])}{image_note}") + return "\n".join(lines) + + def cancel_all_jobs() -> None: with JOBS_LOCK: jobs = list(ACTIVE_JOBS.values()) for job in jobs: job.cancel() + with TERMINALS_LOCK: + sessions = list(TERMINALS.values()) + TERMINALS.clear() + for session in sessions: + session.kill() def cancel_all_jobs_async() -> None: @@ -621,7 +1524,23 @@ def image_suffix(file_name: str = "", mime_type: str = "", file_path: str = "") return IMAGE_SUFFIX_BY_MIME.get(mime, ".jpg") +def max_images_per_message() -> int: + return max(1, env_int("CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE", MAX_IMAGES_PER_MESSAGE)) + + def image_attachment_specs(message: dict[str, Any]) -> list[dict[str, Any]]: + grouped = message.get("_relay_media_group_messages") + if isinstance(grouped, list): + grouped_specs: list[dict[str, Any]] = [] + for item in grouped: + if isinstance(item, dict): + single = dict(item) + single.pop("_relay_media_group_messages", None) + grouped_specs.extend(image_attachment_specs(single)) + if len(grouped_specs) >= max_images_per_message(): + break + return grouped_specs[: max_images_per_message()] + specs: list[dict[str, Any]] = [] photos = message.get("photo") or [] if isinstance(photos, list) and photos: @@ -656,7 +1575,32 @@ def image_attachment_specs(message: dict[str, Any]) -> list[dict[str, Any]]: } ) - return specs[:MAX_IMAGES_PER_MESSAGE] + return specs[: max_images_per_message()] + + +def media_group_key(message: dict[str, Any]) -> Optional[tuple[int, str]]: + media_group_id = str(message.get("media_group_id") or "").strip() + if not media_group_id or not image_attachment_specs(message): + return None + chat = message.get("chat") or {} + chat_id = int_or_none(chat.get("id")) + if chat_id is None: + return None + return chat_id, media_group_id + + +def merge_media_group_messages(messages: list[dict[str, Any]]) -> dict[str, Any]: + if not messages: + return {} + merged = dict(messages[0]) + merged["_relay_media_group_messages"] = list(messages) + for item in messages: + text = str(item.get("caption") or item.get("text") or "").strip() + if text: + merged["caption"] = text + merged["text"] = text + break + return merged def prune_attachment_cache(root: Path) -> None: @@ -882,6 +1826,86 @@ def resolve_workdir(raw: str, current: str) -> Path: return path +def path_is_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + +def max_file_transfer_bytes() -> int: + return max(1, env_int("CODEX_TELEGRAM_MAX_FILE_BYTES", DEFAULT_MAX_FILE_BYTES)) + + +def allow_sensitive_file_transfer() -> bool: + return env_bool("CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER", False) + + +def resolve_transfer_file(raw: str, current_workdir: str) -> Path: + value = raw.strip() + if not value: + raise ValueError("Give me a file path, like `/file README.md`.") + if value.startswith("~"): + path = Path(value).expanduser() + elif value.startswith("/"): + path = Path(value) + else: + path = Path(current_workdir).expanduser() / value + path = path.resolve() + if not path.exists(): + raise ValueError(f"File does not exist: {path}") + if not path.is_file(): + raise ValueError(f"Not a file: {path}") + size = path.stat().st_size + max_bytes = max_file_transfer_bytes() + if size > max_bytes: + raise ValueError(f"File is too large ({size} bytes; limit {max_bytes}).") + return path + + +def transfer_file_blocker(path: Path) -> str: + if allow_sensitive_file_transfer(): + return "" + lower_parts = {part.lower() for part in path.parts} + name = path.name.lower() + sensitive_names = { + ".env", + ".netrc", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "known_hosts", + } + sensitive_substrings = ("token", "secret", "password", "private-key", "private_key", "credential") + if name in sensitive_names or any(term in name for term in sensitive_substrings): + return "blocked sensitive-looking file name" + if ".ssh" in lower_parts or ".gnupg" in lower_parts: + return "blocked credential directory" + try: + if path_is_within(path, state_dir()): + return "blocked relay runtime state" + except Exception: + pass + return "" + + +def send_file_to_telegram( + api: TelegramAPI, + chat_id: int, + thread: dict[str, Any], + raw_path: str, + reply_to_message_id: Optional[int] = None, +) -> str: + path = resolve_transfer_file(raw_path, str(thread.get("workdir") or default_workdir())) + blocker = transfer_file_blocker(path) + if blocker: + raise ValueError(f"Blocked: {blocker}. Set CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER=true only if you accept the risk.") + api.send_document(chat_id, path, f"File: {path.name}", reply_to_message_id) + return f"Sent file: {path}" + + def authorized( user_id: Optional[int], chat_id: int, @@ -947,6 +1971,10 @@ def gemini_timeout() -> int: return max(1, env_int("CODEX_RELAY_GEMINI_TIMEOUT_SECONDS", DEFAULT_GEMINI_TIMEOUT_SECONDS)) +def gemini_max_output_tokens() -> int: + return max(256, env_int("CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS", DEFAULT_GEMINI_MAX_OUTPUT_TOKENS)) + + def gemini_response_text(payload: dict[str, Any]) -> str: candidates = payload.get("candidates") or [] if not candidates: @@ -969,7 +1997,10 @@ def gemini_generate(prompt: str, response_schema: Optional[dict[str, Any]] = Non + urllib.parse.quote(model, safe="") + ":generateContent" ) - generation_config: dict[str, Any] = {"temperature": 0.2} + generation_config: dict[str, Any] = { + "temperature": 0.2, + "maxOutputTokens": gemini_max_output_tokens(), + } if response_schema is not None: generation_config.update( { @@ -1024,11 +2055,11 @@ def gemini_generate(prompt: str, response_schema: Optional[dict[str, Any]] = Non }, "value": { "type": "string", - "description": "Thread name, folder path, or empty string, depending on action type.", + "description": "Thread name, folder path, thinking mode, pending id, terminal name, file path, latest/all, or empty string, depending on action type.", }, "prompt": { "type": "string", - "description": "Prompt to send to Codex for run_codex actions.", + "description": "Prompt to send to Codex, queued request text, terminal command/input, or replacement text.", }, }, "required": ["type"], @@ -1057,10 +2088,26 @@ def gemini_plan_prompt( Current active thread: {active_name} Current folder: {thread.get('workdir') or default_workdir()} +Current thinking mode: {thinking_mode_status(thread)} +Pending requests: +{pending_queue_text(thread)} Known threads: {thread_text} Allowed actions: +- queue_request: add a Codex request to the active thread's pending queue. Use when the current thread is busy or the user asks to do something after the current job. +- replace_pending_request: replace a pending request. Value is a pending id or latest; prompt is the new request text. +- remove_pending_request: remove a pending request. Value is a pending id, latest, or all. Use latest for "never mind" when the user does not specify. +- remove_pending_images: remove saved images from a pending request but keep its text. Value is a pending id, latest, or all. Use this when the user says the photos/images are bad, wrong, or should be ignored. +- prioritize_pending_request: move a pending request to the front of the queue. Value is a pending id or latest. Use this when the user says to do something next, first, before the others, or bump it up. +- show_queue: show pending requests. +- show_activity: summarize running jobs, pending requests, and recent safe history without starting Codex. +- terminal_open: open a persistent PTY terminal. Value is terminal name or empty for main. Prompt is an optional shell command to run. +- terminal_read: read recent terminal output. Value is terminal name or empty for main. +- terminal_send: send input to a terminal and press Enter. Value is terminal name or empty for main. Prompt is the input text. +- terminal_kill: stop a terminal. Value is terminal name, all, or empty for main. +- send_file: send a local file back to Telegram. Value is a file path relative to the active folder or absolute path. +- set_thinking_mode: set the active thread's Codex thinking mode. Value must be low, medium, high, xhigh, or default. - set_workdir: set the active thread folder. For user shorthand like /code/name or code/name, return code/name or ~/code/name, not the filesystem root /code unless they clearly mean an absolute path. - new_thread: create and switch to a named thread. - use_thread: switch to an existing named thread. @@ -1073,6 +2120,14 @@ def gemini_plan_prompt( Rules: - Return JSON only. - Prefer one set_workdir followed by one run_codex for messages like "set my dir to X and run Y". +- Prefer one set_thinking_mode followed by one run_codex for messages like "switch to high thinking and run tests". +- If the user says "never mind", "forget that", or cancels a pending idea without a job id, use remove_pending_request rather than run_codex. +- If the user says only the photos/images are no good, use remove_pending_images rather than removing the whole pending request. +- If the user changes "that pending request", use replace_pending_request with value latest. +- If the user changes the order, use prioritize_pending_request instead of rewriting the request. +- If the user asks "what is going on", "what are you doing", "where are we", or asks for a summary while Codex is running, use show_activity. +- Use terminal actions only for explicit terminal/session/CLI login work, and prefer terminal_open before terminal_send if no terminal is running. +- Use send_file only when the user explicitly asks to fetch, send, download, or transfer a local file. - Do not invent unsupported slash commands. - Do not request or expose secrets, tokens, raw logs, auth files, or private transcripts. - If the user asks for destructive, public, payment, account, medical, legal, or financial actions, still send the request to Codex as run_codex and let Codex stop at the confirmation boundary. @@ -1101,7 +2156,7 @@ def validate_gemini_plan(raw: Any, original_text: str) -> dict[str, Any]: if action_type == "run_codex" and not action["prompt"]: action["prompt"] = original_text actions.append(action) - if len(actions) >= 4: + if len(actions) >= 6: break return {"reply": str(raw.get("reply") or "").strip()[:1000], "actions": actions} @@ -1130,7 +2185,8 @@ def gemini_polish_answer(prompt_text: str, answer: str, thread: dict[str, Any]) - Preserve every factual claim, command, path, file name, warning, blocker, and verification result. - Do not add new facts or pretend extra work happened. - Do not reveal or request secrets. -- Keep it concise. Use short paragraphs or bullets only when they improve scanability. +- Use enough detail to make the phone reply useful. Do not over-compress changed files, commands, warnings, blockers, or verification. +- Use short paragraphs or bullets when they improve scanability. Original user request: {prompt_text} @@ -1138,6 +2194,9 @@ def gemini_polish_answer(prompt_text: str, answer: str, thread: dict[str, Any]) Active folder: {thread.get('workdir') or default_workdir()} +Thinking mode: +{thread_thinking_mode(thread)} + Codex reply: {answer} """ @@ -1171,6 +2230,136 @@ def execute_gemini_plan( _data, _active_name, thread = active_state(threads_path, chat_id) api.send_message(chat_id, status_text(thread, chat_id), message_id) return True + if action_type == "show_queue": + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + api.send_message(chat_id, pending_queue_text(thread), message_id) + return True + if action_type == "show_activity": + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + api.send_message(chat_id, activity_text(chat_id, thread), message_id) + return True + if action_type == "queue_request": + prompt = str(action.get("prompt") or "").strip() or original_text + try: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + item = queue_pending_request(thread, prompt) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + notes.append(f"Queued request {item['id']}: {prompt_preview(item['prompt'])}") + continue + if action_type == "replace_pending_request": + prompt = str(action.get("prompt") or "").strip() or original_text + try: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + item = replace_pending_request(thread, value, prompt) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + notes.append(f"Updated pending request {item['id']}: {prompt_preview(item['prompt'])}") + continue + if action_type == "remove_pending_request": + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + removed = remove_pending_request(thread, value) + write_threads(threads_path, data) + if removed: + notes.append( + "Removed pending: " + + ", ".join(f"{item['id']} ({prompt_preview(item['prompt'], 48)})" for item in removed) + ) + else: + notes.append("No pending request matched.") + continue + if action_type == "remove_pending_images": + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + changed, deleted = remove_pending_images(thread, value) + write_threads(threads_path, data) + if changed: + image_label = "image" if deleted == 1 else "images" + notes.append( + f"Removed {deleted} saved {image_label} from pending: " + + ", ".join(item["id"] for item in changed) + ) + else: + notes.append("No pending images matched.") + continue + if action_type == "prioritize_pending_request": + try: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + item = prioritize_pending_request(thread, value) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + notes.append(f"Next up: {item['id']}: {prompt_preview(item['prompt'])}") + continue + if action_type == "terminal_open": + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + try: + name = normalize_terminal_name(value or "main") + cwd = Path(str(thread.get("workdir") or default_workdir())).expanduser() + session = terminal_open(chat_id, name, cwd, str(action.get("prompt") or "").strip()) + except (RuntimeError, ValueError, OSError) as exc: + api.send_message(chat_id, str(exc), message_id) + return True + time.sleep(0.2) + notes.append(f"Terminal `{session.name}` started.\n{session.read(800)}") + continue + if action_type == "terminal_read": + name = normalize_terminal_name(value or "main") + session = terminal_get(chat_id, name) + if not session: + api.send_message(chat_id, f"No running terminal: {name}", message_id) + return True + notes.append(f"Terminal `{name}` output:\n{session.read()}") + continue + if action_type == "terminal_send": + name = normalize_terminal_name(value or "main") + text = str(action.get("prompt") or "").strip() + if not text: + api.send_message(chat_id, "Give me terminal input to send.", message_id) + return True + session = terminal_get(chat_id, name) + if not session: + api.send_message(chat_id, f"No running terminal: {name}", message_id) + return True + try: + session.send(text, newline=True) + except RuntimeError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + time.sleep(0.2) + notes.append(f"Sent input to terminal `{name}`.\n{session.read(1000)}") + continue + if action_type == "terminal_kill": + try: + killed = terminal_kill(chat_id, value or "main") + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + notes.append("Killed terminal: " + ", ".join(killed) if killed else "No matching terminal to kill.") + continue + if action_type == "send_file": + raw_path = value or str(action.get("prompt") or "").strip() + try: + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + note = send_file_to_telegram(api, chat_id, thread, raw_path, message_id) + except (RuntimeError, ValueError) as exc: + api.send_message(chat_id, str(exc), message_id) + return True + notes.append(note) + continue if action_type == "new_thread": try: name = normalize_thread_name(value) @@ -1216,6 +2405,18 @@ def execute_gemini_plan( write_threads(threads_path, data) notes.append(f"Reset thread: {active_name}") continue + if action_type == "set_thinking_mode": + try: + mode_note = "" + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + mode_note = set_thinking_mode_text(thread, value) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return True + notes.append(mode_note) + continue if action_type == "set_workdir": with THREADS_LOCK: data, active_name, thread = active_state(threads_path, chat_id) @@ -1242,7 +2443,17 @@ def execute_gemini_plan( _data, active_name, thread = active_state(threads_path, chat_id) busy = busy_thread_message(chat_id, active_name) if busy: - api.send_message(chat_id, busy, message_id) + try: + with THREADS_LOCK: + data, active_name, thread = active_state(threads_path, chat_id) + item = queue_pending_request(thread, prompt) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, f"{busy}\n{exc}", message_id) + return True + preface = "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])) + queued = f"Queued request {item['id']} until thread `{active_name}` is clear: {prompt_preview(item['prompt'])}" + api.send_message(chat_id, "\n\n".join(part for part in [preface, queued] if part), message_id) return True preface = "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])) if preface: @@ -1281,9 +2492,15 @@ def style_instruction(reply_style: str) -> str: "Reply with enough detail to be useful for debugging or handoff. " "Use concise structure, include verification, and avoid filler." ) + if reply_style == "normal": + return ( + "Reply with a compact but complete update. Include what changed, what was verified, " + "and any blocker or next step. Use bullets when they improve scanability." + ) return ( - "Reply in the fewest words that still answer the task. " - "Prefer concrete status, changed files, verification, and the next human-only boundary." + "Reply in the fewest words that still answer the task, but do not reduce useful status " + "to fragments. Prefer concrete status, changed files, verification, and the next " + "human-only boundary." ) @@ -1292,6 +2509,7 @@ def codex_prompt( thread_name: str, image_paths: Optional[list[Path]] = None, reply_style: Optional[str] = None, + thinking_mode: Optional[str] = None, ) -> str: user_name = relay_user_name() assistant_name = relay_assistant_name() @@ -1300,6 +2518,7 @@ def codex_prompt( f"\n{assistant_name}'s personality: {personality}\n" if personality else "" ) style = reply_style if reply_style in REPLY_STYLES else reply_style_default() + mode = thinking_mode or thinking_mode_default() image_paths = image_paths or [] image_note = "" if image_paths: @@ -1319,6 +2538,7 @@ def codex_prompt( Read live state first. Act directly. Keep replies terse and concrete. Default voice: Mac-side operator, not generic chatbot. Say what changed, what you verified, and the next human-only boundary if there is one. Reply style: {style}. {style_instruction(style)} +Current Codex thinking mode: {mode}. Do not reveal secrets, tokens, auth files, private logs, session transcripts, or personal content. If a requested action is blocked by credentials, permissions, network, macOS privacy, tool availability, or mandatory safety confirmation, state the exact blocker and the next human-only step. This Telegram chat is mapped to the Codex thread named `{thread_name}`. @@ -1419,14 +2639,11 @@ def run_codex( image_paths: Optional[list[Path]] = None, cancel_event: Optional[threading.Event] = None, process_callback: Optional[Callable[[subprocess.Popen[str]], None]] = None, + progress_callback: Optional[Callable[[str], None]] = None, ) -> tuple[str, str, dict[str, Any]]: session_id = str(thread.get("session_id") or "") image_count = len(image_paths or []) - reasoning_effort = env_choice( - "CODEX_TELEGRAM_REASONING_EFFORT", - DEFAULT_REASONING_EFFORT, - REASONING_EFFORTS, - ) + reasoning_effort = thread_thinking_mode(thread) started_at = now_iso() started = time.monotonic() @@ -1478,17 +2695,40 @@ def finish( command.extend(["--output-last-message", str(output_path), "-"]) process: Optional[subprocess.Popen[str]] = None - stdout = "" - stderr = "" + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] returncode: Optional[int] = None prompt = codex_prompt( message_text, thread_name, image_paths, str(thread.get("reply_style") or reply_style_default()), + reasoning_effort, ) - input_text: Optional[str] = prompt + def reader(stream: Any, chunks: list[str]) -> None: + try: + for line in iter(stream.readline, ""): + chunks.append(line) + if progress_callback: + progress_callback(line) + except Exception: + pass + finally: + try: + stream.close() + except Exception: + pass + + def stop_streaming_process(target: subprocess.Popen[str]) -> None: + signal_process(target, signal.SIGTERM) + deadline = time.monotonic() + 5 + while target.poll() is None and time.monotonic() < deadline: + time.sleep(0.05) + if target.poll() is None: + signal_process(target, signal.SIGKILL) + + readers: list[threading.Thread] = [] try: process = subprocess.Popen( command, @@ -1497,38 +2737,46 @@ def finish( stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + bufsize=1, start_new_session=True, ) if process_callback: process_callback(process) + if process.stdout is not None: + readers.append(threading.Thread(target=reader, args=(process.stdout, stdout_parts), daemon=True)) + if process.stderr is not None: + readers.append(threading.Thread(target=reader, args=(process.stderr, stderr_parts), daemon=True)) + for item in readers: + item.start() + if process.stdin is not None: + try: + process.stdin.write(prompt) + process.stdin.close() + except BrokenPipeError: + pass deadline = time.monotonic() + timeout while True: + returncode = process.poll() + if returncode is not None: + break if cancel_event and cancel_event.is_set(): - stop_process(process) + stop_streaming_process(process) return finish( "Canceled: job stopped before Codex replied.", session_id, "canceled", ) - remaining = deadline - time.monotonic() - if remaining <= 0: - stop_process(process) + if time.monotonic() >= deadline: + stop_streaming_process(process) return finish( f"Blocked: Codex timed out after {timeout} seconds. " "The task was stopped before it could reply.", session_id, "timeout", ) - try: - stdout, stderr = process.communicate( - input=input_text, - timeout=min(0.5, remaining), - ) - returncode = process.returncode - break - except subprocess.TimeoutExpired: - input_text = None - continue + time.sleep(0.1) + for item in readers: + item.join(timeout=1) if output_path.exists(): answer = output_path.read_text(errors="replace").strip() else: @@ -1541,18 +2789,21 @@ def finish( except OSError: pass + stdout = "".join(stdout_parts) + stderr = "".join(stderr_parts) + combined_output = "\n".join(part for part in [stdout, stderr] if part) + if cancel_event and cancel_event.is_set(): return finish("Canceled: job stopped before Codex replied.", session_id, "canceled") if returncode != 0: return finish( - f"Codex failed with exit {returncode}. Run local diagnostics with ./scripts/doctor.sh.", + codex_failure_message(returncode, combined_output), session_id, "failed", returncode, ) - combined_output = "\n".join(part for part in [stdout, stderr] if part) new_session_id = extract_session_id(combined_output) or session_id return finish(answer or "(Codex returned an empty final message.)", new_session_id, "ok", 0) @@ -1564,14 +2815,25 @@ def command_help() -> str: "/ping - check the bridge", "/health - fast local bridge checks", "/jobs - show running and last run", + "/activity - summarize running jobs, pending requests, and recent safe history", + "/queue [text] - show or add a pending request", + "/queue next id - move a pending request to the front", + "/forget [id|latest|all] - remove pending request", + "/forgetphotos [id|latest|all] - remove saved images from pending requests", + "/watch - edit the job status message with live Codex progress", + "/unwatch - stop live job status edits", "/history - show recent run receipts", "/cancel [job] - stop a running job", + "/recover [restart] - run local self-recovery through scripts/recover.sh", + "/terminal help - persistent interactive terminal sessions", + "/file path - send a local file back to Telegram", "/automations - inspect Codex automations through Codex", "/new name - start a fresh Codex thread", "/use name - switch threads", "/list - show threads", "/where - show current thread and folder", "/cd path - set this thread's folder", + "/think mode - set thinking mode: low, medium, high, xhigh, or default", "/status - show runtime state", "/policy - show safety boundaries", "/screenshot - send the Mac screen back to Telegram", @@ -1581,7 +2843,7 @@ def command_help() -> str: "/verbose - detailed replies for this thread", "/update - show local update command", "/capabilities - show what this remote can do", - "/gemini - show optional Gemini assist status", + "/gemini [key|on|off] - configure optional mobile assist", "/try - show good first prompts", "/tools - probe Codex tool access", "/reset - restart the current thread", @@ -1639,8 +2901,13 @@ def health_text() -> str: ( "model", True, - f"{os.environ.get('CODEX_TELEGRAM_MODEL', 'gpt-5.5')} / " - f"{env_choice('CODEX_TELEGRAM_REASONING_EFFORT', DEFAULT_REASONING_EFFORT, REASONING_EFFORTS)}", + os.environ.get("CODEX_TELEGRAM_MODEL", "gpt-5.5"), + "", + ), + ( + "thinking mode", + True, + thinking_mode_default(), "", ), ] @@ -1660,7 +2927,7 @@ def status_text(thread: dict[str, Any], chat_id: Optional[int] = None) -> str: f"thread: {thread.get('name', DEFAULT_THREAD)} ({session_status})", f"folder: {thread.get('workdir', default_workdir())}", f"model: {os.environ.get('CODEX_TELEGRAM_MODEL', 'gpt-5.5')}", - f"reasoning effort: {env_choice('CODEX_TELEGRAM_REASONING_EFFORT', DEFAULT_REASONING_EFFORT, REASONING_EFFORTS)}", + f"thinking mode: {thinking_mode_status(thread)}", f"reply style: {thread.get('reply_style') or reply_style_default()}", f"sandbox: {os.environ.get('CODEX_TELEGRAM_SANDBOX', 'danger-full-access')}", f"approval: {os.environ.get('CODEX_TELEGRAM_APPROVAL', 'never')}", @@ -1668,9 +2935,13 @@ def status_text(thread: dict[str, Any], chat_id: Optional[int] = None) -> str: f"reply threading: {'enabled' if env_bool('CODEX_TELEGRAM_REPLY_TO_MESSAGES', False) else 'disabled'}", f"group chats: {'enabled' if env_bool('CODEX_TELEGRAM_ALLOW_GROUP_CHATS', False) else 'disabled'}", f"typing interval: {max(1, env_int('CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS', 4))}s", - "telegram images: enabled", + f"live job updates: {'on' if progress_enabled(thread) else 'off'}", + f"telegram images: enabled; max {max_images_per_message()}", + f"file transfer max: {max_file_transfer_bytes()} bytes", f"gemini assist: {'enabled' if gemini_enabled() else 'disabled'}", + f"terminals: {len(terminal_list(chat_id)) if chat_id is not None else 0}", f"running jobs: {len(running)}", + f"pending requests: {len(pending_requests(thread))}", ] lines.extend(last_run_lines(thread)) if running: @@ -1687,7 +2958,7 @@ def alive_text(thread: dict[str, Any]) -> str: f"thread: {thread.get('name', DEFAULT_THREAD)} ({session_status})", f"folder: {thread.get('workdir', default_workdir())}", f"model: {os.environ.get('CODEX_TELEGRAM_MODEL', 'gpt-5.5')}", - f"reasoning: {env_choice('CODEX_TELEGRAM_REASONING_EFFORT', DEFAULT_REASONING_EFFORT, REASONING_EFFORTS)}", + f"thinking: {thinking_mode_status(thread)}", f"style: {thread.get('reply_style') or reply_style_default()}", "remote: Telegram -> LaunchAgent -> Codex CLI -> this Mac", "next: send /tools, /try, or a normal task.", @@ -1721,17 +2992,26 @@ def job_line(job: RelayJob) -> str: if image_count: image_label = "image" if image_count == 1 else "images" image_note = f"; {image_count} {image_label}" - return f"{job.id}: {job.thread_name}; running {job.elapsed()}{image_note}" + phase, _revision, lines, _message_id = job.progress_snapshot() + useful_lines = [line for line in lines if progress_line_displayable(line)] + latest = f"; progress: {useful_lines[-1]}" if useful_lines else "" + request = f"; request: {job.request_preview}" if job.request_preview else "" + return f"{job.id}: {job.thread_name}; {phase}; running {job.elapsed()}{image_note}{request}{latest}" def jobs_text(chat_id: int, thread: dict[str, Any]) -> str: running = jobs_for_chat(chat_id) lines = ["running jobs:"] if running: - lines.extend(f"- {job_line(job)}" for job in sorted(running, key=lambda item: item.started_at)) + for job in sorted(running, key=lambda item: item.started_at): + lines.append(f"- {job_line(job)}") lines.append("cancel: /cancel job-id") + lines.append(f"live updates: {'on' if progress_enabled(thread) else 'off'}") else: lines.append("- none") + pending_count = len(pending_requests(thread)) + if pending_count: + lines.append(f"pending: {pending_count}") lines.extend(last_run_lines(thread)) return "\n".join(lines) @@ -1765,38 +3045,128 @@ def set_reply_style_text(thread: dict[str, Any], style: str) -> str: return "Reply style: brief" -def history_text(chat_id: int) -> str: - events = [event for event in read_history(12) if event.get("chat_id") == chat_id] - if not events: - return "history: none" - lines = ["history:"] - for event in events[-8:]: - pieces = [ - str(event.get("status", "unknown")), - str(event.get("thread", DEFAULT_THREAD)), - ] - if event.get("latency_seconds") is not None: - pieces.append(f"{event['latency_seconds']}s") - if event.get("image_count"): - image_count = int_or_none(event.get("image_count")) or 0 - image_label = "image" if image_count == 1 else "images" - pieces.append(f"{image_count} {image_label}") - if event.get("folder"): - pieces.append(str(event["folder"])) - lines.append("- " + "; ".join(pieces)) - return "\n".join(lines) +def history_text(chat_id: int) -> str: + events = [event for event in read_history(12) if event.get("chat_id") == chat_id] + if not events: + return "history: none" + lines = ["history:"] + for event in events[-8:]: + pieces = [ + str(event.get("status", "unknown")), + str(event.get("thread", DEFAULT_THREAD)), + ] + if event.get("latency_seconds") is not None: + pieces.append(f"{event['latency_seconds']}s") + if event.get("image_count"): + image_count = int_or_none(event.get("image_count")) or 0 + image_label = "image" if image_count == 1 else "images" + pieces.append(f"{image_count} {image_label}") + if event.get("folder"): + pieces.append(str(event["folder"])) + lines.append("- " + "; ".join(pieces)) + return "\n".join(lines) + + +def concise_external_error(service: str, exc: BaseException) -> str: + detail = str(exc).strip() + lowered = detail.lower() + gateway_terms = ("gateway", "502", "503", "504", "unavailable", "timed out", "timeout") + if any(term in lowered for term in gateway_terms): + return f"{service} gateway looks down or unreachable right now." + if "network" in lowered or "connection" in lowered: + return f"{service} network call failed." + if "429" in lowered or "rate" in lowered: + return f"{service} is rate limited right now." + if not detail: + return f"{service} failed without a readable error." + clean = sanitize_progress_line(detail) + if not clean: + return f"{service} failed. I hid the raw error because it may contain private details." + return f"{service} failed: {clean[:240]}" + + +def codex_failure_message(returncode: int, output: str) -> str: + lowered = output.lower() + if any(term in lowered for term in ("gateway", "bad gateway", "502", "503", "504", "service unavailable")): + return ( + "Codex gateway error: Telegram is reachable, but the local Codex CLI could not reach " + "its backend. Wait a bit and retry. If local relay health also looks wrong, send /recover." + ) + if any(term in lowered for term in ("network", "connection reset", "connection refused", "timed out", "timeout")): + return ( + "Codex network error: Telegram is reachable, but the local Codex CLI could not complete " + "its backend connection. Check the Mac network or retry. For local relay diagnostics, send /recover." + ) + if any(term in lowered for term in ("rate limit", "429", "quota")): + return "Codex rate limit or quota error. The relay is alive; retry after the account limit clears." + return f"Codex failed with exit {returncode}. Run local diagnostics with ./scripts/doctor.sh or send /recover." + + +def activity_snapshot_text(chat_id: int, thread: dict[str, Any]) -> str: + running = jobs_for_chat(chat_id) + lines = [ + "activity:", + f"thread: {thread.get('name', DEFAULT_THREAD)}", + f"folder: {thread.get('workdir', default_workdir())}", + f"thinking: {thinking_mode_status(thread)}", + ] + if running: + lines.append("running:") + for job in sorted(running, key=lambda item: item.started_at): + lines.append(f"- {job_line(job)}") + else: + lines.append("running: none") + sessions = terminal_list(chat_id) + if sessions: + lines.append("terminals:") + for session in sessions: + lines.append(f"- {session.name}: running {session.elapsed()}; cwd={session.cwd}") + else: + lines.append("terminals: none") + pending = pending_queue_text(thread) + lines.append(pending) + history = history_text(chat_id) + if history != "history: none": + lines.append(history) + lines.extend(last_run_lines(thread)) + return "\n".join(lines) + + +def activity_text(chat_id: int, thread: dict[str, Any]) -> str: + snapshot = activity_snapshot_text(chat_id, thread) + if not gemini_enabled() or not gemini_allows_text(snapshot): + return snapshot + prompt = f"""Summarize this Codex Relay activity for a private Telegram user. + +Rules: +- Do not add facts. +- Do not ask for secrets. +- Keep it short and useful on a phone. +- Say whether Codex is currently running, what is queued, and what the safest next command is. + +Relay activity: +{snapshot} +""" + try: + summary = gemini_generate(prompt).strip() + except Exception as exc: + return snapshot + "\n\n" + concise_external_error("Gemini", exc) + return summary or snapshot def job_ack_text(job: RelayJob) -> str: lines = [ - f"Working: job {job.id}", + f"I'm on it: job {job.id}", f"thread: {job.thread_name}", - "status: running", ] + if job.request_preview: + lines.append(f"request: {job.request_preview}") + lines.append("status: running") if job.image_count: image_label = "image" if job.image_count == 1 else "images" lines.append(f"attachments: {job.image_count} {image_label}") - lines.append(f"check: /jobs") + lines.append("check: /jobs") + lines.append("live updates: /watch") lines.append(f"stop: /cancel {job.id}") return "\n".join(lines) @@ -1819,6 +3189,284 @@ def cancel_text(chat_id: int, arg: str) -> str: return f"Cancel requested: {running[0].id}" +def recovery_timeout_seconds() -> int: + return max(60, env_int("CODEX_RELAY_RECOVERY_TIMEOUT_SECONDS", 1200)) + + +def relay_repo_dir() -> Path: + raw = os.environ.get("CODEX_RELAY_REPO_DIR", "").strip() + if raw: + return Path(raw).expanduser().resolve() + runtime_parent = ROOT.parent + if (runtime_parent / "codex_relay.py").exists() and (runtime_parent / "scripts").exists(): + return runtime_parent.resolve() + return ROOT + + +def relay_env_update_paths() -> list[Path]: + paths: list[Path] = [] + seen: set[Path] = set() + candidates = [ENV_PATH] + repo_env = relay_repo_dir() / ".env" + if repo_env != ENV_PATH and repo_env.exists(): + candidates.append(repo_env) + for path in candidates: + resolved = path.expanduser().resolve() + if resolved in seen: + continue + seen.add(resolved) + paths.append(resolved) + return paths + + +def persist_relay_env_updates(updates: dict[str, str]) -> None: + for path in relay_env_update_paths(): + update_private_env_file(path, updates) + for key, value in updates.items(): + os.environ[key] = value + + +def valid_gemini_api_key(value: str) -> bool: + candidate = value.strip() + if not candidate or any(ch.isspace() for ch in candidate): + return False + if len(candidate) > 256: + return False + return GEMINI_API_KEY_RE.fullmatch(candidate) is not None + + +def extract_gemini_api_key(value: str) -> str: + match = GEMINI_API_KEY_RE.search(value) + if match: + return match.group(1) + candidate = value.strip() + if valid_gemini_api_key(candidate): + return candidate + return "" + + +def delete_secret_message(api: TelegramAPI, chat_id: int, message_id: Optional[int]) -> None: + if message_id is None: + return + try: + api.delete_message(chat_id, int(message_id)) + except Exception: + pass + + +def set_gemini_assist( + api_key: Optional[str] = None, + enabled: Optional[bool] = None, + force_mobile_defaults: bool = False, +) -> None: + updates: dict[str, str] = {} + if api_key is not None: + updates["CODEX_RELAY_GEMINI_API_KEY"] = api_key + if enabled is not None: + updates["CODEX_RELAY_GEMINI_ENABLED"] = "true" if enabled else "false" + if force_mobile_defaults: + updates["CODEX_RELAY_GEMINI_NATURAL_COMMANDS"] = "true" + updates["CODEX_RELAY_GEMINI_POLISH"] = "true" + if updates: + persist_relay_env_updates(updates) + + +def gemini_status_text() -> str: + lines = [ + "Gemini assist:", + f"- status: {'enabled' if gemini_enabled() else 'disabled'}", + f"- model: {gemini_model()}", + f"- max output tokens: {gemini_max_output_tokens()}", + f"- natural commands: {'enabled' if gemini_natural_commands_enabled() else 'disabled'}", + f"- polish: {'enabled' if gemini_polish_enabled() else 'disabled'}", + ] + if gemini_configured(): + lines.append("- key: configured") + lines.append("- controls: /gemini on, /gemini off, /gemini clear") + else: + lines.append("- setup: /gemini key YOUR_GEMINI_API_KEY") + lines.append("- reload: immediate; no Mac-side restart needed") + return "\n".join(lines) + + +def gemini_command_text( + api: TelegramAPI, + chat_id: int, + message_id: Optional[int], + arg: str, +) -> str: + raw = arg.strip() + if not raw or raw.lower() in {"status", "help"}: + return gemini_status_text() + + command, _, rest = raw.partition(" ") + action = command.lower() + + if valid_gemini_api_key(raw): + action = "key" + rest = raw + + if action in {"key", "set", "setup"}: + api_key = extract_gemini_api_key(rest) + if not api_key: + return "Usage: /gemini key YOUR_GEMINI_API_KEY" + set_gemini_assist(api_key, True, True) + delete_secret_message(api, chat_id, message_id) + return "Gemini assist enabled. Key saved privately and loaded in this running relay." + + if action in {"on", "enable"}: + api_key = extract_gemini_api_key(rest) + if api_key: + set_gemini_assist(api_key, True, True) + delete_secret_message(api, chat_id, message_id) + return "Gemini assist enabled. Key saved privately and loaded in this running relay." + if not gemini_configured(): + return "Missing Gemini API key. Send /gemini key YOUR_GEMINI_API_KEY." + set_gemini_assist(enabled=True, force_mobile_defaults=True) + return "Gemini assist enabled and reloaded." + + if action in {"off", "disable"}: + set_gemini_assist(enabled=False) + return "Gemini assist disabled." + + if action in {"clear", "remove", "forget"}: + set_gemini_assist("", False) + return "Gemini assist key cleared and disabled." + + return "Unknown Gemini command. Use /gemini, /gemini key YOUR_GEMINI_API_KEY, /gemini on, or /gemini off." + + +def recovery_script_path() -> Path: + raw = os.environ.get("CODEX_RELAY_RECOVERY_SCRIPT", "").strip() + if raw: + return Path(raw).expanduser().resolve() + repo_script = relay_repo_dir() / "scripts" / "recover.sh" + if repo_script.exists(): + return repo_script + return ROOT / "scripts" / "recover.sh" + + +def sanitize_shell_output(output: str, limit: int = 3600) -> str: + safe_lines: list[str] = [] + for line in output.splitlines(): + clean = sanitize_progress_line(line) + if clean: + safe_lines.append(clean) + text = "\n".join(safe_lines).strip() + if not text: + return "(no safe output)" + if len(text) > limit: + text = text[-limit:].lstrip() + first_newline = text.find("\n") + if first_newline != -1: + text = text[first_newline + 1 :] + text = "(tail)\n" + text + return text + + +def run_recovery_command(job: RelayJob, arg: str) -> tuple[str, int]: + script = recovery_script_path() + if not script.exists(): + return f"Recovery script missing: {script}", 127 + command = ["/bin/zsh", str(script)] + if arg.strip(): + command.extend(arg.strip().split()) + env = os.environ.copy() + env.setdefault("CODEX_RELAY_REPO_DIR", str(relay_repo_dir())) + timeout = recovery_timeout_seconds() + started = time.monotonic() + output_parts: list[str] = [] + try: + process = subprocess.Popen( + command, + cwd=relay_repo_dir(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + start_new_session=True, + ) + except OSError as exc: + return f"Could not start recovery script: {exc}", 126 + job.set_process(process) + + def reader() -> None: + if process.stdout is None: + return + try: + for line in iter(process.stdout.readline, ""): + output_parts.append(line) + job.add_progress(line) + except Exception: + pass + finally: + try: + process.stdout.close() + except Exception: + pass + + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + while process.poll() is None: + if job.cancel_event.is_set(): + signal_process(process, signal.SIGTERM) + return "Canceled: recovery stopped before it finished.", 130 + if time.monotonic() - started >= timeout: + signal_process(process, signal.SIGTERM) + return f"Recovery timed out after {timeout} seconds.", 124 + time.sleep(0.1) + reader_thread.join(timeout=1) + return sanitize_shell_output("".join(output_parts)), int(process.returncode or 0) + + +def run_recovery_worker( + api: TelegramAPI, + chat_id: int, + job: RelayJob, + arg: str, +) -> None: + try: + with TypingPulse(api, chat_id), ProgressPulse(api, chat_id, job, True): + output, exit_code = run_recovery_command(job, arg) + if exit_code == 0: + api.send_message(chat_id, "Recovery finished.\n\n" + output) + else: + api.send_message(chat_id, f"Recovery failed with exit {exit_code}.\n\n{output}") + except Exception as exc: + api.send_message(chat_id, "Recovery failed: " + concise_external_error("local recovery", exc)) + finally: + finish_job(job) + cleanup_workers() + + +def start_recovery_job( + api: TelegramAPI, + chat_id: int, + arg: str, + reply_to_message_id: Optional[int] = None, +) -> None: + running = jobs_for_thread(chat_id, "recovery") + if running: + api.send_message(chat_id, "Recovery is already running.\n" + "\n".join(job_line(job) for job in running), reply_to_message_id) + return + job = RelayJob(chat_id, "recovery", 0, f"/recover {arg}".strip()) + job.set_status_message(api.send_message(chat_id, job_ack_text(job), reply_to_message_id)) + register_job(job) + worker = threading.Thread( + target=run_recovery_worker, + args=(api, chat_id, job, arg), + daemon=False, + ) + register_worker(worker) + try: + worker.start() + except Exception: + finish_job(job) + cleanup_workers() + raise + + def history_event_from_stats( chat_id: int, thread_name: str, @@ -1867,13 +3515,14 @@ def run_job_worker( record_history: bool = True, ) -> None: try: - with TypingPulse(api, chat_id): + with TypingPulse(api, chat_id), ProgressPulse(api, chat_id, job, progress_enabled(thread_snapshot)): answer, session_id, stats = run_codex( prompt_text, thread_snapshot, image_paths, job.cancel_event, job.set_process, + job.add_progress, ) if persist_thread_state: with THREADS_LOCK: @@ -1908,6 +3557,8 @@ def run_job_worker( finally: finish_job(job) cleanup_workers() + if persist_thread_state: + start_next_pending_job(api, chat_id, threads_path, thread_name) def start_background_job( @@ -1932,9 +3583,9 @@ def start_background_job( reply_to_message_id, ) return - job = RelayJob(chat_id, thread_name, len(image_paths)) + job = RelayJob(chat_id, thread_name, len(image_paths), prompt_text) try: - api.send_message(chat_id, job_ack_text(job), reply_to_message_id) + job.set_status_message(api.send_message(chat_id, job_ack_text(job), reply_to_message_id)) except Exception: finish_job(job) raise @@ -1964,6 +3615,44 @@ def start_background_job( raise +def start_next_pending_job( + api: TelegramAPI, + chat_id: int, + threads_path: Path, + thread_name: str, +) -> None: + if jobs_for_thread(chat_id, thread_name): + return + with THREADS_LOCK: + data = read_threads(threads_path) + thread = ensure_thread(data, chat_id, thread_name) + item = pop_next_pending_request(thread) + if item is None: + return + thread["updated_at"] = now_iso() + write_threads(threads_path, data) + thread_snapshot = dict(thread) + try: + image_paths = pending_paths_for_codex(item) + image_count = len(image_paths) + image_note = "" + if image_count: + image_label = "image" if image_count == 1 else "images" + image_note = f" with {image_count} {image_label}" + api.send_message(chat_id, f"Starting queued request {item['id']}{image_note}: {prompt_preview(item['prompt'])}") + start_background_job( + api, + chat_id, + threads_path, + thread_name, + thread_snapshot, + item["prompt"], + image_paths, + ) + except Exception: + pass + + def capabilities_text() -> str: return "\n".join( [ @@ -1974,6 +3663,8 @@ def capabilities_text() -> str: "- run tests, scripts, git, and shell commands", "- read Telegram photo and image-document attachments", "- send a current Mac screenshot back to Telegram with /screenshot", + "- send local files back to Telegram with /file", + "- keep persistent interactive terminal sessions with /terminal", "- use Computer Use, Browser Use, apps/connectors, images, and subagents when your Codex runtime exposes them", "- inspect Codex automations with /automations", "- operate local app/browser sessions when macOS permissions and logins allow it", @@ -1989,7 +3680,7 @@ def policy_text() -> str: [ "Policy:", "- allowed: local repo/file/test/shell work inside your configured Codex sandbox", - "- allowed: Telegram images, /screenshot, named threads, local status, and automations inspection", + "- allowed: Telegram images, /screenshot, /file, /terminal, named threads, local status, and automations inspection", "- stops before: public posts, messages to people, account/security changes, payments, purchases, deletes, or medical/legal/financial submissions", "- cannot bypass: logins, MFA, CAPTCHAs, macOS privacy prompts, site safety barriers, Codex/OpenAI limits, or required confirmations", "- bot access: only the allow-listed Telegram user/chat can run tasks", @@ -2009,6 +3700,9 @@ def try_text() -> str: " read this repo and make the README more impressive without pushing", "4. send a screenshot/photo and ask what I should do next", "5. use available local tools to tell me what apps are open and what looks unfinished", + "6. /terminal open setup -- gh auth login", + " /terminal read setup", + "7. /file README.md", ] ) @@ -2025,6 +3719,66 @@ def update_text() -> str: ) +def builtin_natural_control( + api: TelegramAPI, + chat_id: int, + message_id: Optional[int], + threads_path: Path, + text: str, + prefix: str = "", +) -> bool: + lowered = text.strip().lower() + if not lowered: + return False + gemini_key = extract_gemini_api_key(text) + if gemini_key: + set_gemini_assist(gemini_key, True, True) + delete_secret_message(api, chat_id, message_id) + reply = "Gemini assist enabled. Key saved privately and loaded in this running relay." + api.send_message(chat_id, "\n\n".join(part for part in [prefix, reply] if part), None) + return True + if any(phrase in lowered for phrase in ("what's going on", "what is going on", "what are you doing", "where are we")): + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + reply = activity_snapshot_text(chat_id, thread) + api.send_message(chat_id, "\n\n".join(part for part in [prefix, reply] if part), message_id) + return True + + mode_match = re.search(r"\b(low|medium|normal|high|deep|xhigh|x-high|extra-high|default)\b", lowered) + if mode_match and any(word in lowered for word in ("think", "thinking", "reasoning")): + try: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + reply = set_thinking_mode_text(thread, mode_match.group(1)) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, "\n\n".join(part for part in [prefix, str(exc)] if part), message_id) + return True + api.send_message(chat_id, "\n\n".join(part for part in [prefix, reply] if part), message_id) + return True + + forget_words = ("never mind", "nevermind", "forget that", "drop that", "ignore that") + if any(phrase in lowered for phrase in forget_words): + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + if any(word in lowered for word in ("photo", "photos", "image", "images", "pic", "pics")): + changed, deleted = remove_pending_images(thread, "latest") + write_threads(threads_path, data) + if changed: + image_label = "image" if deleted == 1 else "images" + reply = f"Removed {deleted} saved {image_label} from pending: " + ", ".join(item["id"] for item in changed) + else: + reply = "No pending images matched." + else: + removed = remove_pending_request(thread, "latest") + write_threads(threads_path, data) + reply = "Removed pending: " + ", ".join(item["id"] for item in removed) if removed else "No pending request matched." + api.send_message(chat_id, "\n\n".join(part for part in [prefix, reply] if part), message_id) + return True + + return False + + def handle_message( api: TelegramAPI, message: dict[str, Any], @@ -2188,6 +3942,12 @@ def handle_message( api.send_message(chat_id, status_text(thread, chat_id), message_id) return + if command in {"/activity", "/now", "/what"}: + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + api.send_message(chat_id, activity_text(chat_id, thread), message_id) + return + if command == "/latency": with THREADS_LOCK: _data, _active_name, thread = active_state(threads_path, chat_id) @@ -2210,6 +3970,92 @@ def handle_message( api.send_message(chat_id, reply, message_id) return + if command in {"/think", "/thinking", "/reasoning"}: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + if not arg.strip(): + reply = thinking_mode_help_text(thread) + else: + try: + reply = set_thinking_mode_text(thread, arg) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return + write_threads(threads_path, data) + api.send_message(chat_id, reply, message_id) + return + + if command in {"/watch", "/subscribe"}: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + reply = set_progress_updates_text(thread, True) + write_threads(threads_path, data) + api.send_message(chat_id, reply, message_id) + return + + if command in {"/unwatch", "/unsubscribe"}: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + reply = set_progress_updates_text(thread, False) + write_threads(threads_path, data) + api.send_message(chat_id, reply, message_id) + return + + if command in {"/queue", "/pending"}: + queue_arg = arg.strip() + if not queue_arg: + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + api.send_message(chat_id, pending_queue_text(thread), message_id) + return + if queue_arg.lower().startswith("next "): + selector = queue_arg[5:].strip() or "latest" + try: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + item = prioritize_pending_request(thread, selector) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return + api.send_message(chat_id, f"Next up: {item['id']}: {prompt_preview(item['prompt'])}", message_id) + return + try: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + item = queue_pending_request(thread, queue_arg) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, str(exc), message_id) + return + api.send_message(chat_id, f"Queued request {item['id']}: {prompt_preview(item['prompt'])}", message_id) + return + + if command in {"/forget", "/dequeue"}: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + removed = remove_pending_request(thread, arg) + write_threads(threads_path, data) + if removed: + reply = "Removed pending: " + ", ".join(item["id"] for item in removed) + else: + reply = "No pending request matched." + api.send_message(chat_id, reply, message_id) + return + + if command in {"/forgetphotos", "/dropimages", "/dropphotos"}: + with THREADS_LOCK: + data, _active_name, thread = active_state(threads_path, chat_id) + changed, deleted = remove_pending_images(thread, arg) + write_threads(threads_path, data) + if changed: + image_label = "image" if deleted == 1 else "images" + reply = f"Removed {deleted} saved {image_label} from pending: " + ", ".join(item["id"] for item in changed) + else: + reply = "No pending images matched." + api.send_message(chat_id, reply, message_id) + return + if command in {"/jobs", "/job"}: with THREADS_LOCK: _data, _active_name, thread = active_state(threads_path, chat_id) @@ -2238,6 +4084,27 @@ def handle_message( api.send_message(chat_id, policy_text(), message_id) return + if command in {"/terminal", "/term"}: + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + try: + reply = terminal_command_text(chat_id, thread, arg) + except (RuntimeError, ValueError, OSError) as exc: + reply = str(exc) + api.send_message(chat_id, reply, message_id) + return + + if command in {"/file", "/fetch", "/sendfile"}: + with THREADS_LOCK: + _data, _active_name, thread = active_state(threads_path, chat_id) + try: + note = send_file_to_telegram(api, chat_id, thread, arg, message_id) + except (RuntimeError, ValueError) as exc: + api.send_message(chat_id, str(exc), message_id) + return + api.send_message(chat_id, note, message_id) + return + if command in {"/screenshot", "/screen"}: try: with TypingPulse(api, chat_id, "upload_photo"): @@ -2252,22 +4119,18 @@ def handle_message( return if command in {"/gemini", "/assistant"}: - lines = [ - "Gemini assist:", - f"- status: {'enabled' if gemini_enabled() else 'disabled'}", - f"- model: {gemini_model()}", - f"- natural commands: {'enabled' if gemini_natural_commands_enabled() else 'disabled'}", - f"- polish: {'enabled' if gemini_polish_enabled() else 'disabled'}", - ] - if not gemini_configured(): - lines.append("- setup: add CODEX_RELAY_GEMINI_API_KEY to .env and run ./scripts/install_launch_agent.sh") - api.send_message(chat_id, "\n".join(lines), message_id) + reply = gemini_command_text(api, chat_id, message_id, arg) + api.send_message(chat_id, reply, None if "key saved privately" in reply.lower() else message_id) return if command == "/update": api.send_message(chat_id, update_text(), message_id) return + if command in {"/recover", "/selfheal", "/repair"}: + start_recovery_job(api, chat_id, arg, message_id) + return + if command in {"/automations", "/automation"}: with THREADS_LOCK: _data, active_name, thread = active_state(threads_path, chat_id) @@ -2324,6 +4187,9 @@ def handle_message( api.send_message(chat_id, "Unknown command. Use /help.", message_id) return + if not image_specs and builtin_natural_control(api, chat_id, message_id, threads_path, text): + return + if gemini_natural_commands_enabled() and not image_specs and gemini_allows_text(text): try: with THREADS_LOCK: @@ -2332,8 +4198,13 @@ def handle_message( plan = gemini_plan_for_message(text, active_name, thread, threads) if execute_gemini_plan(api, chat_id, message_id, threads_path, plan, text): return - except Exception: - pass + except Exception as exc: + if env_bool("CODEX_RELAY_GEMINI_ERROR_NOTICES", True): + api.send_message( + chat_id, + concise_external_error("Gemini assist", exc) + "\nContinuing with the normal Codex path.", + message_id, + ) with THREADS_LOCK: _data, active_name, thread = active_state(threads_path, chat_id) @@ -2349,6 +4220,27 @@ def handle_message( "Inspect the attached Telegram image and answer directly. " "Do not mention file paths." ) + busy = busy_thread_message(chat_id, active_name) + if busy: + try: + with THREADS_LOCK: + data, active_name, thread = active_state(threads_path, chat_id) + item = queue_pending_request(thread, prompt_text, image_paths) + write_threads(threads_path, data) + except ValueError as exc: + api.send_message(chat_id, f"{busy}\n{exc}", message_id) + return + image_count = len(image_paths) + image_note = "" + if image_count: + image_label = "image" if image_count == 1 else "images" + image_note = f" with {image_count} {image_label}" + api.send_message( + chat_id, + f"Queued request {item['id']}{image_note} until thread `{active_name}` is clear: {prompt_preview(item['prompt'])}", + message_id, + ) + return start_background_job( api, chat_id, @@ -2375,10 +4267,12 @@ def check_config() -> int: print(f"codex={shutil.which(codex_bin) or 'missing'}") print(f"sandbox={os.environ.get('CODEX_TELEGRAM_SANDBOX', 'danger-full-access')}") print(f"model={os.environ.get('CODEX_TELEGRAM_MODEL', 'gpt-5.5')}") - print(f"reasoning_effort={env_choice('CODEX_TELEGRAM_REASONING_EFFORT', DEFAULT_REASONING_EFFORT, REASONING_EFFORTS)}") + print(f"thinking_mode={thinking_mode_default()}") + print(f"reasoning_effort={thinking_mode_default()}") print(f"reply_style={reply_style_default()}") print(f"gemini_enabled={gemini_enabled()}") print(f"gemini_model={gemini_model()}") + print(f"gemini_max_output_tokens={gemini_max_output_tokens()}") print(f"gemini_natural_commands={gemini_natural_commands_enabled()}") print(f"gemini_polish={gemini_polish_enabled()}") print(f"approval={os.environ.get('CODEX_TELEGRAM_APPROVAL', 'never')}") @@ -2387,6 +4281,12 @@ def check_config() -> int: print(f"reply_unauthorized={env_bool('CODEX_TELEGRAM_REPLY_UNAUTHORIZED', False)}") print(f"allow_group_chats={env_bool('CODEX_TELEGRAM_ALLOW_GROUP_CHATS', False)}") print(f"typing_interval_seconds={max(1, env_int('CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS', 4))}") + print(f"poll_timeout_seconds={max(1, env_int('CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS', DEFAULT_TELEGRAM_POLL_TIMEOUT_SECONDS))}") + print(f"poll_http_timeout_seconds={max(env_int('CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS', DEFAULT_TELEGRAM_POLL_TIMEOUT_SECONDS) + 10, env_int('CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS', DEFAULT_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS))}") + print(f"max_images_per_message={max_images_per_message()}") + print(f"max_file_bytes={max_file_transfer_bytes()}") + print(f"terminal_buffer_chars={terminal_buffer_chars()}") + print(f"recovery_script={recovery_script_path()}") print(f"telegram_images={'enabled'}") if not token: return 2 @@ -2423,8 +4323,10 @@ def main() -> int: print("Enrollment mode: messages will only return Telegram ids.") offset = read_offset(offset_path) + pending_media_groups: dict[tuple[int, str], tuple[float, list[dict[str, Any]]]] = {} while not SHUTDOWN_EVENT.is_set(): try: + standalone_messages: list[dict[str, Any]] = [] for update in api.get_updates(offset): if SHUTDOWN_EVENT.is_set(): break @@ -2433,7 +4335,30 @@ def main() -> int: write_private_text(offset_path, str(offset)) message = update.get("message") if message: - handle_message(api, message, allowed_users, allowed_chats, threads_path) + group_key = media_group_key(message) + if group_key: + _seen_at, messages = pending_media_groups.get(group_key, (0.0, [])) + messages.append(message) + pending_media_groups[group_key] = (time.monotonic(), messages) + else: + standalone_messages.append(message) + for message in standalone_messages: + handle_message(api, message, allowed_users, allowed_chats, threads_path) + now = time.monotonic() + ready_keys = [ + key + for key, (seen_at, _messages) in pending_media_groups.items() + if now - seen_at >= DEFAULT_MEDIA_GROUP_GRACE_SECONDS + ] + for key in ready_keys: + _seen_at, messages = pending_media_groups.pop(key) + handle_message( + api, + merge_media_group_messages(messages), + allowed_users, + allowed_chats, + threads_path, + ) except KeyboardInterrupt: print("Stopping.") SHUTDOWN_EVENT.set() diff --git a/scripts/configure.py b/scripts/configure.py index 2410282..639c83a 100755 --- a/scripts/configure.py +++ b/scripts/configure.py @@ -67,13 +67,20 @@ def save_env(values: dict[str, str]) -> None: "CODEX_RELAY_GEMINI_API_KEY", "CODEX_RELAY_GEMINI_ENABLED", "CODEX_RELAY_GEMINI_MODEL", + "CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS", "CODEX_RELAY_GEMINI_NATURAL_COMMANDS", "CODEX_RELAY_GEMINI_POLISH", "CODEX_RELAY_GEMINI_TIMEOUT_SECONDS", + "CODEX_RELAY_GEMINI_ERROR_NOTICES", + "CODEX_RELAY_RECOVERY_TIMEOUT_SECONDS", + "CODEX_RELAY_TERMINAL_BUFFER_CHARS", + "CODEX_RELAY_TERMINAL_READ_LIMIT", + "CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER", "CODEX_TELEGRAM_WORKDIR", "CODEX_BIN", "CODEX_TELEGRAM_SANDBOX", "CODEX_TELEGRAM_MODEL", + "CODEX_TELEGRAM_THINKING_MODE", "CODEX_TELEGRAM_REASONING_EFFORT", "CODEX_TELEGRAM_REPLY_STYLE", "CODEX_TELEGRAM_APPROVAL", @@ -82,7 +89,11 @@ def save_env(values: dict[str, str]) -> None: "CODEX_TELEGRAM_REPLY_UNAUTHORIZED", "CODEX_TELEGRAM_ALLOW_GROUP_CHATS", "CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS", + "CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS", + "CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS", + "CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE", "CODEX_TELEGRAM_MAX_IMAGE_BYTES", + "CODEX_TELEGRAM_MAX_FILE_BYTES", "CODEX_TELEGRAM_IMAGE_RETENTION_DAYS", ] lines = ["# Codex Relay private config. Do not commit this file."] @@ -315,13 +326,21 @@ def main() -> int: "CODEX_RELAY_GEMINI_API_KEY": values.get("CODEX_RELAY_GEMINI_API_KEY") or "", "CODEX_RELAY_GEMINI_ENABLED": values.get("CODEX_RELAY_GEMINI_ENABLED") or "true", "CODEX_RELAY_GEMINI_MODEL": values.get("CODEX_RELAY_GEMINI_MODEL") or "gemini-3.1-flash-lite-preview", + "CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS": values.get("CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS") or "4096", "CODEX_RELAY_GEMINI_NATURAL_COMMANDS": values.get("CODEX_RELAY_GEMINI_NATURAL_COMMANDS") or "true", "CODEX_RELAY_GEMINI_POLISH": values.get("CODEX_RELAY_GEMINI_POLISH") or "true", "CODEX_RELAY_GEMINI_TIMEOUT_SECONDS": values.get("CODEX_RELAY_GEMINI_TIMEOUT_SECONDS") or "20", + "CODEX_RELAY_GEMINI_ERROR_NOTICES": values.get("CODEX_RELAY_GEMINI_ERROR_NOTICES") or "true", + "CODEX_RELAY_RECOVERY_TIMEOUT_SECONDS": values.get("CODEX_RELAY_RECOVERY_TIMEOUT_SECONDS") or "1200", + "CODEX_RELAY_TERMINAL_BUFFER_CHARS": values.get("CODEX_RELAY_TERMINAL_BUFFER_CHARS") or "20000", + "CODEX_RELAY_TERMINAL_READ_LIMIT": values.get("CODEX_RELAY_TERMINAL_READ_LIMIT") or "4000", + "CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER": values.get("CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER") or "false", "CODEX_BIN": codex_bin, "CODEX_TELEGRAM_SANDBOX": values.get("CODEX_TELEGRAM_SANDBOX") or "danger-full-access", "CODEX_TELEGRAM_MODEL": values.get("CODEX_TELEGRAM_MODEL") or "gpt-5.5", - "CODEX_TELEGRAM_REASONING_EFFORT": values.get("CODEX_TELEGRAM_REASONING_EFFORT") or "xhigh", + "CODEX_TELEGRAM_THINKING_MODE": values.get("CODEX_TELEGRAM_THINKING_MODE") + or values.get("CODEX_TELEGRAM_REASONING_EFFORT") + or "xhigh", "CODEX_TELEGRAM_REPLY_STYLE": values.get("CODEX_TELEGRAM_REPLY_STYLE") or "brief", "CODEX_TELEGRAM_APPROVAL": values.get("CODEX_TELEGRAM_APPROVAL") or "never", "CODEX_TELEGRAM_TIMEOUT_SECONDS": values.get("CODEX_TELEGRAM_TIMEOUT_SECONDS") or "600", @@ -329,7 +348,11 @@ def main() -> int: "CODEX_TELEGRAM_REPLY_UNAUTHORIZED": values.get("CODEX_TELEGRAM_REPLY_UNAUTHORIZED") or "false", "CODEX_TELEGRAM_ALLOW_GROUP_CHATS": values.get("CODEX_TELEGRAM_ALLOW_GROUP_CHATS") or "false", "CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS": values.get("CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS") or "4", + "CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS": values.get("CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS") or "25", + "CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS": values.get("CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS") or "60", + "CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE": values.get("CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE") or "10", "CODEX_TELEGRAM_MAX_IMAGE_BYTES": values.get("CODEX_TELEGRAM_MAX_IMAGE_BYTES") or "20971520", + "CODEX_TELEGRAM_MAX_FILE_BYTES": values.get("CODEX_TELEGRAM_MAX_FILE_BYTES") or "20971520", "CODEX_TELEGRAM_IMAGE_RETENTION_DAYS": values.get("CODEX_TELEGRAM_IMAGE_RETENTION_DAYS") or "7", } ) diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 692c341..399aa98 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -14,7 +14,18 @@ cd "$ROOT" [[ "$(uname -s)" == "Darwin" ]] && ok "macOS detected" || fail "Codex Relay is macOS-first" -if [[ -x "/Applications/Codex.app/Contents/Resources/codex" ]]; then +configured_codex="" +if [[ -f "$ROOT/.env" ]]; then + configured_codex="$(sed -n 's/^CODEX_BIN=//p' "$ROOT/.env" | head -n 1)" + configured_codex="${configured_codex#\"}" + configured_codex="${configured_codex%\"}" + configured_codex="${configured_codex#\'}" + configured_codex="${configured_codex%\'}" +fi + +if [[ -n "$configured_codex" && -x "$configured_codex" ]]; then + ok "configured Codex CLI found" +elif [[ -x "/Applications/Codex.app/Contents/Resources/codex" ]]; then ok "Codex app CLI found" elif command -v codex >/dev/null 2>&1; then warn "using PATH codex; Codex app CLI not found at /Applications/Codex.app" diff --git a/scripts/install.sh b/scripts/install.sh index 8dfc00d..29f27d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -36,5 +36,7 @@ printf "\nRunning doctor...\n" printf "\nDone. DM your bot:\n" printf "/alive\n/health\n/policy\n/screenshot\n/tools\n/latency\n" +printf "\nOptional Gemini mobile harness powered by Flash 3.1 Lite:\n" +printf "/gemini key YOUR_GEMINI_API_KEY\n" printf "\nOptional Mac control surface:\n./scripts/menu_bar.sh\n" printf "\nLocal status page:\n./scripts/status_ui.sh\n" diff --git a/scripts/install_launch_agent.sh b/scripts/install_launch_agent.sh index 154b868..845cd79 100755 --- a/scripts/install_launch_agent.sh +++ b/scripts/install_launch_agent.sh @@ -35,8 +35,49 @@ source = root / ".env" target = runtime / ".env" lines = source.read_text().splitlines() updates = { + "CODEX_RELAY_REPO_DIR": str(root), "CODEX_TELEGRAM_STATE_DIR": str(state_dir), } +thinking_mode = "" +for line in lines: + if line.startswith("CODEX_TELEGRAM_THINKING_MODE="): + thinking_mode = line.split("=", 1)[1].strip() + break +if not thinking_mode: + for line in lines: + if line.startswith("CODEX_TELEGRAM_REASONING_EFFORT="): + thinking_mode = line.split("=", 1)[1].strip() + break +updates["CODEX_TELEGRAM_THINKING_MODE"] = thinking_mode or "xhigh" +max_images = "" +for line in lines: + if line.startswith("CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE="): + max_images = line.split("=", 1)[1].strip() + break +updates["CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE"] = max_images or "10" +defaults = { + "CODEX_RELAY_GEMINI_ENABLED": "true", + "CODEX_RELAY_GEMINI_MODEL": "gemini-3.1-flash-lite-preview", + "CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS": "4096", + "CODEX_RELAY_GEMINI_NATURAL_COMMANDS": "true", + "CODEX_RELAY_GEMINI_POLISH": "true", + "CODEX_RELAY_GEMINI_TIMEOUT_SECONDS": "20", + "CODEX_RELAY_GEMINI_ERROR_NOTICES": "true", + "CODEX_RELAY_RECOVERY_TIMEOUT_SECONDS": "1200", + "CODEX_RELAY_TERMINAL_BUFFER_CHARS": "20000", + "CODEX_RELAY_TERMINAL_READ_LIMIT": "4000", + "CODEX_RELAY_ALLOW_SENSITIVE_FILE_TRANSFER": "false", + "CODEX_TELEGRAM_POLL_TIMEOUT_SECONDS": "25", + "CODEX_TELEGRAM_POLL_HTTP_TIMEOUT_SECONDS": "60", + "CODEX_TELEGRAM_MAX_FILE_BYTES": "20971520", +} +for key, fallback in defaults.items(): + value = "" + for line in lines: + if line.startswith(key + "="): + value = line.split("=", 1)[1].strip() + break + updates[key] = value or fallback has_workdir = any( line.startswith("CODEX_TELEGRAM_WORKDIR=") and line.split("=", 1)[1].strip() diff --git a/scripts/recover.sh b/scripts/recover.sh new file mode 100755 index 0000000..3bb3e93 --- /dev/null +++ b/scripts/recover.sh @@ -0,0 +1,73 @@ +#!/bin/zsh +set -u + +ROOT="${CODEX_RELAY_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd -P)}" +MODE="${1:-codex}" + +cd "$ROOT" || exit 2 + +say() { + printf "%s\n" "$1" +} + +run_step() { + say "==> $*" + "$@" +} + +say "Codex Relay recovery" +say "repo: $ROOT" +say "mode: $MODE" + +run_step python3 -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" || exit $? +say "==> python3 $ROOT/scripts/smoke_test.py" +PYTHONPATH="$ROOT" python3 "$ROOT/scripts/smoke_test.py" || exit $? + +if [[ "$MODE" == "restart" || "$MODE" == "reinstall" ]]; then + run_step "$ROOT/scripts/install_launch_agent.sh" || exit $? + run_step "$ROOT/scripts/status.sh" || true + exit 0 +fi + +CODEX="${CODEX_BIN:-codex}" +if ! command -v "$CODEX" >/dev/null 2>&1 && [[ ! -x "$CODEX" ]]; then + say "Codex CLI not found: $CODEX" + say "Use /recover restart after fixing CODEX_BIN." + exit 127 +fi + +MODEL="${CODEX_TELEGRAM_MODEL:-gpt-5.5}" +THINKING="${CODEX_TELEGRAM_THINKING_MODE:-${CODEX_TELEGRAM_REASONING_EFFORT:-high}}" +SANDBOX="${CODEX_TELEGRAM_SANDBOX:-danger-full-access}" + +PROMPT="You are repairing the local codex-relay checkout. + +Goal: make the Telegram relay healthy without exposing secrets. + +Rules: +- Do not read, print, modify, or summarize .env values, bot tokens, private keys, runtime state, screenshots, or private logs. +- Do not commit or push. +- Inspect the code and scripts needed for relay startup, Telegram handling, queueing, terminal sessions, file transfer, recovery, and tests. +- Make the smallest safe fixes if anything is broken. +- Run: python3 -m py_compile codex_relay.py scripts/configure.py +- Run: PYTHONPATH=. python3 scripts/smoke_test.py +- If installer/startup changed, run ./scripts/doctor.sh and ./scripts/status.sh when safe. +- Finish with a terse summary of changed files and verification. +" + +say "==> codex self-repair" +printf "%s" "$PROMPT" | "$CODEX" exec \ + -c "sandbox_mode=\"$SANDBOX\"" \ + -c 'approval_policy="never"' \ + -c "model_reasoning_effort=\"$THINKING\"" \ + --model "$MODEL" \ + --skip-git-repo-check \ + - +CODEX_EXIT=$? + +say "==> post-check" +python3 -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" || exit $? +PYTHONPATH="$ROOT" python3 "$ROOT/scripts/smoke_test.py" || exit $? +"$ROOT/scripts/status.sh" || true + +exit "$CODEX_EXIT" diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 4ee8a3f..d91cfc8 100755 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -60,6 +60,25 @@ def send_photo( ) ) + def send_document( + self, + chat_id: int, + path: Path, + caption: str = "", + reply_to_message_id: Optional[int] = None, + ) -> None: + self.calls.append( + ( + "sendDocument", + { + "chat_id": chat_id, + "path": str(path), + "caption": caption, + "reply_to_message_id": reply_to_message_id, + }, + ) + ) + class FakeResponse: def __init__(self, chunks: list[bytes], headers: Optional[dict[str, str]] = None) -> None: @@ -79,9 +98,10 @@ def read(self, _size: int = -1) -> bytes: ENV_PREFIXES = ("CODEX_TELEGRAM_", "CODEX_RELAY_", "TELEGRAM_") -ENV_EXACT = {"CODEX_BIN"} +ENV_EXACT = {"CODEX_BIN", "GEMINI_API_KEY"} TEST_ENV = { "CODEX_TELEGRAM_MODEL": "gpt-5.5", + "CODEX_TELEGRAM_THINKING_MODE": "xhigh", "CODEX_TELEGRAM_REASONING_EFFORT": "xhigh", "CODEX_TELEGRAM_REPLY_STYLE": "brief", "CODEX_TELEGRAM_TIMEOUT_SECONDS": "600", @@ -134,6 +154,32 @@ def run_tests() -> int: assert_true(relay.image_attachment_specs(document_message), "expected image document support") assert_true(relay.image_suffix("screen.PNG") == ".png", "expected png suffix") assert_true(relay.image_suffix("photo.jpeg") == ".jpg", "expected jpeg normalization") + grouped = relay.merge_media_group_messages( + [ + { + "message_id": 3, + "media_group_id": "album-1", + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "photo": [{"file_id": "album-a", "width": 640, "height": 480, "file_size": 1000}], + }, + { + "message_id": 4, + "media_group_id": "album-1", + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "caption": "compare these", + "photo": [{"file_id": "album-b", "width": 640, "height": 480, "file_size": 1000}], + }, + ] + ) + grouped_specs = relay.image_attachment_specs(grouped) + assert_true(len(grouped_specs) == 2, "expected Telegram media group to preserve multiple images") + assert_true(grouped.get("caption") == "compare these", "expected media group caption") + assert_true(relay.media_group_key(grouped["_relay_media_group_messages"][0]) == (123, "album-1"), "expected media group key") + os.environ["CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE"] = "1" + assert_true(len(relay.image_attachment_specs(grouped)) == 1, "expected configurable image cap") + os.environ.pop("CODEX_TELEGRAM_MAX_IMAGES_PER_MESSAGE", None) prompt = relay.codex_prompt("what is in this image?", "main", [Path("/tmp/example.png")]) assert_true("attached to this Codex prompt" in prompt, "expected image prompt note") @@ -273,6 +319,10 @@ def fake_configure_urlopen(*_args: object, **kwargs: object) -> FakeResponse: assert_true(fake.calls[-1][1].get("reply_to_message_id") == 999, "expected opt-in reply threading") os.environ.pop("CODEX_TELEGRAM_REPLY_TO_MESSAGES", None) + timeout_api = relay.TelegramAPI("token") + timeout_api.call = lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("Telegram request timed out")) + assert_true(timeout_api.get_updates(123) == [], "expected polling timeout to be non-fatal") + thread = { "name": "main", "workdir": "/tmp", @@ -285,9 +335,10 @@ def fake_configure_urlopen(*_args: object, **kwargs: object) -> FakeResponse: assert_true("reply threading: disabled" in status, "expected reply threading status") assert_true("reply style: brief" in status, "expected reply style status") assert_true("group chats: disabled" in status, "expected group chat status") - assert_true("reasoning effort: xhigh" in status, "expected default xhigh reasoning status") + assert_true("thinking mode: xhigh" in status, "expected default xhigh thinking status") assert_true("gemini assist: disabled" in status, "expected Gemini status") assert_true("running jobs: 0" in status, "expected running job count") + assert_true("pending requests: 0" in status, "expected pending request count") assert_true("last run: ok; 1.2s; 1 image" in status, "expected last-run latency status") health = relay.health_text() assert_true("health:" in health, "expected health output") @@ -347,6 +398,11 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: job = relay.RelayJob(123, "main", 2) relay.register_job(job) try: + job.add_progress("?? LaunchAgent") + job.add_progress("Running scripts/smoke_test.py") + progress = relay.job_progress_text(job) + assert_true("?? LaunchAgent" not in progress, "expected noisy Codex fragment hidden from progress") + assert_true("Running scripts/smoke_test.py" in progress, "expected useful progress line") busy = relay.busy_thread_message(123, "main") assert_true("Thread `main` is busy." in busy, "expected busy thread message") jobs = relay.jobs_text(123, thread) @@ -425,6 +481,41 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: data["threads_by_chat"]["123"]["main"]["reply_style"] == "brief", "expected /brief to persist style", ) + relay.handle_message( + fake_style, + { + "message_id": 4, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/think high", + }, + {1}, + {123}, + threads_path, + ) + data = relay.read_threads(threads_path) + assert_true( + data["threads_by_chat"]["123"]["main"]["thinking_mode"] == "high", + "expected /think to persist thread thinking mode", + ) + assert_true("Thinking mode: high" in str(fake_style.calls[-1][1].get("text")), "expected /think reply") + relay.handle_message( + fake_style, + { + "message_id": 4, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/think default", + }, + {1}, + {123}, + threads_path, + ) + data = relay.read_threads(threads_path) + assert_true( + "thinking_mode" not in data["threads_by_chat"]["123"]["main"], + "expected /think default to clear thread override", + ) natural_project = Path(tmp) / "natural-project" natural_project.mkdir() original_gemini_plan_for_message = relay.gemini_plan_for_message @@ -436,6 +527,7 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: def fake_gemini_plan_for_message(*_args: object) -> dict[str, object]: return { "actions": [ + {"type": "set_thinking_mode", "value": "high"}, {"type": "set_workdir", "value": str(natural_project)}, {"type": "run_codex", "prompt": "Run a security audit."}, ] @@ -471,9 +563,226 @@ def fake_natural_start_background_job(*args: object, **kwargs: object) -> None: data["threads_by_chat"]["123"]["main"]["workdir"] == str(natural_project.resolve()), "expected Gemini natural command to update workdir", ) + assert_true( + data["threads_by_chat"]["123"]["main"]["thinking_mode"] == "high", + "expected Gemini natural command to update thinking mode", + ) assert_true(natural_jobs, "expected Gemini natural command to start Codex job") assert_true(natural_jobs[-1][0][5] == "Run a security audit.", "expected planned Codex prompt") + queue_job = relay.RelayJob(123, "main", 0) + relay.register_job(queue_job) + original_gemini_plan_for_message = relay.gemini_plan_for_message + old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = "fake-gemini-key" + + def fake_queue_plan(*_args: object) -> dict[str, object]: + return {"actions": [{"type": "run_codex", "prompt": "Queued audit."}]} + + relay.gemini_plan_for_message = fake_queue_plan + try: + relay.handle_message( + fake_style, + { + "message_id": 5, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "run this after the current job", + }, + {1}, + {123}, + threads_path, + ) + finally: + relay.gemini_plan_for_message = original_gemini_plan_for_message + relay.finish_job(queue_job) + if old_gemini_key is None: + os.environ.pop("CODEX_RELAY_GEMINI_API_KEY", None) + else: + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = old_gemini_key + data = relay.read_threads(threads_path) + pending = data["threads_by_chat"]["123"]["main"]["pending_requests"] + assert_true(pending and pending[0]["prompt"] == "Queued audit.", "expected busy Gemini run to queue") + + original_gemini_plan_for_message = relay.gemini_plan_for_message + old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = "fake-gemini-key" + + def fake_replace_plan(*_args: object) -> dict[str, object]: + return { + "actions": [ + { + "type": "replace_pending_request", + "value": "latest", + "prompt": "Run tests instead.", + } + ] + } + + relay.gemini_plan_for_message = fake_replace_plan + try: + relay.handle_message( + fake_style, + { + "message_id": 5, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "change that pending request to run tests", + }, + {1}, + {123}, + threads_path, + ) + finally: + relay.gemini_plan_for_message = original_gemini_plan_for_message + if old_gemini_key is None: + os.environ.pop("CODEX_RELAY_GEMINI_API_KEY", None) + else: + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = old_gemini_key + data = relay.read_threads(threads_path) + pending = data["threads_by_chat"]["123"]["main"]["pending_requests"] + assert_true(pending and pending[0]["prompt"] == "Run tests instead.", "expected Gemini to replace pending request") + + original_gemini_plan_for_message = relay.gemini_plan_for_message + old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = "fake-gemini-key" + + def fake_remove_plan(*_args: object) -> dict[str, object]: + return {"actions": [{"type": "remove_pending_request", "value": "latest"}]} + + relay.gemini_plan_for_message = fake_remove_plan + try: + relay.handle_message( + fake_style, + { + "message_id": 5, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "never mind", + }, + {1}, + {123}, + threads_path, + ) + finally: + relay.gemini_plan_for_message = original_gemini_plan_for_message + if old_gemini_key is None: + os.environ.pop("CODEX_RELAY_GEMINI_API_KEY", None) + else: + os.environ["CODEX_RELAY_GEMINI_API_KEY"] = old_gemini_key + data = relay.read_threads(threads_path) + assert_true( + not data["threads_by_chat"]["123"]["main"].get("pending_requests"), + "expected Gemini never mind to clear pending request", + ) + + with relay.THREADS_LOCK: + data = relay.read_threads(threads_path) + thread = relay.ensure_thread(data, 123, "main") + relay.queue_pending_request(thread, "Queued follow up.") + relay.write_threads(threads_path, data) + queued_starts = [] + original_start_background_job = relay.start_background_job + relay.start_background_job = lambda *args, **kwargs: queued_starts.append((args, kwargs)) + try: + relay.start_next_pending_job(fake_style, 123, threads_path, "main") + finally: + relay.start_background_job = original_start_background_job + assert_true(queued_starts, "expected queued request to start after thread clears") + assert_true(queued_starts[-1][0][5] == "Queued follow up.", "expected queued prompt to start") + data = relay.read_threads(threads_path) + assert_true( + not data["threads_by_chat"]["123"]["main"].get("pending_requests"), + "expected started queued request to be removed from queue", + ) + + attachment = relay.attachments_dir() / "queued-image.jpg" + relay.write_private_bytes(attachment, b"fake-image") + with relay.THREADS_LOCK: + data = relay.read_threads(threads_path) + thread = relay.ensure_thread(data, 123, "main") + queued_image = relay.queue_pending_request(thread, "Queued image task.", [attachment]) + relay.write_threads(threads_path, data) + assert_true(queued_image["image_count"] == 1, "expected queued request to retain image count") + data = relay.read_threads(threads_path) + thread = data["threads_by_chat"]["123"]["main"] + assert_true("1 image" in relay.pending_queue_text(thread), "expected queue text to show image count") + changed, deleted = relay.remove_pending_images(thread, queued_image["id"]) + assert_true(changed and deleted == 1, "expected pending image removal") + assert_true(not attachment.exists(), "expected removed pending image file to be deleted") + + terminal_message_id = 30 + relay.handle_message( + fake_style, + { + "message_id": terminal_message_id, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/terminal open smoke -- printf ready; sleep 5", + }, + {1}, + {123}, + threads_path, + ) + assert_true("Terminal `smoke` started" in str(fake_style.calls[-1][1].get("text")), "expected terminal open reply") + relay.handle_message( + fake_style, + { + "message_id": terminal_message_id + 1, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/terminal read smoke", + }, + {1}, + {123}, + threads_path, + ) + assert_true("ready" in str(fake_style.calls[-1][1].get("text")), "expected terminal read output") + relay.handle_message( + fake_style, + { + "message_id": terminal_message_id + 2, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/terminal kill smoke", + }, + {1}, + {123}, + threads_path, + ) + assert_true("Killed terminal: smoke" in str(fake_style.calls[-1][1].get("text")), "expected terminal kill") + + fetch_file = natural_project / "fetch.txt" + fetch_file.write_text("send me") + relay.handle_message( + fake_style, + { + "message_id": 34, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/file fetch.txt", + }, + {1}, + {123}, + threads_path, + ) + assert_true(any(call[0] == "sendDocument" for call in fake_style.calls[-3:]), "expected /file to send document") + + dummy_recovery = Path(tmp) / "recover.sh" + dummy_recovery.write_text("#!/bin/sh\necho recover-ok\n") + dummy_recovery.chmod(0o700) + old_recovery_script = os.environ.get("CODEX_RELAY_RECOVERY_SCRIPT") + os.environ["CODEX_RELAY_RECOVERY_SCRIPT"] = str(dummy_recovery) + try: + recovery_job = relay.RelayJob(123, "recovery", 0) + output, exit_code = relay.run_recovery_command(recovery_job, "") + finally: + if old_recovery_script is None: + os.environ.pop("CODEX_RELAY_RECOVERY_SCRIPT", None) + else: + os.environ["CODEX_RELAY_RECOVERY_SCRIPT"] = old_recovery_script + assert_true(exit_code == 0 and "recover-ok" in output, "expected recovery script runner") + relay.handle_message( fake_style, { @@ -488,6 +797,58 @@ def fake_natural_start_background_job(*args: object, **kwargs: object) -> None: ) assert_true("health:" in str(fake_style.calls[-1][1].get("text")), "expected /health command") + relay.handle_message( + fake_style, + { + "message_id": 5, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/gemini", + }, + {1}, + {123}, + threads_path, + ) + assert_true("/gemini key" in str(fake_style.calls[-1][1].get("text")), "expected Telegram Gemini setup hint") + + old_env_paths = relay.relay_env_update_paths + old_gemini_values = { + key: os.environ.get(key) + for key in ( + "CODEX_RELAY_GEMINI_API_KEY", + "CODEX_RELAY_GEMINI_ENABLED", + "CODEX_RELAY_GEMINI_NATURAL_COMMANDS", + "CODEX_RELAY_GEMINI_POLISH", + ) + } + gemini_env = Path(tmp) / "gemini.env" + gemini_key = "AIzaSyDUMMYKEYDUMMYKEYDUMMYKEY123456789" + relay.relay_env_update_paths = lambda: [gemini_env] + try: + relay.handle_message( + fake_style, + { + "message_id": 55, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": f"/gemini key {gemini_key}", + }, + {1}, + {123}, + threads_path, + ) + assert_true("CODEX_RELAY_GEMINI_API_KEY=" in gemini_env.read_text(), "expected Gemini key persistence") + assert_true(os.environ.get("CODEX_RELAY_GEMINI_API_KEY") == gemini_key, "expected live Gemini env reload") + assert_true(any(call[0] == "deleteMessage" for call in fake_style.calls[-3:]), "expected key message deletion attempt") + assert_true(gemini_key not in str(fake_style.calls[-1][1].get("text")), "expected Gemini key hidden from reply") + finally: + relay.relay_env_update_paths = old_env_paths + for key, value in old_gemini_values.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + relay.handle_message( fake_style, { @@ -586,6 +947,34 @@ def fake_start_background_job(*args: object, **kwargs: object) -> None: assert_true("last_latency_seconds" in stats, "expected latency stats") assert_true(stats["last_reasoning_effort"] == "xhigh", "expected reasoning stats") + high_codex = Path(tmp) / "high-codex" + high_codex.write_text( + "#!/bin/sh\n" + "out=''\n" + "found_reasoning=0\n" + "while [ \"$#\" -gt 0 ]; do\n" + " if [ \"$1\" = '--output-last-message' ]; then shift; out=\"$1\"; fi\n" + " if [ \"$1\" = 'model_reasoning_effort=\"high\"' ]; then found_reasoning=1; fi\n" + " shift || true\n" + "done\n" + "if [ \"$found_reasoning\" != 1 ]; then echo 'missing high thinking mode' >&2; exit 7; fi\n" + "printf 'high answer\\n' > \"$out\"\n" + ) + high_codex.chmod(0o700) + os.environ["CODEX_BIN"] = str(high_codex) + try: + answer, _session_id, stats = relay.run_codex( + "hello", + {"workdir": tmp, "name": "main", "thinking_mode": "high"}, + ) + finally: + if old_codex_bin is None: + os.environ.pop("CODEX_BIN", None) + else: + os.environ["CODEX_BIN"] = old_codex_bin + assert_true(answer == "high answer", "expected thread thinking override to reach Codex") + assert_true(stats["last_reasoning_effort"] == "high", "expected thinking override stats") + failing_codex = Path(tmp) / "failing-codex" failing_codex.write_text( "#!/bin/sh\n" @@ -607,6 +996,7 @@ def fake_start_background_job(*args: object, **kwargs: object) -> None: old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") original_gemini_generate = relay.gemini_generate + original_telegram_urlopen = relay.telegram_urlopen os.environ["CODEX_RELAY_GEMINI_API_KEY"] = "fake-gemini-key" relay.gemini_generate = lambda *_args, **_kwargs: "Polished answer" try: @@ -621,6 +1011,30 @@ def fake_start_background_job(*args: object, **kwargs: object) -> None: assert_true(not relay.gemini_allows_text("set OPENAI_API_KEY=sk-12345678901234567890"), "expected Gemini secret guard") finally: relay.gemini_generate = original_gemini_generate + try: + captured: dict[str, object] = {} + + def fake_gemini_urlopen(request: object, timeout: int) -> FakeResponse: + captured["timeout"] = timeout + captured["body"] = json.loads(getattr(request, "data").decode()) + return FakeResponse( + [ + b'{"candidates":[{"content":{"parts":[{"text":"Gemini ok"}]}}]}' + ] + ) + + os.environ["CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS"] = "4096" + relay.telegram_urlopen = fake_gemini_urlopen + assert_true(relay.gemini_generate("hello") == "Gemini ok", "expected fake Gemini output") + body = captured["body"] + assert_true( + isinstance(body, dict) + and body["generationConfig"]["maxOutputTokens"] == 4096, + "expected Gemini max output tokens in generation config", + ) + finally: + relay.telegram_urlopen = original_telegram_urlopen + os.environ.pop("CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS", None) if old_gemini_key is None: os.environ.pop("CODEX_RELAY_GEMINI_API_KEY", None) else: diff --git a/scripts/status_ui.sh b/scripts/status_ui.sh index d1de714..7089537 100755 --- a/scripts/status_ui.sh +++ b/scripts/status_ui.sh @@ -26,7 +26,7 @@ for label, needle in [ ("LaunchAgent", "state = running"), ("Token", "TELEGRAM_BOT_TOKEN=set"), ("Model", "model="), - ("Reasoning", "reasoning_effort="), + ("Thinking", "thinking_mode="), ("Style", "reply_style="), ("Images", "telegram_images=enabled"), ]: From 2cfe64dd6ee33c927a6ce2afe61da627f3514ccc Mon Sep 17 00:00:00 2001 From: "Rodi (Digital Shadow)" Date: Fri, 1 May 2026 11:37:53 -0700 Subject: [PATCH 4/4] Harden relay operations and recovery --- codex_relay.py | 589 +++++++++++++++++++++++++------- native/CodexRelayMenu.swift | 3 +- scripts/configure.py | 5 +- scripts/doctor.sh | 46 ++- scripts/install_launch_agent.sh | 81 ++++- scripts/recover.sh | 44 ++- scripts/smoke_test.py | 292 +++++++++++++++- scripts/status.sh | 119 ++++++- 8 files changed, 1024 insertions(+), 155 deletions(-) diff --git a/codex_relay.py b/codex_relay.py index e0c0d44..4856c00 100755 --- a/codex_relay.py +++ b/codex_relay.py @@ -149,6 +149,10 @@ class TelegramTLSCertificateError(RuntimeError): """Raised when Python cannot verify Telegram's HTTPS certificate.""" +class RelayConfigError(ValueError): + """Raised for invalid relay configuration.""" + + def load_dotenv(path: Path = ENV_PATH) -> None: if not path.exists(): return @@ -168,9 +172,26 @@ def load_dotenv(path: Path = ENV_PATH) -> None: os.environ[key] = value +_CHMOD_WARNINGS: set[tuple[str, int]] = set() + + +def chmod_private(path: Path, mode: int) -> None: + try: + os.chmod(path, mode) + except OSError as exc: + key = (str(path), mode) + if key in _CHMOD_WARNINGS: + return + _CHMOD_WARNINGS.add(key) + print( + f"Relay permission warning: could not set {oct(mode)} on {path}: {exc}", + file=sys.stderr, + ) + + def private_dir(path: Path) -> Path: - path.mkdir(parents=True, exist_ok=True) - os.chmod(path, 0o700) + path.mkdir(parents=True, exist_ok=True, mode=0o700) + chmod_private(path, 0o700) return path @@ -180,7 +201,7 @@ def write_private_text(path: Path, text: str) -> None: with os.fdopen(fd, "w") as handle: handle.write(text) os.replace(tmp, path) - os.chmod(path, 0o600) + chmod_private(path, 0o600) def write_private_bytes(path: Path, content: bytes) -> None: @@ -189,7 +210,7 @@ def write_private_bytes(path: Path, content: bytes) -> None: with os.fdopen(fd, "wb") as handle: handle.write(content) os.replace(tmp, path) - os.chmod(path, 0o600) + chmod_private(path, 0o600) def update_private_env_file(path: Path, updates: dict[str, str]) -> None: @@ -227,7 +248,7 @@ def env_int(name: str, default: int) -> int: try: return int(value) except ValueError: - raise SystemExit(f"{name} must be an integer") + raise RelayConfigError(f"{name} must be an integer") def env_bool(name: str, default: bool = False) -> bool: @@ -238,14 +259,14 @@ def env_bool(name: str, default: bool = False) -> bool: return True if value in {"0", "false", "no", "off"}: return False - raise SystemExit(f"{name} must be true or false") + raise RelayConfigError(f"{name} must be true or false") def env_choice(name: str, default: str, allowed: set[str]) -> str: value = os.environ.get(name, "").strip().lower() or default if value not in allowed: choices = ", ".join(sorted(allowed)) - raise SystemExit(f"{name} must be one of: {choices}") + raise RelayConfigError(f"{name} must be one of: {choices}") return value @@ -271,7 +292,7 @@ def thinking_mode_default() -> str: try: return normalize_thinking_mode(raw) except ValueError: - raise SystemExit("CODEX_TELEGRAM_THINKING_MODE must be one of: low, medium, high, xhigh") + raise RelayConfigError("CODEX_TELEGRAM_THINKING_MODE must be one of: low, medium, high, xhigh") def thread_thinking_mode(thread: dict[str, Any]) -> str: @@ -357,7 +378,7 @@ def parse_id_set(*names: str) -> set[int]: try: values.add(int(chunk)) except ValueError: - raise SystemExit(f"{name} contains a non-numeric id: {chunk!r}") + raise RelayConfigError(f"{name} contains a non-numeric id: {chunk!r}") return values @@ -451,6 +472,16 @@ def telegram_urlopen(request: urllib.request.Request, timeout: int): raise TelegramTLSCertificateError(certificate_error_message(first_error, tried)) from first_error +def decode_json_response(content: bytes, service: str) -> dict[str, Any]: + try: + payload = json.loads(content.decode()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError(f"{service} returned invalid JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"{service} returned unexpected JSON: {type(payload).__name__}") + return payload + + class TelegramAPI: def __init__(self, token: str) -> None: self.token = token @@ -466,7 +497,7 @@ def call( request = urllib.request.Request(self.base + method, data=data, method="POST") try: with telegram_urlopen(request, timeout=timeout) as response: - payload = json.loads(response.read().decode()) + payload = decode_json_response(response.read(), "Telegram") except urllib.error.HTTPError as exc: body = exc.read().decode(errors="replace")[:600] raise RuntimeError(f"Telegram HTTP {exc.code}: {body}") from exc @@ -558,7 +589,7 @@ def send_photo( ) try: with telegram_urlopen(request, timeout=70) as response: - payload = json.loads(response.read().decode()) + payload = decode_json_response(response.read(), "Telegram") except urllib.error.HTTPError as exc: body_text = exc.read().decode(errors="replace")[:600] raise RuntimeError(f"Telegram HTTP {exc.code}: {body_text}") from exc @@ -609,7 +640,7 @@ def send_document( ) try: with telegram_urlopen(request, timeout=70) as response: - payload = json.loads(response.read().decode()) + payload = decode_json_response(response.read(), "Telegram") except urllib.error.HTTPError as exc: body_text = exc.read().decode(errors="replace")[:600] raise RuntimeError(f"Telegram HTTP {exc.code}: {body_text}") from exc @@ -630,7 +661,8 @@ def get_updates(self, offset: Optional[int]) -> list[dict[str, Any]]: if offset is not None: params["offset"] = offset try: - return self.call("getUpdates", params, timeout=http_timeout).get("result", []) + result = self.call("getUpdates", params, timeout=http_timeout).get("result", []) + return result if isinstance(result, list) else [] except RuntimeError as exc: if telegram_timeout_error(exc): return [] @@ -980,6 +1012,12 @@ def start(self) -> None: start_new_session=True, env=env, ) + except Exception: + try: + os.close(master_fd) + except OSError: + pass + raise finally: os.close(slave_fd) self.master_fd = master_fd @@ -998,33 +1036,44 @@ def _append_output(self, chunk: str) -> None: self.output = self.output[-max_chars:] def _read_loop(self) -> None: - while True: - process = self.process - if process is None: - return - try: - ready, _write, _error = select.select([self.master_fd], [], [], 0.2) - if ready: - try: - data = os.read(self.master_fd, 4096) - except BlockingIOError: - data = b"" - except OSError: - data = b"" - if data: - self._append_output(data.decode(errors="replace")) - if process.poll() is not None: - try: - while True: + try: + while True: + process = self.process + if process is None: + return + try: + ready, _write, _error = select.select([self.master_fd], [], [], 0.2) + if ready: + try: data = os.read(self.master_fd, 4096) - if not data: - break + except BlockingIOError: + data = b"" + except OSError: + data = b"" + if data: self._append_output(data.decode(errors="replace")) - except OSError: - pass + if process.poll() is not None: + try: + while True: + data = os.read(self.master_fd, 4096) + if not data: + break + self._append_output(data.decode(errors="replace")) + except OSError: + pass + return + except Exception: return - except Exception: - return + finally: + self.close_master_fd() + + def close_master_fd(self) -> None: + if self.master_fd >= 0: + try: + os.close(self.master_fd) + except OSError: + pass + self.master_fd = -1 def alive(self) -> bool: return self.process is not None and self.process.poll() is None @@ -1050,17 +1099,8 @@ def send(self, text: str, newline: bool = False) -> None: def kill(self) -> None: process = self.process if process is not None and process.poll() is None: - signal_process(process, signal.SIGTERM) - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - signal_process(process, signal.SIGKILL) - if self.master_fd >= 0: - try: - os.close(self.master_fd) - except OSError: - pass - self.master_fd = -1 + terminate_process_tree(process, grace_seconds=2) + self.close_master_fd() def terminal_get(chat_id: int, name: str) -> Optional[TerminalSession]: @@ -1697,7 +1737,7 @@ def capture_screenshot() -> Path: raise RuntimeError("screencapture timed out") from exc if not target.exists() or target.stat().st_size == 0: raise RuntimeError("screencapture produced no image") - os.chmod(target, 0o600) + chmod_private(target, 0o600) return target @@ -1721,17 +1761,21 @@ def screenshot_failure_text(error: str) -> str: def read_offset(path: Path) -> Optional[int]: try: return int(path.read_text().strip()) - except (FileNotFoundError, ValueError): + except (FileNotFoundError, OSError, ValueError): return None def read_threads(path: Path) -> dict[str, Any]: try: data = json.loads(path.read_text()) - except (FileNotFoundError, json.JSONDecodeError): + except (FileNotFoundError, OSError, json.JSONDecodeError): + return {"active_by_chat": {}, "threads_by_chat": {}} + if not isinstance(data, dict): return {"active_by_chat": {}, "threads_by_chat": {}} - data.setdefault("active_by_chat", {}) - data.setdefault("threads_by_chat", {}) + if not isinstance(data.get("active_by_chat"), dict): + data["active_by_chat"] = {} + if not isinstance(data.get("threads_by_chat"), dict): + data["threads_by_chat"] = {} return data @@ -1759,14 +1803,21 @@ def append_history_event(event: dict[str, Any]) -> None: fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) with os.fdopen(fd, "a") as handle: handle.write(line) - os.chmod(path, 0o600) + chmod_private(path, 0o600) + + +def safe_append_history_event(event: dict[str, Any]) -> None: + try: + append_history_event(event) + except Exception as exc: + print(f"Relay history error: {safe_error_detail(exc)}", file=sys.stderr) def read_history(limit: int = 8) -> list[dict[str, Any]]: path = history_path() try: lines = path.read_text().splitlines() - except FileNotFoundError: + except (FileNotFoundError, OSError): return [] events: list[dict[str, Any]] = [] for line in lines[-max(1, limit) :]: @@ -1781,11 +1832,22 @@ def read_history(limit: int = 8) -> list[dict[str, Any]]: def chat_threads(data: dict[str, Any], chat_id: int) -> dict[str, dict[str, Any]]: chats = data.setdefault("threads_by_chat", {}) - return chats.setdefault(str(chat_id), {}) + key = str(chat_id) + raw_threads = chats.get(key) + if not isinstance(raw_threads, dict): + raw_threads = {} + chats[key] = raw_threads + for name, thread in list(raw_threads.items()): + if not isinstance(name, str) or not isinstance(thread, dict): + raw_threads.pop(name, None) + return raw_threads def active_thread_name(data: dict[str, Any], chat_id: int) -> str: - return data.setdefault("active_by_chat", {}).get(str(chat_id), DEFAULT_THREAD) + raw_name = data.setdefault("active_by_chat", {}).get(str(chat_id), DEFAULT_THREAD) + if not isinstance(raw_name, str) or not THREAD_RE.fullmatch(raw_name): + return DEFAULT_THREAD + return raw_name def set_active_thread(data: dict[str, Any], chat_id: int, name: str) -> None: @@ -1794,11 +1856,13 @@ def set_active_thread(data: dict[str, Any], chat_id: int, name: str) -> None: def active_state(threads_path: Path, chat_id: int) -> tuple[dict[str, Any], str, dict[str, Any]]: data = read_threads(threads_path) - active_missing = str(chat_id) not in data.setdefault("active_by_chat", {}) + active_by_chat = data.setdefault("active_by_chat", {}) + raw_active = active_by_chat.get(str(chat_id)) + active_missing = raw_active is None active_name = active_thread_name(data, chat_id) thread = ensure_thread(data, chat_id, active_name) set_active_thread(data, chat_id, active_name) - if active_missing: + if active_missing or raw_active != active_name: write_threads(threads_path, data) return data, active_name, thread @@ -1813,9 +1877,11 @@ def normalize_thread_name(raw: str) -> str: def ensure_thread(data: dict[str, Any], chat_id: int, name: str) -> dict[str, Any]: + if not isinstance(name, str) or not THREAD_RE.fullmatch(name): + name = DEFAULT_THREAD threads = chat_threads(data, chat_id) thread = threads.get(name) - if thread is None: + if not isinstance(thread, dict): thread = { "name": name, "session_id": "", @@ -2002,10 +2068,22 @@ def gemini_max_output_tokens() -> int: def gemini_response_text(payload: dict[str, Any]) -> str: candidates = payload.get("candidates") or [] - if not candidates: + if not isinstance(candidates, list) or not candidates: raise RuntimeError("Gemini returned no candidates") - parts = ((candidates[0].get("content") or {}).get("parts") or []) - text_parts = [str(part.get("text") or "") for part in parts if part.get("text")] + first = candidates[0] + if not isinstance(first, dict): + raise RuntimeError("Gemini returned malformed candidate") + content = first.get("content") or {} + if not isinstance(content, dict): + raise RuntimeError("Gemini returned malformed content") + parts = content.get("parts") or [] + if not isinstance(parts, list): + raise RuntimeError("Gemini returned malformed parts") + text_parts = [ + str(part.get("text") or "") + for part in parts + if isinstance(part, dict) and part.get("text") + ] text = "".join(text_parts).strip() if not text: raise RuntimeError("Gemini returned no text") @@ -2053,7 +2131,7 @@ def gemini_generate(prompt: str, response_schema: Optional[dict[str, Any]] = Non ) try: with telegram_urlopen(request, timeout=gemini_timeout()) as response: - payload = json.loads(response.read().decode()) + payload = decode_json_response(response.read(), "Gemini") except urllib.error.HTTPError as exc: raise RuntimeError(f"Gemini HTTP {exc.code}") from exc except urllib.error.URLError as exc: @@ -2243,6 +2321,9 @@ def execute_gemini_plan( actions = plan.get("actions") or [] if not actions: return False + def reply(text: str, reply_to_message_id: Optional[int] = message_id) -> Optional[int]: + return safe_send_message(api, chat_id, text, reply_to_message_id, context="gemini action") + notes: list[str] = [] for action in actions: action_type = action.get("type") @@ -2474,15 +2555,15 @@ def execute_gemini_plan( item = queue_pending_request(thread, prompt) write_threads(threads_path, data) except ValueError as exc: - api.send_message(chat_id, f"{busy}\n{exc}", message_id) + reply(f"{busy}\n{exc}") return True preface = "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])) queued = f"Queued request {item['id']} until thread `{active_name}` is clear: {prompt_preview(item['prompt'])}" - api.send_message(chat_id, "\n\n".join(part for part in [preface, queued] if part), message_id) + reply("\n\n".join(part for part in [preface, queued] if part)) return True preface = "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])) if preface: - api.send_message(chat_id, preface, message_id) + reply(preface) start_background_job( api, chat_id, @@ -2494,7 +2575,7 @@ def execute_gemini_plan( ) return True if notes or plan.get("reply"): - api.send_message(chat_id, "\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else [])), message_id) + reply("\n\n".join(notes + ([plan.get("reply", "").strip()] if plan.get("reply") else []))) return True return False @@ -2606,7 +2687,71 @@ def extract_session_id(output: str) -> str: return match.group(1) if match else "" -def child_pids(pid: int) -> list[int]: +def darwin_direct_child_pids(pid: int) -> Optional[list[int]]: + if sys.platform != "darwin": + return None + try: + import ctypes + import struct + + proc_all_pids = 1 + proc_pidtbsdinfo = 3 + proc_bsdinfo_size = 256 + pbi_ppid_offset = 16 + + libproc = ctypes.CDLL("/usr/lib/libproc.dylib") + libproc.proc_listpids.argtypes = [ + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ctypes.c_int, + ] + libproc.proc_listpids.restype = ctypes.c_int + libproc.proc_pidinfo.argtypes = [ + ctypes.c_int, + ctypes.c_int, + ctypes.c_uint64, + ctypes.c_void_p, + ctypes.c_int, + ] + libproc.proc_pidinfo.restype = ctypes.c_int + + byte_count = libproc.proc_listpids(proc_all_pids, 0, None, 0) + if byte_count <= 0: + return None + pid_count = max(1024, byte_count // ctypes.sizeof(ctypes.c_int) + 1024) + pid_buffer = (ctypes.c_int * pid_count)() + actual_bytes = libproc.proc_listpids( + proc_all_pids, + 0, + pid_buffer, + ctypes.sizeof(pid_buffer), + ) + if actual_bytes <= 0: + return None + children: list[int] = [] + for candidate in pid_buffer[: actual_bytes // ctypes.sizeof(ctypes.c_int)]: + if candidate <= 0: + continue + info = ctypes.create_string_buffer(proc_bsdinfo_size) + actual = libproc.proc_pidinfo( + candidate, + proc_pidtbsdinfo, + 0, + ctypes.byref(info), + ctypes.sizeof(info), + ) + if actual < pbi_ppid_offset + ctypes.sizeof(ctypes.c_uint32): + continue + parent_pid = struct.unpack_from("I", info.raw, pbi_ppid_offset)[0] + if int(parent_pid) == pid: + children.append(int(candidate)) + return children + except Exception: + return None + + +def pgrep_direct_child_pids(pid: int) -> list[int]: try: result = subprocess.run( ["pgrep", "-P", str(pid)], @@ -2617,11 +2762,31 @@ def child_pids(pid: int) -> list[int]: ) except Exception: return [] - children = [int(line) for line in result.stdout.splitlines() if line.strip().isdigit()] + if result.returncode not in {0, 1}: + return [] + return [int(line) for line in result.stdout.splitlines() if line.strip().isdigit()] + + +def direct_child_pids(pid: int) -> list[int]: + children = darwin_direct_child_pids(pid) + if children is not None: + return children + return pgrep_direct_child_pids(pid) + + +def child_pids(pid: int) -> list[int]: + seen: set[int] = set() descendants: list[int] = [] - for child in children: - descendants.append(child) - descendants.extend(child_pids(child)) + + def collect(parent_pid: int) -> None: + for child in direct_child_pids(parent_pid): + if child in seen: + continue + seen.add(child) + descendants.append(child) + collect(child) + + collect(pid) return descendants @@ -2652,6 +2817,23 @@ def signal_process(process: subprocess.Popen[str], sig: signal.Signals) -> None: signal_pid_group(process.pid, sig) +def terminate_process_tree(process: subprocess.Popen[str], grace_seconds: float = 5.0) -> None: + signal_process(process, signal.SIGTERM) + deadline = time.monotonic() + max(0.1, grace_seconds) + while process.poll() is None and time.monotonic() < deadline: + time.sleep(0.05) + if process.poll() is None: + signal_process(process, signal.SIGKILL) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + signal_process(process, signal.SIGKILL) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + + def stop_process(process: subprocess.Popen[str]) -> tuple[str, str]: signal_process(process, signal.SIGTERM) try: @@ -2750,14 +2932,6 @@ def reader(stream: Any, chunks: list[str]) -> None: except Exception: pass - def stop_streaming_process(target: subprocess.Popen[str]) -> None: - signal_process(target, signal.SIGTERM) - deadline = time.monotonic() + 5 - while target.poll() is None and time.monotonic() < deadline: - time.sleep(0.05) - if target.poll() is None: - signal_process(target, signal.SIGKILL) - readers: list[threading.Thread] = [] try: process = subprocess.Popen( @@ -2790,14 +2964,18 @@ def stop_streaming_process(target: subprocess.Popen[str]) -> None: if returncode is not None: break if cancel_event and cancel_event.is_set(): - stop_streaming_process(process) + terminate_process_tree(process) + for item in readers: + item.join(timeout=1) return finish( "Canceled: job stopped before Codex replied.", session_id, "canceled", ) if time.monotonic() >= deadline: - stop_streaming_process(process) + terminate_process_tree(process) + for item in readers: + item.join(timeout=1) return finish( f"Blocked: Codex timed out after {timeout} seconds. " "The task was stopped before it could reply.", @@ -3125,6 +3303,28 @@ def concise_external_error(service: str, exc: BaseException) -> str: return f"{service} failed: {clean[:240]}" +def safe_error_detail(exc: BaseException, limit: int = 500) -> str: + clean = sanitize_progress_line(str(exc)) + if not clean: + return "internal relay error" + return clean[:limit] + + +def safe_send_message( + api: TelegramAPI, + chat_id: int, + text: str, + reply_to_message_id: Optional[int] = None, + context: str = "", +) -> Optional[int]: + try: + return api.send_message(chat_id, text, reply_to_message_id) + except Exception as exc: + label = f" ({context})" if context else "" + print(f"Relay delivery error{label}: {safe_error_detail(exc)}", file=sys.stderr) + return None + + def codex_failure_message(returncode: int, output: str) -> str: lowered = output.lower() if any(term in lowered for term in ("gateway", "bad gateway", "502", "503", "504", "service unavailable")): @@ -3386,6 +3586,23 @@ def recovery_script_path() -> Path: return ROOT / "scripts" / "recover.sh" +def recovery_command_args(arg: str) -> list[str]: + try: + parts = shlex.split(arg) + except ValueError as exc: + raise ValueError("Usage: /recover [restart|reinstall]") from exc + if not parts: + return [] + if len(parts) > 1: + raise ValueError("Usage: /recover [restart|reinstall]") + mode = parts[0].lower() + if mode in {"codex", "check", "self-check", "selfcheck"}: + return [] + if mode in {"restart", "reinstall"}: + return [mode] + raise ValueError("Usage: /recover [restart|reinstall]") + + def sanitize_shell_output(output: str, limit: int = 3600) -> str: safe_lines: list[str] = [] for line in output.splitlines(): @@ -3408,9 +3625,10 @@ def run_recovery_command(job: RelayJob, arg: str) -> tuple[str, int]: script = recovery_script_path() if not script.exists(): return f"Recovery script missing: {script}", 127 - command = ["/bin/zsh", str(script)] - if arg.strip(): - command.extend(arg.strip().split()) + try: + command = ["/bin/zsh", str(script), *recovery_command_args(arg)] + except ValueError as exc: + return str(exc), 64 env = os.environ.copy() env.setdefault("CODEX_RELAY_REPO_DIR", str(relay_repo_dir())) timeout = recovery_timeout_seconds() @@ -3450,10 +3668,12 @@ def reader() -> None: reader_thread.start() while process.poll() is None: if job.cancel_event.is_set(): - signal_process(process, signal.SIGTERM) + terminate_process_tree(process) + reader_thread.join(timeout=1) return "Canceled: recovery stopped before it finished.", 130 if time.monotonic() - started >= timeout: - signal_process(process, signal.SIGTERM) + terminate_process_tree(process) + reader_thread.join(timeout=1) return f"Recovery timed out after {timeout} seconds.", 124 time.sleep(0.1) reader_thread.join(timeout=1) @@ -3470,11 +3690,21 @@ def run_recovery_worker( with TypingPulse(api, chat_id), ProgressPulse(api, chat_id, job, True): output, exit_code = run_recovery_command(job, arg) if exit_code == 0: - api.send_message(chat_id, "Recovery finished.\n\n" + output) + safe_send_message(api, chat_id, "Recovery finished.\n\n" + output, context="recovery result") else: - api.send_message(chat_id, f"Recovery failed with exit {exit_code}.\n\n{output}") + safe_send_message( + api, + chat_id, + f"Recovery failed with exit {exit_code}.\n\n{output}", + context="recovery failure", + ) except Exception as exc: - api.send_message(chat_id, "Recovery failed: " + concise_external_error("local recovery", exc)) + safe_send_message( + api, + chat_id, + "Recovery failed: " + concise_external_error("local recovery", exc), + context="recovery exception", + ) finally: finish_job(job) cleanup_workers() @@ -3486,6 +3716,11 @@ def start_recovery_job( arg: str, reply_to_message_id: Optional[int] = None, ) -> None: + try: + recovery_command_args(arg) + except ValueError as exc: + api.send_message(chat_id, str(exc), reply_to_message_id) + return running = jobs_for_thread(chat_id, "recovery") if running: api.send_message(chat_id, "Recovery is already running.\n" + "\n".join(job_line(job) for job in running), reply_to_message_id) @@ -3578,14 +3813,14 @@ def run_job_worker( else: thread = dict(thread_snapshot) if record_history: - append_history_event( + safe_append_history_event( history_event_from_stats(chat_id, thread_name, thread, job, stats) ) if stats.get("last_status") == "ok": answer = gemini_polish_answer(prompt_text, answer, thread) - api.send_message(chat_id, answer) + safe_send_message(api, chat_id, answer, context="job result") except Exception as exc: - append_history_event( + safe_append_history_event( { "at": now_iso(), "chat_id": chat_id, @@ -3595,12 +3830,25 @@ def run_job_worker( "folder": Path(str(thread_snapshot.get("workdir") or default_workdir())).name, } ) - api.send_message(chat_id, f"Relay job failed: {exc}") + safe_send_message( + api, + chat_id, + f"Relay job failed: {safe_error_detail(exc)}", + context="job exception", + ) finally: finish_job(job) cleanup_workers() if persist_thread_state: - start_next_pending_job(api, chat_id, threads_path, thread_name) + try: + start_next_pending_job(api, chat_id, threads_path, thread_name) + except Exception as exc: + safe_send_message( + api, + chat_id, + f"Relay could not start the next queued request: {safe_error_detail(exc)}", + context="pending queue", + ) def start_background_job( @@ -3665,6 +3913,7 @@ def start_next_pending_job( ) -> None: if jobs_for_thread(chat_id, thread_name): return + item: Optional[dict[str, Any]] = None with THREADS_LOCK: data = read_threads(threads_path) thread = ensure_thread(data, chat_id, thread_name) @@ -3681,7 +3930,12 @@ def start_next_pending_job( if image_count: image_label = "image" if image_count == 1 else "images" image_note = f" with {image_count} {image_label}" - api.send_message(chat_id, f"Starting queued request {item['id']}{image_note}: {prompt_preview(item['prompt'])}") + safe_send_message( + api, + chat_id, + f"Starting queued request {item['id']}{image_note}: {prompt_preview(item['prompt'])}", + context="queued request start", + ) start_background_job( api, chat_id, @@ -3691,8 +3945,25 @@ def start_next_pending_job( item["prompt"], image_paths, ) - except Exception: - pass + except Exception as exc: + if item is not None: + try: + with THREADS_LOCK: + data = read_threads(threads_path) + thread = ensure_thread(data, chat_id, thread_name) + items = pending_requests(thread) + if not any(existing.get("id") == item.get("id") for existing in items): + items.insert(0, item) + save_pending_requests(thread, items) + write_threads(threads_path, data) + except Exception as requeue_exc: + print(f"Relay pending requeue error: {safe_error_detail(requeue_exc)}", file=sys.stderr) + safe_send_message( + api, + chat_id, + f"Relay could not start queued request {item.get('id') if item else ''}: {safe_error_detail(exc)}", + context="queued request failure", + ) def capabilities_text() -> str: @@ -4242,10 +4513,12 @@ def handle_message( return except Exception as exc: if env_bool("CODEX_RELAY_GEMINI_ERROR_NOTICES", True): - api.send_message( + safe_send_message( + api, chat_id, concise_external_error("Gemini assist", exc) + "\nContinuing with the normal Codex path.", message_id, + context="gemini notice", ) with THREADS_LOCK: @@ -4295,6 +4568,75 @@ def handle_message( ) +def send_error_if_authorized( + api: TelegramAPI, + message: dict[str, Any], + allowed_users: set[int], + allowed_chats: set[int], + text: str, + context: str, +) -> None: + chat = message.get("chat") or {} + sender = message.get("from") or {} + chat_id = int_or_none(chat.get("id")) + if chat_id is None: + return + chat_type = str(chat.get("type") or "private") + user_id = int_or_none(sender.get("id")) + if chat_type != "private": + return + if not authorized(user_id, chat_id, chat_type, allowed_users, allowed_chats): + return + safe_send_message( + api, + chat_id, + text, + int_or_none(message.get("message_id")), + context=context, + ) + + +def send_config_error_if_authorized( + api: TelegramAPI, + message: dict[str, Any], + allowed_users: set[int], + allowed_chats: set[int], + exc: RelayConfigError, +) -> None: + send_error_if_authorized( + api, + message, + allowed_users, + allowed_chats, + f"Relay configuration error: {safe_error_detail(exc)}", + "config error", + ) + + +def handle_message_safely( + api: TelegramAPI, + message: dict[str, Any], + allowed_users: set[int], + allowed_chats: set[int], + threads_path: Path, +) -> None: + try: + handle_message(api, message, allowed_users, allowed_chats, threads_path) + except RelayConfigError as exc: + send_config_error_if_authorized(api, message, allowed_users, allowed_chats, exc) + print(f"Relay configuration error: {safe_error_detail(exc)}", file=sys.stderr) + except Exception as exc: + send_error_if_authorized( + api, + message, + allowed_users, + allowed_chats, + f"Relay internal error: {safe_error_detail(exc)}", + "handler exception", + ) + print(f"Relay handler error: {safe_error_detail(exc)}", file=sys.stderr) + + def check_config() -> int: load_dotenv() token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() @@ -4343,18 +4685,25 @@ def main() -> int: parser.add_argument("--check-config", action="store_true", help="Validate config without polling Telegram.") args = parser.parse_args() - load_dotenv() - if args.check_config: - return check_config() - - token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() - if not token: - print("Missing TELEGRAM_BOT_TOKEN. Get one from @BotFather, put it in .env, then rerun.", file=sys.stderr) + try: + load_dotenv() + if args.check_config: + return check_config() + + token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if not token: + print("Missing TELEGRAM_BOT_TOKEN. Get one from @BotFather, put it in .env, then rerun.", file=sys.stderr) + return 2 + + allowed_users = parse_id_set("TELEGRAM_ALLOWED_USER_ID", "TELEGRAM_ALLOWED_USER_IDS") + allowed_chats = parse_id_set("TELEGRAM_ALLOWED_CHAT_ID", "TELEGRAM_ALLOWED_CHAT_IDS") + directory = state_dir() + except RelayConfigError as exc: + print(f"Relay configuration error: {safe_error_detail(exc)}", file=sys.stderr) + return 2 + except OSError as exc: + print(f"Relay startup error: {safe_error_detail(exc)}", file=sys.stderr) return 2 - - allowed_users = parse_id_set("TELEGRAM_ALLOWED_USER_ID", "TELEGRAM_ALLOWED_USER_IDS") - allowed_chats = parse_id_set("TELEGRAM_ALLOWED_CHAT_ID", "TELEGRAM_ALLOWED_CHAT_IDS") - directory = state_dir() offset_path = directory / "offset" threads_path = directory / "threads.json" api = TelegramAPI(token) @@ -4373,11 +4722,15 @@ def main() -> int: for update in api.get_updates(offset): if SHUTDOWN_EVENT.is_set(): break - update_id = int(update["update_id"]) + if not isinstance(update, dict): + continue + update_id = int_or_none(update.get("update_id")) + if update_id is None: + continue offset = update_id + 1 write_private_text(offset_path, str(offset)) message = update.get("message") - if message: + if isinstance(message, dict): group_key = media_group_key(message) if group_key: _seen_at, messages = pending_media_groups.get(group_key, (0.0, [])) @@ -4386,7 +4739,7 @@ def main() -> int: else: standalone_messages.append(message) for message in standalone_messages: - handle_message(api, message, allowed_users, allowed_chats, threads_path) + handle_message_safely(api, message, allowed_users, allowed_chats, threads_path) now = time.monotonic() ready_keys = [ key @@ -4395,7 +4748,7 @@ def main() -> int: ] for key in ready_keys: _seen_at, messages = pending_media_groups.pop(key) - handle_message( + handle_message_safely( api, merge_media_group_messages(messages), allowed_users, @@ -4409,7 +4762,11 @@ def main() -> int: join_workers() return 0 except Exception as exc: - print(f"Relay error: {exc}", file=sys.stderr) + print( + f"Relay error ({type(exc).__name__}): {safe_error_detail(exc)}", + file=sys.stderr, + flush=True, + ) time.sleep(5) cancel_all_jobs() join_workers() diff --git a/native/CodexRelayMenu.swift b/native/CodexRelayMenu.swift index ce7a978..52f55f2 100644 --- a/native/CodexRelayMenu.swift +++ b/native/CodexRelayMenu.swift @@ -120,7 +120,8 @@ final class RelayMenuApp: NSObject, NSApplicationDelegate, NSMenuDelegate { private func isRelayRunning() -> Bool { let uid = String(getuid()) - let result = run(URL(fileURLWithPath: "/bin/launchctl"), arguments: ["print", "gui/\(uid)/com.codexrelay.agent"]) + let label = ProcessInfo.processInfo.environment["CODEX_RELAY_LABEL"] ?? "com.codexrelay.agent" + let result = run(URL(fileURLWithPath: "/bin/launchctl"), arguments: ["print", "gui/\(uid)/\(label)"]) return result.code == 0 && (result.output.contains("state = running") || result.output.range(of: #"pid = [1-9][0-9]*"#, options: .regularExpression) != nil) } diff --git a/scripts/configure.py b/scripts/configure.py index a9e20d6..39289b9 100755 --- a/scripts/configure.py +++ b/scripts/configure.py @@ -41,7 +41,10 @@ def private_write(path: Path, text: str) -> None: with os.fdopen(fd, "w") as handle: handle.write(text) os.replace(tmp, path) - os.chmod(path, 0o600) + try: + os.chmod(path, 0o600) + except OSError as exc: + print(f"warn: could not set 0600 on {path}: {exc}", file=sys.stderr) def load_env() -> dict[str, str]: diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 2ee1129..4a8464b 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -4,11 +4,13 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" LABEL="${CODEX_RELAY_LABEL:-com.codexrelay.agent}" RUNTIME="$HOME/Library/Application Support/CodexRelay" +STATE_DIR="$RUNTIME/state" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +FAILED=0 ok() { printf "ok: %s\n" "$1"; } warn() { printf "warn: %s\n" "$1"; } -fail() { printf "fail: %s\n" "$1"; exit 1; } +fail() { printf "fail: %s\n" "$1"; FAILED=1; } cd "$ROOT" @@ -36,7 +38,18 @@ fi [[ -f "$ROOT/.env" ]] && ok ".env exists" || fail ".env missing; run ./scripts/install.sh" if [[ -f "$ROOT/.env" ]]; then - "$ROOT/codex_relay.py" --check-config + "$ROOT/codex_relay.py" --check-config || fail "config check failed" +fi + +[[ -f "$PLIST" ]] && ok "plist exists" || fail "plist missing" +[[ -f "$RUNTIME/codex_relay.py" ]] && ok "runtime script exists" || fail "runtime script missing" +if [[ -f "$RUNTIME/codex_relay.py" ]]; then + cmp -s "$ROOT/codex_relay.py" "$RUNTIME/codex_relay.py" && ok "runtime script matches repo" || fail "runtime script differs; run ./scripts/install_launch_agent.sh" +fi + +disabled_output="$(launchctl print-disabled "gui/$(id -u)" 2>/dev/null || true)" +if [[ -n "$disabled_output" ]] && printf "%s\n" "$disabled_output" | grep -Fq "\"$LABEL\" => true"; then + fail "LaunchAgent is disabled; run launchctl enable gui/$(id -u)/$LABEL, then ./scripts/install_launch_agent.sh" fi launch_state="$(launchctl print "gui/$(id -u)/$LABEL" 2>/dev/null || true)" @@ -46,12 +59,6 @@ else fail "LaunchAgent is not running" fi -[[ -f "$PLIST" ]] && ok "plist exists" || fail "plist missing" -[[ -f "$RUNTIME/codex_relay.py" ]] && ok "runtime script exists" || fail "runtime script missing" -if [[ -f "$RUNTIME/codex_relay.py" ]]; then - cmp -s "$ROOT/codex_relay.py" "$RUNTIME/codex_relay.py" && ok "runtime script matches repo" || fail "runtime script differs; run ./scripts/install_launch_agent.sh" -fi - shot="$(mktemp -t codex-relay-screen.XXXXXX.jpg)" if screencapture -x -t jpg "$shot" >/dev/null 2>&1 && [[ -s "$shot" ]]; then ok "screenshot permission works" @@ -60,7 +67,24 @@ else fi rm -f "$shot" -python3 -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" -ok "python syntax" +if python3 -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py"; then + ok "python syntax" +else + fail "python syntax failed" +fi + +PYTHONPATH="$ROOT" python3 "$ROOT/scripts/smoke_test.py" || fail "smoke tests failed" + +if [[ "$FAILED" -ne 0 ]]; then + printf "\nnext diagnostics:\n" + printf "- ./scripts/status.sh --tail 120\n" + printf "- ./scripts/install_launch_agent.sh\n" + for log_file in "$STATE_DIR/launchd.err" "$STATE_DIR/launchd.out"; do + if [[ -s "$log_file" ]]; then + printf "\n== tail -20 %s ==\n" "$log_file" + tail -n 20 "$log_file" + fi + done +fi -PYTHONPATH="$ROOT" python3 "$ROOT/scripts/smoke_test.py" +exit "$FAILED" diff --git a/scripts/install_launch_agent.sh b/scripts/install_launch_agent.sh index 845cd79..18f41f9 100755 --- a/scripts/install_launch_agent.sh +++ b/scripts/install_launch_agent.sh @@ -9,23 +9,66 @@ RUNTIME="$HOME/Library/Application Support/CodexRelay" STATE_DIR="$RUNTIME/state" WORKDIR="$HOME" +fail_install() { + printf "fail: %s\n" "$1" >&2 + printf "diagnostics: ./scripts/status.sh --tail 120\n" >&2 + printf "retry from a logged-in macOS Terminal as this user; do not use sudo for this user LaunchAgent.\n" >&2 + exit "${2:-1}" +} + +warn_install() { + printf "warn: %s\n" "$1" >&2 +} + +launchctl_optional() { + local description="$1" + shift + local output + output="$(mktemp -t codex-relay-launchctl.XXXXXX)" + if "$@" >"$output" 2>&1; then + rm -f "$output" + return 0 + fi + warn_install "$description failed; continuing because the service may not exist yet" + sed -n '1,20p' "$output" >&2 + rm -f "$output" + return 0 +} + +launchctl_required() { + local description="$1" + shift + local output rc + output="$(mktemp -t codex-relay-launchctl.XXXXXX)" + if "$@" >"$output" 2>&1; then + cat "$output" + rm -f "$output" + return 0 + fi + rc=$? + printf "launchctl output:\n" >&2 + sed -n '1,80p' "$output" >&2 + rm -f "$output" + fail_install "$description failed with exit $rc" +} + if [ ! -f "$ROOT/.env" ]; then echo "Missing $ROOT/.env. Copy .env.example to .env and fill it first." >&2 exit 2 fi -"$ROOT/codex_relay.py" --check-config >/dev/null +"$PYTHON" "$ROOT/codex_relay.py" --check-config >/dev/null || fail_install "repo config check failed" 2 -mkdir -p "$HOME/Library/LaunchAgents" "$RUNTIME" "$STATE_DIR" -chmod 700 "$RUNTIME" "$STATE_DIR" +mkdir -p "$HOME/Library/LaunchAgents" "$RUNTIME" "$STATE_DIR" || fail_install "could not create LaunchAgent runtime directories" +chmod 700 "$RUNTIME" "$STATE_DIR" || fail_install "could not make runtime directories private" umask 077 -: > "$STATE_DIR/launchd.out" -: > "$STATE_DIR/launchd.err" -chmod 600 "$STATE_DIR/launchd.out" "$STATE_DIR/launchd.err" +: > "$STATE_DIR/launchd.out" || fail_install "could not create launch stdout log" +: > "$STATE_DIR/launchd.err" || fail_install "could not create launch stderr log" +chmod 600 "$STATE_DIR/launchd.out" "$STATE_DIR/launchd.err" || fail_install "could not make launch logs private" -install -m 700 "$ROOT/codex_relay.py" "$RUNTIME/codex_relay.py" +install -m 700 "$ROOT/codex_relay.py" "$RUNTIME/codex_relay.py" || fail_install "could not install runtime script" -python3 - < "$PLIST" < "$PLIST" < @@ -131,11 +177,16 @@ cat > "$PLIST" < PLIST +then + fail_install "could not write LaunchAgent plist" +fi -chmod 600 "$PLIST" +chmod 600 "$PLIST" || fail_install "could not make LaunchAgent plist private" +plutil -lint "$PLIST" >/dev/null || fail_install "LaunchAgent plist is invalid" +"$PYTHON" "$RUNTIME/codex_relay.py" --check-config >/dev/null || fail_install "runtime config check failed" 2 -launchctl bootout "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true -launchctl enable "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true -launchctl bootstrap "gui/$(id -u)" "$PLIST" -launchctl kickstart -k "gui/$(id -u)/$LABEL" -launchctl print "gui/$(id -u)/$LABEL" | sed -n '1,40p' +launchctl_optional "launchctl bootout" launchctl bootout "gui/$(id -u)" "$PLIST" +launchctl_required "launchctl enable" launchctl enable "gui/$(id -u)/$LABEL" +launchctl_required "launchctl bootstrap" launchctl bootstrap "gui/$(id -u)" "$PLIST" +launchctl_required "launchctl kickstart" launchctl kickstart -k "gui/$(id -u)/$LABEL" +launchctl_required "launchctl print" launchctl print "gui/$(id -u)/$LABEL" diff --git a/scripts/recover.sh b/scripts/recover.sh index 3bb3e93..c0c8e90 100755 --- a/scripts/recover.sh +++ b/scripts/recover.sh @@ -3,6 +3,8 @@ set -u ROOT="${CODEX_RELAY_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd -P)}" MODE="${1:-codex}" +PYTHON="${CODEX_RELAY_PYTHON:-/usr/bin/python3}" +SCRIPT_NAME="$0" cd "$ROOT" || exit 2 @@ -15,13 +17,45 @@ run_step() { "$@" } +usage() { + say "Usage: $SCRIPT_NAME [codex|restart|reinstall]" +} + +if [[ $# -gt 1 ]]; then + usage + exit 64 +fi + +case "$MODE" in + codex|check|self-check|selfcheck) + MODE="codex" + ;; + restart|reinstall) + ;; + --help|-h|help) + usage + exit 0 + ;; + *) + say "Unknown recovery mode: $MODE" + usage + exit 64 + ;; +esac + +if [[ ! -x "$PYTHON" ]]; then + say "Python not found: $PYTHON" + exit 127 +fi + say "Codex Relay recovery" say "repo: $ROOT" say "mode: $MODE" +say "python: $PYTHON" -run_step python3 -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" || exit $? -say "==> python3 $ROOT/scripts/smoke_test.py" -PYTHONPATH="$ROOT" python3 "$ROOT/scripts/smoke_test.py" || exit $? +run_step "$PYTHON" -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" || exit $? +say "==> $PYTHON $ROOT/scripts/smoke_test.py" +PYTHONPATH="$ROOT" "$PYTHON" "$ROOT/scripts/smoke_test.py" || exit $? if [[ "$MODE" == "restart" || "$MODE" == "reinstall" ]]; then run_step "$ROOT/scripts/install_launch_agent.sh" || exit $? @@ -66,8 +100,8 @@ printf "%s" "$PROMPT" | "$CODEX" exec \ CODEX_EXIT=$? say "==> post-check" -python3 -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" || exit $? -PYTHONPATH="$ROOT" python3 "$ROOT/scripts/smoke_test.py" || exit $? +"$PYTHON" -m py_compile "$ROOT/codex_relay.py" "$ROOT/scripts/configure.py" || exit $? +PYTHONPATH="$ROOT" "$PYTHON" "$ROOT/scripts/smoke_test.py" || exit $? "$ROOT/scripts/status.sh" || true exit "$CODEX_EXIT" diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index bebd4d8..855b25b 100755 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -3,13 +3,14 @@ from __future__ import annotations -import tempfile import os +import subprocess import ssl import sys import threading import json import time +import tempfile import importlib.util import contextlib import io @@ -80,6 +81,47 @@ def send_document( ) +class FailingTelegram(FakeTelegram): + def send_message( + self, chat_id: int, text: str, reply_to_message_id: Optional[int] = None + ) -> Optional[int]: + self.calls.append( + ( + "sendMessage", + { + "chat_id": chat_id, + "text": text, + "reply_to_message_id": reply_to_message_id, + }, + ) + ) + raise RuntimeError("Telegram send failed") + + +class FirstSendFailingTelegram(FakeTelegram): + def __init__(self) -> None: + super().__init__() + self.failed_once = False + + def send_message( + self, chat_id: int, text: str, reply_to_message_id: Optional[int] = None + ) -> Optional[int]: + self.calls.append( + ( + "sendMessage", + { + "chat_id": chat_id, + "text": text, + "reply_to_message_id": reply_to_message_id, + }, + ) + ) + if not self.failed_once: + self.failed_once = True + raise RuntimeError("first send failed") + return len(self.calls) + + class FakeResponse: def __init__(self, chunks: list[bytes], headers: Optional[dict[str, str]] = None) -> None: self.chunks = chunks @@ -346,6 +388,61 @@ def fake_configure_urlopen(*_args: object, **kwargs: object) -> FakeResponse: assert_true("health:" in health, "expected health output") assert_true("deep check: /tools" in health, "expected health to point at deep check") + old_typing_interval = os.environ.get("CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS") + os.environ["CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS"] = "bad" + fake_config_error = FakeTelegram() + try: + with contextlib.redirect_stderr(io.StringIO()): + relay.handle_message_safely( + fake_config_error, + { + "message_id": 22, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/status", + }, + {1}, + {123}, + Path("/tmp/codex-relay-unused-threads.json"), + ) + finally: + if old_typing_interval is None: + os.environ.pop("CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS", None) + else: + os.environ["CODEX_TELEGRAM_TYPING_INTERVAL_SECONDS"] = old_typing_interval + assert_true(fake_config_error.calls, "expected runtime config error reply") + assert_true( + "Relay configuration error" in str(fake_config_error.calls[-1][1].get("text")), + "expected structured runtime config error", + ) + + original_handle_message = relay.handle_message + fake_internal_error = FakeTelegram() + try: + relay.handle_message = lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("state write failed") + ) + with contextlib.redirect_stderr(io.StringIO()): + relay.handle_message_safely( + fake_internal_error, + { + "message_id": 23, + "chat": {"id": 123, "type": "private"}, + "from": {"id": 1}, + "text": "/status", + }, + {1}, + {123}, + Path("/tmp/codex-relay-unused-threads.json"), + ) + finally: + relay.handle_message = original_handle_message + assert_true(fake_internal_error.calls, "expected internal handler error reply") + assert_true( + "Relay internal error" in str(fake_internal_error.calls[-1][1].get("text")), + "expected structured internal handler error", + ) + old_urlopen = relay.urllib.request.urlopen old_context = relay.ssl.create_default_context try: @@ -377,6 +474,24 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: os.environ["CODEX_RELAY_CA_FILE"] = old_ca relay.ssl.create_default_context = old_context + relay.urllib.request.urlopen = lambda *_args, **_kwargs: FakeResponse([b"not-json"]) + try: + relay.TelegramAPI("token").call("getMe") + except RuntimeError as exc: + assert_true("invalid JSON" in str(exc), "expected invalid Telegram JSON to be structured") + else: + raise SystemExit("expected invalid Telegram JSON failure") + relay.urllib.request.urlopen = lambda *_args, **_kwargs: FakeResponse([b"[]"]) + try: + relay.TelegramAPI("token").call("getMe") + except RuntimeError as exc: + assert_true("unexpected JSON" in str(exc), "expected non-object Telegram JSON to be structured") + else: + raise SystemExit("expected non-object Telegram JSON failure") + non_list_updates = relay.TelegramAPI("token") + non_list_updates.call = lambda *_args, **_kwargs: {"ok": True, "result": {"bad": "shape"}} + assert_true(non_list_updates.get_updates(None) == [], "expected malformed update result to be ignored") + relay.urllib.request.urlopen = lambda *_args, **_kwargs: FakeResponse([b"ok"]) assert_true(relay.TelegramAPI("token").download_file("file.jpg", max_bytes=2) == b"ok", "expected bounded download") relay.urllib.request.urlopen = lambda *_args, **_kwargs: FakeResponse([b"abc"]) @@ -418,6 +533,17 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: assert_true("- none" in relay.jobs_text(123, thread), "expected empty jobs output") assert_true("last run: ok; 1.2s; 1 image" in relay.latency_text(thread), "expected latency text") assert_true("./scripts/update.sh" in relay.update_text(), "expected update command text") + status_help = subprocess.run( + ["/bin/zsh", str(ROOT / "scripts" / "status.sh"), "--help"], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + timeout=10, + ) + assert_true(status_help.returncode == 0, "expected status help to pass") + assert_true("--tail" in status_help.stdout, "expected status help to document log tailing") with tempfile.TemporaryDirectory() as tmp: old_state_dir = os.environ.get("CODEX_TELEGRAM_STATE_DIR") @@ -426,6 +552,71 @@ def fake_relay_tls_urlopen(*_args: object, **kwargs: object) -> FakeResponse: relay.write_private_bytes(target, b"ok") assert_true(target.read_bytes() == b"ok", "expected private byte write") assert_true(oct(target.stat().st_mode & 0o777) == "0o600", "expected private file mode") + original_configure_chmod = configure.os.chmod + try: + configure_target = Path(tmp) / "configure.env" + configure.os.chmod = lambda *_args, **_kwargs: (_ for _ in ()).throw( + PermissionError("chmod blocked") + ) + configure_stderr = io.StringIO() + with contextlib.redirect_stderr(configure_stderr): + configure.private_write(configure_target, "TOKEN=redacted\n") + assert_true( + configure_target.read_text() == "TOKEN=redacted\n", + "expected configure write despite chmod warning", + ) + assert_true( + "could not set 0600" in configure_stderr.getvalue(), + "expected configure chmod warning", + ) + finally: + configure.os.chmod = original_configure_chmod + original_chmod = relay.os.chmod + try: + relay._CHMOD_WARNINGS.clear() + + def blocked_chmod(_path: object, _mode: int) -> None: + raise PermissionError("chmod blocked") + + relay.os.chmod = blocked_chmod + permission_stderr = io.StringIO() + with contextlib.redirect_stderr(permission_stderr): + restricted_dir = relay.private_dir(Path(tmp) / "restricted-state") + restricted_file = Path(tmp) / "restricted.txt" + relay.write_private_text(restricted_file, "ok") + assert_true(restricted_dir.exists(), "expected private dir despite chmod warning") + assert_true(restricted_file.read_text() == "ok", "expected private write despite chmod warning") + assert_true( + "Relay permission warning" in permission_stderr.getvalue(), + "expected chmod warning to be logged", + ) + finally: + relay.os.chmod = original_chmod + relay._CHMOD_WARNINGS.clear() + corrupt_threads = Path(tmp) / "corrupt-threads.json" + corrupt_threads.write_text("[]") + assert_true( + relay.read_threads(corrupt_threads) == {"active_by_chat": {}, "threads_by_chat": {}}, + "expected non-object thread state to reset safely", + ) + corrupt_threads.write_text('{"active_by_chat": [], "threads_by_chat": null}') + repaired = relay.read_threads(corrupt_threads) + assert_true( + repaired == {"active_by_chat": {}, "threads_by_chat": {}}, + "expected malformed thread maps to reset safely", + ) + nested_corrupt = { + "active_by_chat": {"123": ["bad"]}, + "threads_by_chat": {"123": {"main": [], "ok": {"name": "ok", "workdir": tmp}}}, + } + corrupt_threads.write_text(json.dumps(nested_corrupt)) + data, active_name, repaired_thread = relay.active_state(corrupt_threads, 123) + assert_true(active_name == "main", "expected malformed active thread to reset") + assert_true(isinstance(repaired_thread, dict), "expected malformed nested thread to be repaired") + assert_true( + "ok" in data["threads_by_chat"]["123"], + "expected valid nested thread to be preserved", + ) relay.append_history_event( { @@ -779,12 +970,29 @@ def fake_remove_plan(*_args: object) -> dict[str, object]: try: recovery_job = relay.RelayJob(123, "recovery", 0) output, exit_code = relay.run_recovery_command(recovery_job, "") + bad_output, bad_exit_code = relay.run_recovery_command(recovery_job, "restart extra") finally: if old_recovery_script is None: os.environ.pop("CODEX_RELAY_RECOVERY_SCRIPT", None) else: os.environ["CODEX_RELAY_RECOVERY_SCRIPT"] = old_recovery_script assert_true(exit_code == 0 and "recover-ok" in output, "expected recovery script runner") + assert_true( + bad_exit_code == 64 and "/recover" in bad_output, + "expected invalid recovery mode to be rejected", + ) + + recover_check = subprocess.run( + ["/bin/zsh", str(ROOT / "scripts" / "recover.sh"), "bogus"], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + timeout=10, + ) + assert_true(recover_check.returncode == 64, "expected recover.sh to reject unknown modes") + assert_true("Usage:" in recover_check.stdout, "expected recover.sh usage") relay.handle_message( fake_style, @@ -1029,6 +1237,79 @@ def fake_start_background_job(*args: object, **kwargs: object) -> None: assert_true("exit 9" in answer, "expected exit code in sanitized failure") assert_true(stats["last_status"] == "failed", "expected failed status") + original_run_codex = relay.run_codex + try: + relay.run_codex = lambda *_args, **_kwargs: ( + "worker answer", + "", + { + "last_run_at": relay.now_iso(), + "last_latency_seconds": 0.1, + "last_status": "ok", + "last_image_count": 0, + "last_reasoning_effort": "high", + "last_speed": "standard", + }, + ) + worker_job = relay.RelayJob(123, "main", 0, "worker prompt") + relay.register_job(worker_job) + with contextlib.redirect_stderr(io.StringIO()): + relay.run_job_worker( + FailingTelegram(), + 123, + threads_path, + "main", + "worker prompt", + {"workdir": tmp, "name": "main"}, + [], + worker_job, + persist_thread_state=False, + ) + assert_true(relay.find_job(123, worker_job.id) is None, "expected failed delivery not to leave active job") + finally: + relay.run_codex = original_run_codex + + original_run_codex = relay.run_codex + try: + relay.run_codex = lambda *_args, **_kwargs: ( + "started despite preface failure", + "", + { + "last_run_at": relay.now_iso(), + "last_latency_seconds": 0.1, + "last_status": "ok", + "last_image_count": 0, + "last_reasoning_effort": "high", + "last_speed": "standard", + }, + ) + flaky_preface = FirstSendFailingTelegram() + with contextlib.redirect_stderr(io.StringIO()): + handled = relay.execute_gemini_plan( + flaky_preface, + 123, + 77, + threads_path, + { + "reply": "preface", + "actions": [{"type": "run_codex", "prompt": "do the work"}], + }, + "do the work", + ) + relay.join_workers(timeout=2) + assert_true(handled, "expected Gemini plan to handle run_codex action") + assert_true(flaky_preface.failed_once, "expected preface delivery failure to be exercised") + assert_true( + any( + call[0] == "sendMessage" + and "started despite preface failure" in str(call[1].get("text")) + for call in flaky_preface.calls + ), + "expected Codex job to continue after preface send failure", + ) + finally: + relay.run_codex = original_run_codex + old_gemini_key = os.environ.get("CODEX_RELAY_GEMINI_API_KEY") original_gemini_generate = relay.gemini_generate original_telegram_urlopen = relay.telegram_urlopen @@ -1067,6 +1348,15 @@ def fake_gemini_urlopen(request: object, timeout: int) -> FakeResponse: and body["generationConfig"]["maxOutputTokens"] == 4096, "expected Gemini max output tokens in generation config", ) + relay.telegram_urlopen = lambda *_args, **_kwargs: FakeResponse( + [b'{"candidates":["bad-candidate"]}'] + ) + try: + relay.gemini_generate("hello") + except RuntimeError as exc: + assert_true("malformed candidate" in str(exc), "expected malformed Gemini candidate error") + else: + raise SystemExit("expected malformed Gemini candidate failure") finally: relay.telegram_urlopen = original_telegram_urlopen os.environ.pop("CODEX_RELAY_GEMINI_MAX_OUTPUT_TOKENS", None) diff --git a/scripts/status.sh b/scripts/status.sh index 105a2f1..65e6901 100755 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -1,18 +1,127 @@ #!/bin/zsh -set -eu +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" LABEL="${CODEX_RELAY_LABEL:-com.codexrelay.agent}" +PYTHON="${CODEX_RELAY_PYTHON:-/usr/bin/python3}" RUNTIME="$HOME/Library/Application Support/CodexRelay" +STATE_DIR="$RUNTIME/state" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +EXPECTED_PROGRAM="/usr/bin/python3" +TAIL_LINES="${CODEX_RELAY_STATUS_TAIL_LINES:-40}" +SHOW_LOGS=1 +exit_status=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-tail) + SHOW_LOGS=0 + ;; + --tail) + if [[ $# -lt 2 ]]; then + printf "Missing line count for --tail\n" >&2 + exit 64 + fi + shift + TAIL_LINES="$1" + ;; + --help|-h) + printf "Usage: %s [--tail lines|--no-tail]\n" "$0" + exit 0 + ;; + *) + printf "Unknown option: %s\n" "$1" >&2 + exit 64 + ;; + esac + shift +done + +if ! [[ "$TAIL_LINES" == <-> ]]; then + TAIL_LINES=40 +fi + +fail_status() { + printf "%s\n" "$1" + exit_status=1 +} + +printf "label=%s\n" "$LABEL" +printf "repo=%s\n" "$ROOT" +printf "runtime=%s\n" "$RUNTIME" +printf "state_dir=%s\n" "$STATE_DIR" +printf "self_check_repo=\"%s\" \"%s\" --check-config\n" "$PYTHON" "$ROOT/codex_relay.py" +printf "self_check_runtime=\"%s\" \"%s\" --check-config\n" "$PYTHON" "$RUNTIME/codex_relay.py" +echo if output="$(launchctl print "gui/$(id -u)/$LABEL" 2>/dev/null)"; then printf "%s\n" "$output" | sed -n '1,45p' + if printf "%s\n" "$output" | grep -Eq "state = running|pid = [1-9][0-9]*"; then + printf "launch_state=running\n" + else + fail_status "launch_state=loaded_not_running" + fi else - printf "LaunchAgent not loaded: %s\n" "$LABEL" + fail_status "launch_state=not_loaded" fi echo + +if disabled_output="$(launchctl print-disabled "gui/$(id -u)" 2>/dev/null)"; then + if printf "%s\n" "$disabled_output" | grep -Fq "\"$LABEL\" => true"; then + fail_status "launch_disabled=true; run launchctl enable gui/$(id -u)/$LABEL" + else + printf "launch_disabled=false\n" + fi +else + printf "launch_disabled=unknown\n" +fi + +if [[ -f "$PLIST" ]]; then + printf "plist=%s\n" "$PLIST" + if grep -Fq "$EXPECTED_PROGRAM" "$PLIST" && grep -Fq "$RUNTIME/codex_relay.py" "$PLIST"; then + printf "plist_program=expected\n" + else + fail_status "plist_program=unexpected; run ./scripts/install_launch_agent.sh" + fi +else + fail_status "plist missing: $PLIST" +fi + if [[ -x "$RUNTIME/codex_relay.py" ]]; then - "$RUNTIME/codex_relay.py" --check-config + if [[ ! -x "$PYTHON" ]]; then + fail_status "python missing: $PYTHON" + elif ! "$PYTHON" "$RUNTIME/codex_relay.py" --check-config; then + exit_status=1 + fi + if [[ -f "$ROOT/codex_relay.py" ]]; then + if cmp -s "$ROOT/codex_relay.py" "$RUNTIME/codex_relay.py"; then + printf "runtime_script=matches_repo\n" + else + fail_status "runtime_script=differs_from_repo; run ./scripts/install_launch_agent.sh" + fi + fi else - printf "Runtime script missing: %s/codex_relay.py\n" "$RUNTIME" - exit 1 + fail_status "runtime script missing: $RUNTIME/codex_relay.py" fi + +if [[ "$SHOW_LOGS" == "1" ]]; then + for log_file in "$STATE_DIR/launchd.err" "$STATE_DIR/launchd.out"; do + if [[ -s "$log_file" ]]; then + printf "\n== tail -%s %s ==\n" "$TAIL_LINES" "$log_file" + tail -n "$TAIL_LINES" "$log_file" + elif [[ -e "$log_file" ]]; then + printf "\n%s is empty\n" "$log_file" + else + printf "\n%s is missing\n" "$log_file" + fi + done +fi + +if [[ "$exit_status" -ne 0 ]]; then + printf "\nremediation:\n" + printf "- reinstall or restart: ./scripts/install_launch_agent.sh\n" + printf "- full doctor: ./scripts/doctor.sh\n" + printf "- larger log tail: ./scripts/status.sh --tail 120\n" +fi + +exit "$exit_status"