From 8e486f53519d19cc1ea5deda9c2c0e9b2a2e6ef5 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 21:35:38 -0600 Subject: [PATCH 1/6] chore(retire-sms-bridge): delete opencode-sms-bridge/Containerfile --- opencode-sms-bridge/Containerfile | 33 ------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 opencode-sms-bridge/Containerfile diff --git a/opencode-sms-bridge/Containerfile b/opencode-sms-bridge/Containerfile deleted file mode 100644 index 798d1d1..0000000 --- a/opencode-sms-bridge/Containerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM python:3.13-alpine - -ARG CRYPTOGRAPHY_VERSION=50.0.1 -ARG FASTAPI_VERSION=0.141.1 -ARG HTTPX_VERSION=0.28.1 -ARG PILLOW_VERSION=12.3.0 -ARG TWILIO_VERSION=9.10.9 -ARG UVICORN_VERSION=0.52.4 - -LABEL description="Signed Twilio SMS and MMS bridge for fixed OpenCode agents" -LABEL org.opencontainers.image.source="https://github.com/makeitworkcloud/images" - -RUN apk add --no-cache ffmpeg \ - && pip install --no-cache-dir \ - "cryptography==${CRYPTOGRAPHY_VERSION}" \ - "fastapi==${FASTAPI_VERSION}" \ - "httpx==${HTTPX_VERSION}" \ - "Pillow==${PILLOW_VERSION}" \ - "twilio==${TWILIO_VERSION}" \ - "uvicorn==${UVICORN_VERSION}" \ - && adduser -D -u 1000 opencode-sms \ - && rm -rf /root/.cache /tmp/* - -WORKDIR /app -COPY server.py test_server.py /app/ -RUN python3 -m unittest discover -s /app -p "test_*.py" -v - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -USER 1000 - -ENTRYPOINT ["python3", "/app/server.py"] From 4d7e1195d1a391226a603b0bedb9d217ebe6339d Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 21:35:44 -0600 Subject: [PATCH 2/6] chore(retire-sms-bridge): delete opencode-sms-bridge/server.py --- opencode-sms-bridge/server.py | 795 ---------------------------------- 1 file changed, 795 deletions(-) delete mode 100644 opencode-sms-bridge/server.py diff --git a/opencode-sms-bridge/server.py b/opencode-sms-bridge/server.py deleted file mode 100644 index c2c3746..0000000 --- a/opencode-sms-bridge/server.py +++ /dev/null @@ -1,795 +0,0 @@ -import asyncio -import base64 -import hashlib -import hmac -import io -import json -import logging -import os -import sqlite3 -import subprocess -import tempfile -import time -import uuid -from dataclasses import dataclass -from pathlib import Path -from typing import Any -from urllib.error import HTTPError, URLError -from urllib.parse import parse_qsl, urlsplit -from urllib.request import HTTPRedirectHandler, Request, build_opener - -import uvicorn -from cryptography.fernet import Fernet, InvalidToken -from fastapi import FastAPI, HTTPException, Request as FastAPIRequest -from fastapi.responses import JSONResponse, Response -from PIL import Image, ImageOps, UnidentifiedImageError -from twilio.request_validator import RequestValidator -from twilio.rest import Client - - -LOG = logging.getLogger("opencode-sms-bridge") -CHANNEL_AGENTS = frozenset({"lawnmowerman", "grillmaster", "homesteader", "homerepair"}) -EMPTY_TWIML = '' -MAX_WEBHOOK_BYTES = 64 * 1024 -ERROR_OK = "ok" -ERROR_OPENCODE_REQUEST_FAILED = "opencode-request-failed" -ERROR_OPENCODE_RESPONSE_INVALID = "opencode-response-invalid" -ERROR_OPENCODE_RESPONSE_ERROR = "opencode-response-error" -ERROR_OPENCODE_INPUT_INVALID = "opencode-input-invalid" -ERROR_TWILIO_SEND_FAILED = "twilio-send-failed" -OPENCODE_OPERATION_SESSION_CREATE = "session-create" -OPENCODE_OPERATION_PROMPT = "prompt" -OPENCODE_OPERATIONS = frozenset( - { - OPENCODE_OPERATION_SESSION_CREATE, - OPENCODE_OPERATION_PROMPT, - } -) -FAILURE_HTTP_4XX = "http-4xx" -FAILURE_HTTP_5XX = "http-5xx" -FAILURE_TRANSPORT = "transport" -FAILURE_URL_CONFIGURATION = "url-configuration" -FAILURE_OS = "os" -FAILURE_UNKNOWN = "unknown" -OPENCODE_FAILURE_CATEGORIES = frozenset( - { - FAILURE_HTTP_4XX, - FAILURE_HTTP_5XX, - FAILURE_TRANSPORT, - FAILURE_URL_CONFIGURATION, - FAILURE_OS, - FAILURE_UNKNOWN, - } -) -RESPONSE_ERROR_PROVIDER_AUTH = "provider-auth" -RESPONSE_ERROR_CONTEXT_OVERFLOW = "context-overflow" -RESPONSE_ERROR_ABORTED = "aborted" -RESPONSE_ERROR_OUTPUT_LENGTH = "output-length" -RESPONSE_ERROR_STRUCTURED_OUTPUT = "structured-output" -RESPONSE_ERROR_CONTENT_FILTER = "content-filter" -RESPONSE_ERROR_UNKNOWN = "unknown" -RESPONSE_ERROR_CATEGORIES = frozenset( - { - RESPONSE_ERROR_PROVIDER_AUTH, - RESPONSE_ERROR_CONTEXT_OVERFLOW, - RESPONSE_ERROR_ABORTED, - RESPONSE_ERROR_OUTPUT_LENGTH, - RESPONSE_ERROR_STRUCTURED_OUTPUT, - RESPONSE_ERROR_CONTENT_FILTER, - RESPONSE_ERROR_UNKNOWN, - } -) -OPENCODE_RESPONSE_ERROR_NAME_CATEGORIES = { - "ProviderAuthError": RESPONSE_ERROR_PROVIDER_AUTH, - "ContextOverflowError": RESPONSE_ERROR_CONTEXT_OVERFLOW, - "MessageAbortedError": RESPONSE_ERROR_ABORTED, - "MessageOutputLengthError": RESPONSE_ERROR_OUTPUT_LENGTH, - "StructuredOutputError": RESPONSE_ERROR_STRUCTURED_OUTPUT, - "ContentFilterError": RESPONSE_ERROR_CONTENT_FILTER, -} -RESPONSE_ERROR_API = "api" -RESPONSE_ERROR_API_NO_STATUS = "no-status" -RESPONSE_ERROR_API_RETRYABLE = "retryable" -RESPONSE_ERROR_API_NONRETRYABLE = "nonretryable" -RESPONSE_ERROR_API_UNKNOWN_RETRYABILITY = "unknown" -RESPONSE_ERROR_API_STATUSES = frozenset(range(100, 600)) | {RESPONSE_ERROR_API_NO_STATUS} -RESPONSE_ERROR_API_RETRYABILITIES = frozenset( - { - RESPONSE_ERROR_API_RETRYABLE, - RESPONSE_ERROR_API_NONRETRYABLE, - RESPONSE_ERROR_API_UNKNOWN_RETRYABILITY, - } -) -OPENCODE_REQUEST_ERROR_CODES = frozenset( - f"{ERROR_OPENCODE_REQUEST_FAILED}:{operation}:{category}" - for operation in OPENCODE_OPERATIONS | {FAILURE_UNKNOWN} - for category in OPENCODE_FAILURE_CATEGORIES -) -OPENCODE_RESPONSE_ERROR_CODES = frozenset( - f"{ERROR_OPENCODE_RESPONSE_ERROR}:{category}" for category in RESPONSE_ERROR_CATEGORIES -) | frozenset( - f"{ERROR_OPENCODE_RESPONSE_ERROR}:{RESPONSE_ERROR_API}:{status}:{retryability}" - for status in RESPONSE_ERROR_API_STATUSES - for retryability in RESPONSE_ERROR_API_RETRYABILITIES -) -BRIDGE_ERROR_CODES = frozenset( - { - ERROR_OK, - ERROR_OPENCODE_REQUEST_FAILED, - ERROR_OPENCODE_RESPONSE_INVALID, - ERROR_OPENCODE_RESPONSE_ERROR, - ERROR_OPENCODE_INPUT_INVALID, - ERROR_TWILIO_SEND_FAILED, - } -) | OPENCODE_REQUEST_ERROR_CODES | OPENCODE_RESPONSE_ERROR_CODES - - -class BridgeError(RuntimeError): - def __init__(self, message: str, error_code: str = ERROR_OPENCODE_RESPONSE_INVALID): - super().__init__(message) - self.error_code = error_code if error_code in BRIDGE_ERROR_CODES else ERROR_OPENCODE_RESPONSE_INVALID - - -class UnsupportedMedia(BridgeError): - pass - - -def classify_request_failure(error: BaseException) -> str: - if isinstance(error, HTTPError): - if 400 <= error.code <= 499: - return FAILURE_HTTP_4XX - if 500 <= error.code <= 599: - return FAILURE_HTTP_5XX - return FAILURE_UNKNOWN - if isinstance(error, URLError): - return FAILURE_TRANSPORT if isinstance(error.reason, OSError) else FAILURE_URL_CONFIGURATION - if isinstance(error, ValueError): - return FAILURE_URL_CONFIGURATION - if isinstance(error, OSError): - return FAILURE_OS - return FAILURE_UNKNOWN - - -def opencode_request_error_code(operation: str | None, category: str | None) -> str: - safe_operation = operation if operation in OPENCODE_OPERATIONS else FAILURE_UNKNOWN - safe_category = category if category in OPENCODE_FAILURE_CATEGORIES else FAILURE_UNKNOWN - return f"{ERROR_OPENCODE_REQUEST_FAILED}:{safe_operation}:{safe_category}" - - -def classify_response_error(error: Any) -> str: - if not isinstance(error, dict): - return RESPONSE_ERROR_UNKNOWN - name = error.get("name") - if isinstance(name, str) and name in OPENCODE_RESPONSE_ERROR_NAME_CATEGORIES: - return OPENCODE_RESPONSE_ERROR_NAME_CATEGORIES[name] - if name != "APIError": - return RESPONSE_ERROR_UNKNOWN - data = error.get("data") - if not isinstance(data, dict): - return f"{RESPONSE_ERROR_API}:{RESPONSE_ERROR_API_NO_STATUS}:{RESPONSE_ERROR_API_UNKNOWN_RETRYABILITY}" - status_code = data.get("statusCode") - if type(status_code) is int and status_code in RESPONSE_ERROR_API_STATUSES: - status = str(status_code) - else: - status = RESPONSE_ERROR_API_NO_STATUS - if data.get("isRetryable") is True: - retryability = RESPONSE_ERROR_API_RETRYABLE - elif data.get("isRetryable") is False: - retryability = RESPONSE_ERROR_API_NONRETRYABLE - else: - retryability = RESPONSE_ERROR_API_UNKNOWN_RETRYABILITY - return f"{RESPONSE_ERROR_API}:{status}:{retryability}" - - -def opencode_response_error_code(error: Any) -> str: - candidate = f"{ERROR_OPENCODE_RESPONSE_ERROR}:{classify_response_error(error)}" - return candidate if candidate in OPENCODE_RESPONSE_ERROR_CODES else f"{ERROR_OPENCODE_RESPONSE_ERROR}:{RESPONSE_ERROR_UNKNOWN}" - - -@dataclass(frozen=True) -class Routing: - account_sid: str - approved_senders: frozenset[str] - channels: dict[str, str] - - -@dataclass(frozen=True) -class Settings: - mode: str - routing: Routing - state_path: Path - state_key: bytes - sender_hash_key: bytes - canonical_webhook_url: str - twilio_auth_token: str - media_allowed_hosts: frozenset[str] - max_media_bytes: int - max_audio_seconds: int - image_parts_enabled: bool - opencode_base_url: str - opencode_username: str - opencode_password: str - opencode_timeout_seconds: int - twilio_api_key_sid: str - twilio_api_key_secret: str - whisper_url: str - whisper_model: str - twilio_messaging_service_sid: str = "" - - @classmethod - def from_env(cls) -> "Settings": - mode = os.environ.get("BRIDGE_MODE", "").strip() - if mode not in {"ingress", "worker"}: - raise BridgeError("BRIDGE_MODE must be ingress or worker") - routing = load_routing(Path(require_env("ROUTING_CONFIG_PATH"))) - canonical = require_env("CANONICAL_WEBHOOK_URL") - parsed = urlsplit(canonical) - if parsed.scheme != "https" or not parsed.hostname or parsed.query or parsed.fragment: - raise BridgeError("CANONICAL_WEBHOOK_URL must be an HTTPS URL without query or fragment") - return cls( - mode=mode, - routing=routing, - state_path=Path(require_env("STATE_PATH")), - state_key=require_env("STATE_ENCRYPTION_KEY").encode(), - sender_hash_key=require_env("SENDER_HASH_KEY").encode(), - canonical_webhook_url=canonical, - twilio_auth_token=require_env("TWILIO_AUTH_TOKEN"), - media_allowed_hosts=frozenset( - item.strip().lower() - for item in os.environ.get("TWILIO_MEDIA_ALLOWED_HOSTS", "api.twilio.com").split(",") - if item.strip() - ), - max_media_bytes=positive_int("MAX_MEDIA_BYTES", 5 * 1024 * 1024), - max_audio_seconds=positive_int("MAX_AUDIO_SECONDS", 120), - image_parts_enabled=os.environ.get("OPENCODE_IMAGE_PARTS_ENABLED", "false").lower() == "true", - opencode_base_url=os.environ.get("OPENCODE_API_BASE_URL", "").rstrip("/"), - opencode_username=os.environ.get("OPENCODE_SERVER_USERNAME", "opencode"), - opencode_password=os.environ.get("OPENCODE_SERVER_PASSWORD", ""), - opencode_timeout_seconds=positive_int("OPENCODE_TIMEOUT_SECONDS", 120), - twilio_api_key_sid=os.environ.get("TWILIO_API_KEY_SID", ""), - twilio_api_key_secret=os.environ.get("TWILIO_API_KEY_SECRET", ""), - twilio_messaging_service_sid=os.environ.get("TWILIO_MESSAGING_SERVICE_SID", "").strip(), - whisper_url=os.environ.get("WHISPER_URL", "").rstrip("/"), - whisper_model=os.environ.get("WHISPER_MODEL", "base"), - ) - - def worker_ready(self) -> None: - required = { - "OPENCODE_API_BASE_URL": self.opencode_base_url, - "OPENCODE_SERVER_PASSWORD": self.opencode_password, - "TWILIO_API_KEY_SID": self.twilio_api_key_sid, - "TWILIO_API_KEY_SECRET": self.twilio_api_key_secret, - } - missing = [name for name, value in required.items() if not value] - if missing: - raise BridgeError("worker configuration is incomplete: " + ", ".join(missing)) - - -def require_env(name: str) -> str: - value = os.environ.get(name, "").strip() - if not value: - raise BridgeError(f"{name} is required") - return value - - -def positive_int(name: str, default: int) -> int: - try: - value = int(os.environ.get(name, str(default))) - except ValueError as error: - raise BridgeError(f"{name} must be an integer") from error - if value <= 0: - raise BridgeError(f"{name} must be positive") - return value - - -def normalize_e164(value: Any) -> str: - if not isinstance(value, str): - raise BridgeError("phone number is invalid") - normalized = value.strip() - if not normalized.startswith("+") or not normalized[1:].isdigit() or not 8 <= len(normalized) <= 16: - raise BridgeError("phone number must be E.164") - return normalized - - -def load_routing(path: Path) -> Routing: - try: - payload = json.loads(path.read_text()) - account_sid = str(payload["accountSid"]) - approved_senders = frozenset(normalize_e164(item) for item in payload["approvedSenders"]) - channels = { - normalize_e164(phone): str(config["agent"]) - for phone, config in payload["channels"].items() - } - except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as error: - raise BridgeError("routing configuration is invalid") from error - if not account_sid.startswith("AC") or len(account_sid) < 10: - raise BridgeError("routing account SID is invalid") - if not approved_senders: - raise BridgeError("routing configuration needs an approved sender") - if len(channels) != 4 or set(channels.values()) != CHANNEL_AGENTS: - raise BridgeError("routing configuration must map four numbers to the four fixed primary agents") - return Routing(account_sid=account_sid, approved_senders=approved_senders, channels=channels) - - -def sender_hash(key: bytes, sender: str) -> str: - return hmac.new(key, sender.encode(), hashlib.sha256).hexdigest() - - -class SQLiteStore: - def __init__(self, path: Path, key: bytes): - self.path = path - self.fernet = Fernet(key) - path.parent.mkdir(parents=True, exist_ok=True) - self._initialize() - - def _connect(self) -> sqlite3.Connection: - connection = sqlite3.connect(self.path, timeout=10, isolation_level=None) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA busy_timeout=10000") - return connection - - def _initialize(self) -> None: - with self._connect() as connection: - connection.executescript( - """ - PRAGMA journal_mode=WAL; - CREATE TABLE IF NOT EXISTS jobs ( - message_sid TEXT PRIMARY KEY, - channel TEXT NOT NULL, - sender_hash TEXT NOT NULL, - payload BLOB NOT NULL, - status TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - claimed_at INTEGER, - detail_code TEXT - ); - CREATE TABLE IF NOT EXISTS sessions ( - channel TEXT NOT NULL, - sender_hash TEXT NOT NULL, - session_id TEXT NOT NULL, - PRIMARY KEY(channel, sender_hash) - ); - """ - ) - - def enqueue(self, message_sid: str, channel: str, sender_id: str, payload: dict[str, Any]) -> bool: - ciphertext = self.fernet.encrypt(json.dumps(payload, separators=(",", ":")).encode()) - with self._connect() as connection: - result = connection.execute( - """INSERT OR IGNORE INTO jobs - (message_sid, channel, sender_hash, payload, status, created_at) - VALUES (?, ?, ?, ?, 'queued', ?)""", - (message_sid, channel, sender_id, ciphertext, int(time.time())), - ) - return result.rowcount == 1 - - def claim(self) -> dict[str, Any] | None: - stale_before = int(time.time()) - 300 - with self._connect() as connection: - connection.execute("BEGIN IMMEDIATE") - connection.execute( - "UPDATE jobs SET status='queued', claimed_at=NULL WHERE status='processing' AND claimed_at < ?", - (stale_before,), - ) - row = connection.execute( - "SELECT * FROM jobs WHERE status='queued' ORDER BY created_at LIMIT 1" - ).fetchone() - if row is None: - connection.execute("COMMIT") - return None - connection.execute( - "UPDATE jobs SET status='processing', attempts=attempts+1, claimed_at=? WHERE message_sid=?", - (int(time.time()), row["message_sid"]), - ) - connection.execute("COMMIT") - try: - payload = json.loads(self.fernet.decrypt(row["payload"]).decode()) - except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as error: - self.finish(row["message_sid"], "failed", "payload-unreadable") - raise BridgeError("queued payload cannot be decrypted") from error - return {"message_sid": row["message_sid"], "channel": row["channel"], "sender_hash": row["sender_hash"], "payload": payload} - - def session(self, channel: str, sender_id: str) -> str | None: - with self._connect() as connection: - row = connection.execute( - "SELECT session_id FROM sessions WHERE channel=? AND sender_hash=?", (channel, sender_id) - ).fetchone() - return None if row is None else row["session_id"] - - def remember_session(self, channel: str, sender_id: str, session_id: str) -> str: - with self._connect() as connection: - connection.execute( - "INSERT OR IGNORE INTO sessions (channel, sender_hash, session_id) VALUES (?, ?, ?)", - (channel, sender_id, session_id), - ) - return self.session(channel, sender_id) or session_id - - def begin_send(self, message_sid: str) -> bool: - with self._connect() as connection: - result = connection.execute( - "UPDATE jobs SET status='sending' WHERE message_sid=? AND status='processing'", (message_sid,) - ) - return result.rowcount == 1 - - def finish(self, message_sid: str, status: str, detail_code: str) -> None: - with self._connect() as connection: - connection.execute( - "UPDATE jobs SET status=?, detail_code=? WHERE message_sid=?", (status, detail_code, message_sid) - ) - - -def parse_form(body: bytes) -> dict[str, str]: - try: - pairs = parse_qsl(body.decode("utf-8"), keep_blank_values=True, strict_parsing=True) - except (UnicodeDecodeError, ValueError) as error: - raise HTTPException(status_code=400, detail="invalid form") from error - result: dict[str, str] = {} - for key, value in pairs: - if key in result: - raise HTTPException(status_code=400, detail="duplicate form key") - result[key] = value - return result - - -def empty_twiml() -> Response: - return Response(EMPTY_TWIML, media_type="application/xml") - - -def validate_webhook(settings: Settings, form: dict[str, str], signature: str | None) -> bool: - if not signature: - return False - return RequestValidator(settings.twilio_auth_token).validate(settings.canonical_webhook_url, form, signature) - - -def incoming_payload(settings: Settings, form: dict[str, str]) -> tuple[str, str, str, dict[str, Any]] | None: - try: - account_sid = form["AccountSid"] - message_sid = form["MessageSid"] - source = normalize_e164(form["From"]) - destination = normalize_e164(form["To"]) - num_media = int(form.get("NumMedia", "0")) - except (KeyError, ValueError, BridgeError) as error: - raise HTTPException(status_code=400, detail="invalid message") from error - if account_sid != settings.routing.account_sid or destination not in settings.routing.channels: - return None - if source not in settings.routing.approved_senders: - return None - if not message_sid or len(message_sid) > 64 or num_media < 0 or num_media > 3: - raise HTTPException(status_code=400, detail="invalid message metadata") - media = [] - for index in range(num_media): - url = form.get(f"MediaUrl{index}") - content_type = form.get(f"MediaContentType{index}") - if not url or not content_type: - raise HTTPException(status_code=400, detail="invalid media metadata") - media.append({"url": url, "contentType": content_type.lower()}) - channel = settings.routing.channels[destination] - payload = {"from": source, "to": destination, "body": form.get("Body", ""), "media": media, "agent": channel} - return message_sid, channel, sender_hash(settings.sender_hash_key, source), payload - - -def create_ingress_app(settings: Settings, store: SQLiteStore) -> FastAPI: - app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) - - @app.get("/healthz") - async def healthz() -> JSONResponse: - return JSONResponse({"status": "ok"}) - - @app.post("/twilio/inbound") - async def inbound(request: FastAPIRequest) -> Response: - content_type = request.headers.get("content-type", "") - if not content_type.startswith("application/x-www-form-urlencoded"): - raise HTTPException(status_code=415, detail="form encoding required") - content_length = request.headers.get("content-length") - if content_length and int(content_length) > MAX_WEBHOOK_BYTES: - raise HTTPException(status_code=413, detail="request too large") - body = await request.body() - if len(body) > MAX_WEBHOOK_BYTES: - raise HTTPException(status_code=413, detail="request too large") - form = parse_form(body) - if not validate_webhook(settings, form, request.headers.get("x-twilio-signature")): - LOG.warning("event=inbound_rejected reason=invalid-signature") - raise HTTPException(status_code=403, detail="invalid signature") - message = incoming_payload(settings, form) - if message is None: - LOG.info("event=inbound_ignored reason=account-destination-or-sender") - return empty_twiml() - message_sid, channel, source_id, payload = message - queued = store.enqueue(message_sid, channel, source_id, payload) - LOG.info( - "event=%s channel=%s media_count=%d", - "inbound_queued" if queued else "inbound_duplicate", - channel, - len(payload["media"]), - ) - return empty_twiml() - - return app - - -class NoRedirect(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 - return None - - -def checked_media_url(url: str, allowed_hosts: frozenset[str]) -> None: - parsed = urlsplit(url) - if ( - parsed.scheme != "https" - or parsed.port not in (None, 443) - or parsed.username is not None - or parsed.password is not None - or parsed.fragment - or parsed.hostname is None - or parsed.hostname.lower() not in allowed_hosts - ): - raise UnsupportedMedia("media URL is not a Twilio HTTPS endpoint") - - -def download_media(settings: Settings, item: dict[str, str]) -> tuple[bytes, str]: - checked_media_url(item["url"], settings.media_allowed_hosts) - credentials = base64.b64encode(f"{settings.routing.account_sid}:{settings.twilio_auth_token}".encode()).decode() - request = Request(item["url"], headers={"Authorization": f"Basic {credentials}", "Accept": "*/*"}) - try: - with build_opener(NoRedirect).open(request, timeout=30) as response: - length = response.headers.get("Content-Length") - if length and int(length) > settings.max_media_bytes: - raise UnsupportedMedia("media exceeds configured size") - actual_type = response.headers.get_content_type().lower() - data = response.read(settings.max_media_bytes + 1) - except (HTTPError, URLError, OSError, ValueError) as error: - raise UnsupportedMedia("media download failed") from error - if len(data) > settings.max_media_bytes: - raise UnsupportedMedia("media exceeds configured size") - declared_type = item["contentType"].split(";", 1)[0].lower() - if actual_type != declared_type: - raise UnsupportedMedia("media content type does not match") - return data, actual_type - - -def sanitize_image(data: bytes, mime: str) -> tuple[bytes, str]: - expected = {"image/jpeg": "JPEG", "image/png": "PNG"} - if mime not in expected: - raise UnsupportedMedia("only JPEG and PNG images are supported") - try: - with Image.open(io.BytesIO(data)) as check: - check.verify() - with Image.open(io.BytesIO(data)) as image: - if image.format != expected[mime]: - raise UnsupportedMedia("image magic bytes do not match content type") - image = ImageOps.exif_transpose(image) - image.thumbnail((4096, 4096)) - output = io.BytesIO() - if mime == "image/jpeg": - image.convert("RGB").save(output, "JPEG", quality=85, optimize=True) - else: - image.convert("RGBA").save(output, "PNG", optimize=True) - return output.getvalue(), mime - except (UnidentifiedImageError, OSError, ValueError) as error: - raise UnsupportedMedia("image cannot be safely decoded") from error - - -def audio_duration(data: bytes, mime: str, maximum: int) -> None: - suffix = {"audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/x-wav": ".wav"}.get(mime) - if suffix is None: - raise UnsupportedMedia("unsupported audio type") - with tempfile.NamedTemporaryFile(suffix=suffix) as handle: - handle.write(data) - handle.flush() - try: - result = subprocess.run( - ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", handle.name], - check=False, - capture_output=True, - text=True, - timeout=10, - ) - duration = float(result.stdout.strip()) - except (OSError, ValueError, subprocess.TimeoutExpired) as error: - raise UnsupportedMedia("audio duration could not be verified") from error - if result.returncode != 0 or duration <= 0 or duration > maximum: - raise UnsupportedMedia("audio duration is outside the configured limit") - - -def transcribe(settings: Settings, data: bytes, mime: str) -> str: - if not settings.whisper_url: - raise UnsupportedMedia("audio transcription is not configured") - boundary = f"----opencode-sms-{uuid.uuid4().hex}" - body = b"".join( - [ - f"--{boundary}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n{settings.whisper_model}\r\n".encode(), - f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio\"\r\nContent-Type: {mime}\r\n\r\n".encode(), - data, - f"\r\n--{boundary}--\r\n".encode(), - ] - ) - request = Request( - settings.whisper_url, - data=body, - method="POST", - headers={"Content-Type": f"multipart/form-data; boundary={boundary}", "Content-Length": str(len(body))}, - ) - try: - with build_opener(NoRedirect).open(request, timeout=60) as response: - payload = json.loads(response.read().decode()) - text = payload.get("text", "") - except (HTTPError, URLError, OSError, ValueError, json.JSONDecodeError) as error: - raise UnsupportedMedia("audio transcription failed") from error - if not isinstance(text, str) or not text.strip(): - raise UnsupportedMedia("audio transcription was empty") - return text.strip() - - -class OpenCodeClient: - def __init__(self, settings: Settings): - self.settings = settings - basic = base64.b64encode(f"{settings.opencode_username}:{settings.opencode_password}".encode()).decode() - self.headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/json"} - - def _request( - self, - path: str, - payload: dict[str, Any] | None = None, - method: str = "POST", - operation: str | None = None, - ) -> dict[str, Any]: - request = Request( - f"{self.settings.opencode_base_url}{path}", - data=None if payload is None else json.dumps(payload).encode(), - method=method, - headers=self.headers, - ) - try: - with build_opener(NoRedirect).open(request, timeout=self.settings.opencode_timeout_seconds) as response: - body = response.read() - except (HTTPError, URLError, OSError, ValueError) as error: - raise BridgeError( - "OpenCode request failed", - opencode_request_error_code(operation, classify_request_failure(error)), - ) from error - if not body: - return {} - try: - return json.loads(body.decode()) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise BridgeError("OpenCode response was invalid", ERROR_OPENCODE_RESPONSE_INVALID) from error - - def create_session(self) -> str: - response = self._request("/session", {}, operation=OPENCODE_OPERATION_SESSION_CREATE) - session_id = response.get("id") if isinstance(response, dict) else None - if not isinstance(session_id, str) or not session_id: - raise BridgeError("OpenCode session response was invalid", ERROR_OPENCODE_RESPONSE_INVALID) - return session_id - - def prompt(self, session_id: str, agent: str, parts: list[dict[str, str]]) -> str: - if any(part.get("type") != "text" for part in parts): - raise UnsupportedMedia("V2 file prompt mapping is not implemented") - text = "\n".join(part.get("text", "") for part in parts).strip() - if not text: - raise BridgeError("OpenCode prompt has no text", ERROR_OPENCODE_INPUT_INVALID) - response = self._request( - f"/session/{session_id}/message", - {"agent": agent, "parts": [{"type": "text", "text": text}]}, - operation=OPENCODE_OPERATION_PROMPT, - ) - if ( - not isinstance(response, dict) - or not isinstance(response.get("info"), dict) - or not isinstance(response.get("parts"), list) - ): - raise BridgeError("OpenCode message response was invalid", ERROR_OPENCODE_RESPONSE_INVALID) - error = response["info"].get("error") - if error is not None: - raise BridgeError("OpenCode response reported an error", opencode_response_error_code(error)) - reply = "".join( - part.get("text", "") - for part in response["parts"] - if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str) - ) - if not reply.strip(): - raise BridgeError("OpenCode response did not contain text", ERROR_OPENCODE_RESPONSE_INVALID) - return reply.strip() - - -def build_parts(settings: Settings, payload: dict[str, Any]) -> list[dict[str, str]]: - parts: list[dict[str, str]] = [] - body = payload.get("body", "") - if isinstance(body, str) and body.strip(): - parts.append({"type": "text", "text": body.strip()}) - for item in payload.get("media", []): - data, mime = download_media(settings, item) - if mime.startswith("image/"): - if not settings.image_parts_enabled: - raise UnsupportedMedia("image analysis is not configured") - sanitized, safe_mime = sanitize_image(data, mime) - encoded = base64.b64encode(sanitized).decode() - parts.append({"type": "file", "mime": safe_mime, "filename": "twilio-image", "url": f"data:{safe_mime};base64,{encoded}"}) - elif mime.startswith("audio/"): - audio_duration(data, mime, settings.max_audio_seconds) - parts.append({"type": "text", "text": "Audio MMS transcript:\n" + transcribe(settings, data, mime)}) - else: - raise UnsupportedMedia("unsupported media type") - if not parts: - raise UnsupportedMedia("message has no usable text, image, or audio") - return parts - - -def sms_body(value: str) -> str: - cleaned = " ".join(value.split()) - return cleaned[:1500] if cleaned else "I could not prepare a response. Please try again." - - -def process_job(settings: Settings, store: SQLiteStore, client: OpenCodeClient, job: dict[str, Any]) -> None: - try: - session_id = store.session(job["channel"], job["sender_hash"]) - if session_id is None: - session_id = store.remember_session(job["channel"], job["sender_hash"], client.create_session()) - response = client.prompt(session_id, job["payload"]["agent"], build_parts(settings, job["payload"])) - except UnsupportedMedia: - LOG.info("event=job_unsupported_media channel=%s", job["channel"]) - response = "This channel cannot process that attachment yet. Please send text or try a supported attachment later." - except BridgeError as error: - LOG.warning("event=job_failed stage=opencode channel=%s error_code=%s", job["channel"], error.error_code) - store.finish(job["message_sid"], "failed", error.error_code) - return - if not store.begin_send(job["message_sid"]): - LOG.warning("event=job_skipped stage=state channel=%s", job["channel"]) - return - try: - twilio = Client(settings.twilio_api_key_sid, settings.twilio_api_key_secret, settings.routing.account_sid) - if settings.twilio_messaging_service_sid: - twilio.messages.create( - to=job["payload"]["from"], - body=sms_body(response), - messaging_service_sid=settings.twilio_messaging_service_sid, - ) - else: - twilio.messages.create(to=job["payload"]["from"], from_=job["payload"]["to"], body=sms_body(response)) - except Exception: # The helper library's exception details can include provider data; do not log them. - LOG.warning("event=job_delivery_unknown stage=twilio channel=%s", job["channel"]) - store.finish(job["message_sid"], "delivery-unknown", ERROR_TWILIO_SEND_FAILED) - return - store.finish(job["message_sid"], "sent", ERROR_OK) - LOG.info("event=job_sent channel=%s", job["channel"]) - - -def create_worker_app(settings: Settings, store: SQLiteStore) -> FastAPI: - settings.worker_ready() - client = OpenCodeClient(settings) - app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) - app.state.last_cycle = 0.0 - - @app.on_event("startup") - async def start_worker() -> None: - async def loop() -> None: - while True: - app.state.last_cycle = time.monotonic() - job = await asyncio.to_thread(store.claim) - if job is None: - await asyncio.sleep(1) - continue - LOG.info("event=job_claimed channel=%s", job["channel"]) - await asyncio.to_thread(process_job, settings, store, client, job) - asyncio.create_task(loop()) - - @app.get("/healthz") - async def healthz() -> JSONResponse: - healthy = time.monotonic() - app.state.last_cycle < 30 - return JSONResponse({"status": "ok" if healthy else "unhealthy"}, status_code=200 if healthy else 503) - - return app - - -def main() -> None: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") - settings = Settings.from_env() - store = SQLiteStore(settings.state_path, settings.state_key) - if settings.mode == "ingress": - uvicorn.run(create_ingress_app(settings, store), host="0.0.0.0", port=8080, log_level="warning", access_log=False) - else: - uvicorn.run(create_worker_app(settings, store), host="127.0.0.1", port=8081, log_level="warning", access_log=False) - - -if __name__ == "__main__": - main() From defdbd875b2f12ef1ea5d16f72e10f57e02966f3 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 21:35:53 -0600 Subject: [PATCH 3/6] chore(retire-sms-bridge): delete opencode-sms-bridge/test_server.py --- opencode-sms-bridge/test_server.py | 621 ----------------------------- 1 file changed, 621 deletions(-) delete mode 100644 opencode-sms-bridge/test_server.py diff --git a/opencode-sms-bridge/test_server.py b/opencode-sms-bridge/test_server.py deleted file mode 100644 index 8a55d35..0000000 --- a/opencode-sms-bridge/test_server.py +++ /dev/null @@ -1,621 +0,0 @@ -import base64 -import json -import sqlite3 -import tempfile -import unittest -from dataclasses import replace -from pathlib import Path -from unittest.mock import patch -from urllib.error import HTTPError, URLError - -from cryptography.fernet import Fernet -from fastapi.testclient import TestClient -from PIL import Image -from twilio.request_validator import RequestValidator - -from server import ( - BRIDGE_ERROR_CODES, - BridgeError, - OPENCODE_OPERATION_PROMPT, - OPENCODE_OPERATION_SESSION_CREATE, - OpenCodeClient, - Routing, - SQLiteStore, - Settings, - UnsupportedMedia, - classify_request_failure, - classify_response_error, - create_ingress_app, - load_routing, - normalize_e164, - opencode_request_error_code, - opencode_response_error_code, - process_job, - sanitize_image, - sender_hash, -) - - -class BridgeTests(unittest.TestCase): - def setUp(self): - self.tempdir = tempfile.TemporaryDirectory() - self.key = Fernet.generate_key() - self.routing = Routing( - account_sid="AC1234567890", - approved_senders=frozenset({"+15559999999"}), - channels={ - "+15550000001": "lawnmowerman", - "+15550000002": "grillmaster", - "+15550000003": "homesteader", - "+15550000004": "homerepair", - }, - ) - self.settings = Settings( - mode="ingress", - routing=self.routing, - state_path=Path(self.tempdir.name) / "state.db", - state_key=self.key, - sender_hash_key=b"sender-hash-key", - canonical_webhook_url="https://sms.example.invalid/twilio/inbound", - twilio_auth_token="auth-token", - media_allowed_hosts=frozenset({"api.twilio.com"}), - max_media_bytes=1024 * 1024, - max_audio_seconds=120, - image_parts_enabled=False, - opencode_base_url="", - opencode_username="opencode", - opencode_password="", - opencode_timeout_seconds=120, - twilio_api_key_sid="", - twilio_api_key_secret="", - whisper_url="", - whisper_model="base", - ) - self.store = SQLiteStore(self.settings.state_path, self.key) - - def tearDown(self): - self.tempdir.cleanup() - - def test_e164_rejects_noncanonical_values(self): - self.assertEqual(normalize_e164("+15551234567"), "+15551234567") - for value in ("15551234567", "+1 555 123 4567", "+abc"): - with self.assertRaises(Exception): - normalize_e164(value) - - def test_routing_requires_the_existing_primary_agents(self): - routing_path = Path(self.tempdir.name) / "routing.json" - payload = { - "accountSid": "AC1234567890", - "approvedSenders": ["+15559999999"], - "channels": { - "+15550000001": {"agent": "lawnmowerman"}, - "+15550000002": {"agent": "grillmaster"}, - "+15550000003": {"agent": "homesteader"}, - "+15550000004": {"agent": "homerepair"}, - }, - } - routing_path.write_text(json.dumps(payload)) - self.assertEqual(load_routing(routing_path).channels, self.routing.channels) - - payload["channels"]["+15550000001"]["agent"] = "lawnmowerman-sms" - routing_path.write_text(json.dumps(payload)) - with self.assertRaises(BridgeError): - load_routing(routing_path) - - def test_queue_is_deduplicated_and_payload_is_encrypted(self): - payload = {"from": "+15559999999", "to": "+15550000001", "body": "confidential body", "media": [], "agent": "lawnmowerman"} - identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) - self.assertTrue(self.store.enqueue("SM123", "lawnmowerman", identifier, payload)) - self.assertFalse(self.store.enqueue("SM123", "lawnmowerman", identifier, payload)) - with sqlite3.connect(self.settings.state_path) as connection: - stored = connection.execute("SELECT payload FROM jobs").fetchone()[0] - self.assertNotIn(b"confidential body", stored) - claimed = self.store.claim() - self.assertEqual(claimed["payload"]["body"], "confidential body") - - def test_ingress_accepts_signed_approved_message_once(self): - form = {"AccountSid": "AC1234567890", "MessageSid": "SM123", "From": "+15559999999", "To": "+15550000001", "Body": "hello", "NumMedia": "0"} - signature = RequestValidator("auth-token").compute_signature(self.settings.canonical_webhook_url, form) - client = TestClient(create_ingress_app(self.settings, self.store)) - headers = {"X-Twilio-Signature": signature} - with self.assertLogs("opencode-sms-bridge", level="INFO") as captured: - self.assertEqual(client.post("/twilio/inbound", data=form, headers=headers).status_code, 200) - telemetry = "\n".join(captured.output) - self.assertIn("event=inbound_queued channel=lawnmowerman media_count=0", telemetry) - for unsafe_value in (form["From"], form["To"], form["MessageSid"], form["Body"]): - self.assertNotIn(unsafe_value, telemetry) - self.assertEqual(client.post("/twilio/inbound", data=form, headers=headers).status_code, 200) - self.assertIsNotNone(self.store.claim()) - self.assertIsNone(self.store.claim()) - - def test_ingress_ignores_unapproved_sender_before_queueing(self): - form = {"AccountSid": "AC1234567890", "MessageSid": "SM124", "From": "+15558888888", "To": "+15550000001", "Body": "hello", "NumMedia": "0"} - signature = RequestValidator("auth-token").compute_signature(self.settings.canonical_webhook_url, form) - client = TestClient(create_ingress_app(self.settings, self.store)) - with patch.object(self.store, "enqueue", wraps=self.store.enqueue) as enqueue: - with self.assertLogs("opencode-sms-bridge", level="INFO") as captured: - response = client.post("/twilio/inbound", data=form, headers={"X-Twilio-Signature": signature}) - telemetry = "\n".join(captured.output) - self.assertEqual(response.status_code, 200) - self.assertIn("event=inbound_ignored reason=account-destination-or-sender", telemetry) - for unsafe_value in (form["From"], form["To"], form["MessageSid"], form["Body"]): - self.assertNotIn(unsafe_value, telemetry) - enqueue.assert_not_called() - self.assertIsNone(self.store.claim()) - - def test_documented_flow_uses_exact_routes_methods_and_bodies(self): - settings = replace(self.settings, opencode_base_url="https://opencode.example.invalid") - client = OpenCodeClient(settings) - session_body = json.dumps({"id": "ses_new"}).encode() - message_body = json.dumps( - {"info": {"id": "msg_1", "role": "assistant"}, "parts": [{"type": "text", "text": "reply"}]} - ).encode() - with patch("server.build_opener") as opener_factory: - context = opener_factory.return_value.open.return_value.__enter__.return_value - context.read.side_effect = [session_body, message_body] - session_id = client.create_session() - reply = client.prompt(session_id, "homesteader", [{"type": "text", "text": "hi"}]) - self.assertEqual(session_id, "ses_new") - self.assertEqual(reply, "reply") - open_mock = opener_factory.return_value.open - self.assertEqual(open_mock.call_count, 2) - requests = [call.args[0] for call in open_mock.call_args_list] - self.assertEqual([request.get_method() for request in requests], ["POST", "POST"]) - self.assertEqual( - [request.full_url for request in requests], - [ - "https://opencode.example.invalid/session", - "https://opencode.example.invalid/session/ses_new/message", - ], - ) - self.assertEqual(json.loads(requests[0].data.decode()), {}) - self.assertEqual( - json.loads(requests[1].data.decode()), - {"agent": "homesteader", "parts": [{"type": "text", "text": "hi"}]}, - ) - for request in requests: - for unsupported in ("/api", "/prompt", "prompt_async", "/wait"): - self.assertNotIn(unsupported, request.full_url) - - def test_session_create_sends_only_documented_fields(self): - client = OpenCodeClient(self.settings) - with patch.object(client, "_request", return_value={"id": "ses_123"}) as request: - self.assertEqual(client.create_session(), "ses_123") - request.assert_called_once_with("/session", {}, operation=OPENCODE_OPERATION_SESSION_CREATE) - - def test_session_create_rejects_invalid_response(self): - client = OpenCodeClient(self.settings) - for invalid in ({}, {"data": {"id": "ses_123"}}, "ses_123"): - with self.subTest(invalid=invalid): - with patch.object(client, "_request", return_value=invalid): - with self.assertRaises(BridgeError) as raised: - client.create_session() - self.assertEqual(raised.exception.error_code, "opencode-response-invalid") - self.assertNotIn("ses_123", str(raised.exception)) - - def test_prompt_makes_single_blocking_message_request(self): - client = OpenCodeClient(self.settings) - completed = {"info": {"id": "msg_123", "role": "assistant"}, "parts": [{"type": "text", "text": "reply"}]} - with patch.object(client, "_request", return_value=completed) as request: - self.assertEqual( - client.prompt("ses_123", "lawnmowerman", [{"type": "text", "text": "hello"}]), - "reply", - ) - self.assertEqual(request.call_count, 1) - request.assert_called_once_with( - "/session/ses_123/message", - {"agent": "lawnmowerman", "parts": [{"type": "text", "text": "hello"}]}, - operation=OPENCODE_OPERATION_PROMPT, - ) - for invoked in request.call_args_list: - path = invoked.args[0] - self.assertNotIn("/api", path) - self.assertNotIn("/prompt", path) - self.assertNotIn("prompt_async", path) - self.assertNotIn("/wait", path) - - def test_prompt_joins_text_parts_into_one_documented_text_part(self): - client = OpenCodeClient(self.settings) - completed = {"info": {"id": "msg_123"}, "parts": [{"type": "text", "text": "reply"}]} - with patch.object(client, "_request", return_value=completed) as request: - client.prompt( - "ses_123", - "grillmaster", - [{"type": "text", "text": "line one"}, {"type": "text", "text": "line two"}], - ) - request.assert_called_once_with( - "/session/ses_123/message", - {"agent": "grillmaster", "parts": [{"type": "text", "text": "line one\nline two"}]}, - operation=OPENCODE_OPERATION_PROMPT, - ) - - def test_prompt_extracts_assistant_text_from_info_and_parts(self): - client = OpenCodeClient(self.settings) - completed = { - "info": {"id": "msg_123", "role": "assistant"}, - "parts": [ - {"type": "step-start"}, - {"type": "tool", "tool": "read", "state": {"content": "raw file detail"}}, - {"type": "text", "text": " part one "}, - {"type": "text", "text": "part two"}, - ], - } - with patch.object(client, "_request", return_value=completed) as request: - self.assertEqual( - client.prompt("ses_123", "lawnmowerman", [{"type": "text", "text": "hello"}]), - "part one part two", - ) - self.assertEqual(request.call_count, 1) - - def test_prompt_rejects_invalid_message_results_safely(self): - client = OpenCodeClient(self.settings) - invalid_results = ( - {}, - {"id": "in_123"}, - {"data": {"id": "in_123"}}, - {"data": {"info": {"id": "msg_123"}, "parts": [{"type": "text", "text": "wrapped"}]}}, - {"info": {"id": "msg_123"}, "parts": "not-a-list"}, - {"info": "not-an-object", "parts": []}, - {"info": {"id": "msg_123"}, "parts": [{"type": "tool", "state": {"output": "raw detail"}}]}, - {"info": {"id": "msg_123"}, "parts": [{"type": "text", "text": " "}]}, - [{"type": "text", "text": "list"}], - "raw string", - ) - for result in invalid_results: - with self.subTest(result=result): - with patch.object(client, "_request", return_value=result): - with self.assertRaises(BridgeError) as raised: - client.prompt("ses_123", "lawnmowerman", [{"type": "text", "text": "hello"}]) - self.assertEqual(raised.exception.error_code, "opencode-response-invalid") - self.assertNotIn("in_123", str(raised.exception)) - self.assertNotIn("raw detail", str(raised.exception)) - - def test_prompt_classifies_structurally_valid_error_envelope(self): - client = OpenCodeClient(self.settings) - errored = { - "info": { - "id": "msg_123", - "role": "assistant", - "error": { - "name": "APIError", - "data": { - "message": "provider quota exhausted", - "statusCode": 429, - "isRetryable": False, - "responseHeaders": {"authorization": "Bearer private-token"}, - "responseBody": "private provider response", - "metadata": {"url": "https://provider.example.invalid/request"}, - }, - }, - }, - "parts": [], - } - expected_code = "opencode-response-error:api:429:nonretryable" - self.assertIn(expected_code, BRIDGE_ERROR_CODES) - with patch.object(client, "_request", return_value=errored): - with self.assertRaises(BridgeError) as raised: - client.prompt("ses_123", "lawnmowerman", [{"type": "text", "text": "hello"}]) - self.assertEqual(raised.exception.error_code, expected_code) - for unsafe_value in ( - "provider quota exhausted", - "private-token", - "private provider response", - "provider.example.invalid", - "msg_123", - ): - self.assertNotIn(unsafe_value, str(raised.exception)) - self.assertNotIn(unsafe_value, raised.exception.error_code) - - def test_response_error_classifier_uses_only_bounded_fields(self): - cases = ( - ( - {"name": "ProviderAuthError", "data": {"providerID": "provider-private", "message": "credential detail"}}, - "opencode-response-error:provider-auth", - ), - ( - {"name": "ContextOverflowError", "data": {"message": "context detail", "responseBody": "private body"}}, - "opencode-response-error:context-overflow", - ), - ( - {"name": "MessageAbortedError", "data": {"message": "interrupt detail"}}, - "opencode-response-error:aborted", - ), - ( - {"name": "MessageOutputLengthError", "data": {}}, - "opencode-response-error:output-length", - ), - ( - {"name": "StructuredOutputError", "data": {"message": "schema detail", "retries": 2}}, - "opencode-response-error:structured-output", - ), - ( - {"name": "ContentFilterError", "data": {"message": "filter detail"}}, - "opencode-response-error:content-filter", - ), - ( - {"name": "APIError", "data": {"message": "detail", "statusCode": "429", "isRetryable": "false"}}, - "opencode-response-error:api:no-status:unknown", - ), - ( - {"name": "UnknownError", "data": {"message": "detail", "ref": "private-ref"}}, - "opencode-response-error:unknown", - ), - ("not-an-error", "opencode-response-error:unknown"), - ) - for error, expected_code in cases: - with self.subTest(expected_code=expected_code): - self.assertEqual(opencode_response_error_code(error), expected_code) - self.assertIn(expected_code, BRIDGE_ERROR_CODES) - self.assertEqual(classify_response_error({"name": "APIError", "data": None}), "api:no-status:unknown") - self.assertNotIn("credential detail", opencode_response_error_code(cases[0][0])) - self.assertNotIn("private-ref", opencode_response_error_code(cases[-2][0])) - - def test_prompt_rejects_unmapped_file_parts(self): - client = OpenCodeClient(self.settings) - with self.assertRaises(UnsupportedMedia): - client.prompt( - "ses_123", - "lawnmowerman", - [{"type": "file", "mime": "image/png", "filename": "image", "url": "data:image/png;base64,"}], - ) - - def test_opencode_request_failures_map_to_static_operation_and_category(self): - settings = replace(self.settings, opencode_base_url="https://opencode.example.invalid") - client = OpenCodeClient(settings) - failures = ( - (URLError(ConnectionRefusedError()), "transport"), - (URLError(TimeoutError()), "transport"), - (URLError("unknown url type"), "url-configuration"), - (ValueError("malformed url detail"), "url-configuration"), - (OSError("socket detail"), "os"), - (HTTPError("https://opencode.example.invalid/session", 404, "client detail", None, None), "http-4xx"), - (HTTPError("https://opencode.example.invalid/session", 429, "rate detail", None, None), "http-4xx"), - (HTTPError("https://opencode.example.invalid/session", 500, "server detail", None, None), "http-5xx"), - (HTTPError("https://opencode.example.invalid/session", 503, "unavailable detail", None, None), "http-5xx"), - (HTTPError("https://opencode.example.invalid/session", 302, "redirect detail", None, None), "unknown"), - ) - for failure, category in failures: - with self.subTest(failure=type(failure).__name__): - with patch("server.build_opener") as opener_factory: - opener_factory.return_value.open.side_effect = failure - with self.assertRaises(BridgeError) as raised: - client.create_session() - self.assertEqual( - raised.exception.error_code, - f"opencode-request-failed:session-create:{category}", - ) - self.assertEqual(str(raised.exception), "OpenCode request failed") - self.assertNotIn("detail", raised.exception.error_code) - self.assertNotIn("opencode.example.invalid", raised.exception.error_code) - - def test_prompt_request_failure_maps_to_prompt_operation(self): - settings = replace(self.settings, opencode_base_url="https://opencode.example.invalid") - client = OpenCodeClient(settings) - with patch("server.build_opener") as opener_factory: - opener_factory.return_value.open.side_effect = URLError(TimeoutError()) - with self.assertRaises(BridgeError) as raised: - client.prompt("ses_123", "lawnmowerman", [{"type": "text", "text": "hello"}]) - self.assertEqual( - raised.exception.error_code, - "opencode-request-failed:prompt:transport", - ) - self.assertNotIn("ses_123", raised.exception.error_code) - self.assertNotIn("opencode.example.invalid", str(raised.exception)) - - def test_classifier_preserves_unknown_fallback_for_unmatched_errors(self): - unmatched = ( - RuntimeError("unclassified detail"), - KeyError("odd detail"), - HTTPError("https://secret.example.invalid/x", 302, "redirect detail", None, None), - ) - for failure in unmatched: - with self.subTest(failure=type(failure).__name__): - self.assertEqual(classify_request_failure(failure), "unknown") - composed = opencode_request_error_code("prompt", classify_request_failure(failure)) - self.assertEqual(composed, "opencode-request-failed:prompt:unknown") - self.assertNotIn("detail", composed) - self.assertNotIn("secret.example.invalid", composed) - - def test_composed_request_codes_stay_within_bounded_taxonomy(self): - for operation in ("session-create", "prompt"): - for category in ("http-4xx", "http-5xx", "transport", "url-configuration", "os", "unknown"): - self.assertIn(opencode_request_error_code(operation, category), BRIDGE_ERROR_CODES) - self.assertNotIn("opencode-request-failed:wait:transport", BRIDGE_ERROR_CODES) - self.assertNotIn("opencode-request-failed:message-list:transport", BRIDGE_ERROR_CODES) - self.assertNotIn("opencode-request-failed:prompt-async:transport", BRIDGE_ERROR_CODES) - self.assertNotIn("opencode-request-failed:message:transport", BRIDGE_ERROR_CODES) - self.assertEqual( - opencode_request_error_code("no-such-operation", "no-such-category"), - "opencode-request-failed:unknown:unknown", - ) - self.assertEqual(opencode_request_error_code(None, None), "opencode-request-failed:unknown:unknown") - self.assertNotIn("no-such-operation", opencode_request_error_code("no-such-operation", "transport")) - - def test_bridge_error_defaults_to_safe_bounded_code(self): - self.assertEqual(BridgeError("worker configuration is incomplete").error_code, "opencode-response-invalid") - self.assertEqual(BridgeError("legacy detail", "opencode-failed").error_code, "opencode-response-invalid") - self.assertEqual(BridgeError("OpenCode prompt has no text", "opencode-input-invalid").error_code, "opencode-input-invalid") - self.assertEqual( - BridgeError("OpenCode response reported an error", "opencode-response-error:api:401:nonretryable").error_code, - "opencode-response-error:api:401:nonretryable", - ) - - def test_process_job_persists_bounded_error_code(self): - payload = {"from": "+15559999999", "to": "+15550000001", "body": "hello", "media": [], "agent": "lawnmowerman"} - identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) - self.store.enqueue("SM301", "lawnmowerman", identifier, payload) - job = self.store.claim() - self.store.remember_session("lawnmowerman", identifier, "ses_301") - client = OpenCodeClient(self.settings) - failure = BridgeError("OpenCode prompt has no text", "opencode-input-invalid") - with patch.object(client, "prompt", side_effect=failure): - with self.assertLogs("opencode-sms-bridge", level="WARNING") as captured: - process_job(self.settings, self.store, client, job) - telemetry = "\n".join(captured.output) - self.assertIn("event=job_failed stage=opencode channel=lawnmowerman error_code=opencode-input-invalid", telemetry) - self.assertNotIn("opencode-failed", telemetry) - self.assertNotIn(payload["body"], telemetry) - with sqlite3.connect(self.settings.state_path) as connection: - row = connection.execute("SELECT status, detail_code FROM jobs WHERE message_sid='SM301'").fetchone() - self.assertEqual(tuple(row), ("failed", "opencode-input-invalid")) - - def test_process_job_persists_static_request_failure_code_without_raw_detail(self): - settings = replace(self.settings, opencode_base_url="https://opencode.example.invalid") - payload = { - "from": "+15559999999", - "to": "+15550000001", - "body": "hello https://secret.example.invalid/token", - "media": [], - "agent": "lawnmowerman", - } - identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) - self.store.enqueue("SM302", "lawnmowerman", identifier, payload) - job = self.store.claim() - self.store.remember_session("lawnmowerman", identifier, "ses_302") - client = OpenCodeClient(settings) - with patch("server.build_opener") as opener_factory: - opener_factory.return_value.open.side_effect = URLError(ConnectionRefusedError("socket detail")) - with self.assertLogs("opencode-sms-bridge", level="WARNING") as captured: - process_job(settings, self.store, client, job) - telemetry = "\n".join(captured.output) - self.assertIn( - "event=job_failed stage=opencode channel=lawnmowerman error_code=opencode-request-failed:prompt:transport", - telemetry, - ) - for unsafe_value in ( - "socket detail", - "opencode.example.invalid", - "secret.example.invalid", - "ses_302", - payload["body"], - ): - self.assertNotIn(unsafe_value, telemetry) - with sqlite3.connect(self.settings.state_path) as connection: - row = connection.execute("SELECT status, detail_code FROM jobs WHERE message_sid='SM302'").fetchone() - self.assertEqual(tuple(row), ("failed", "opencode-request-failed:prompt:transport")) - - def test_worker_sends_through_messaging_service_without_from_number(self): - settings = replace( - self.settings, - mode="worker", - opencode_base_url="https://opencode.example.invalid", - twilio_api_key_sid="SKtestkey", - twilio_api_key_secret="testsecret", - twilio_messaging_service_sid="MGtestservice", - ) - payload = {"from": "+15559999999", "to": "+15550000001", "body": "hello", "media": [], "agent": "lawnmowerman"} - identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) - self.store.enqueue("SM401", "lawnmowerman", identifier, payload) - job = self.store.claim() - self.store.remember_session("lawnmowerman", identifier, "ses_401") - client = OpenCodeClient(settings) - with patch.object(client, "prompt", return_value=" Mow dry grass at noon. "): - with patch("server.Client") as client_factory: - with self.assertLogs("opencode-sms-bridge", level="INFO") as captured: - process_job(settings, self.store, client, job) - client_factory.assert_called_once_with("SKtestkey", "testsecret", "AC1234567890") - create = client_factory.return_value.messages.create - create.assert_called_once_with( - to="+15559999999", - body="Mow dry grass at noon.", - messaging_service_sid="MGtestservice", - ) - self.assertNotIn("from_", create.call_args.kwargs) - telemetry = "\n".join(captured.output) - self.assertIn("event=job_sent channel=lawnmowerman", telemetry) - for unsafe_value in (payload["from"], payload["to"], "MGtestservice", "Mow dry grass at noon.", "ses_401"): - self.assertNotIn(unsafe_value, telemetry) - with sqlite3.connect(self.settings.state_path) as connection: - row = connection.execute("SELECT status, detail_code FROM jobs WHERE message_sid='SM401'").fetchone() - self.assertEqual(tuple(row), ("sent", "ok")) - - def test_worker_keeps_direct_from_number_when_messaging_service_is_unset(self): - settings = replace( - self.settings, - mode="worker", - opencode_base_url="https://opencode.example.invalid", - twilio_api_key_sid="SKtestkey", - twilio_api_key_secret="testsecret", - ) - self.assertEqual(settings.twilio_messaging_service_sid, "") - payload = {"from": "+15559999999", "to": "+15550000003", "body": "hello", "media": [], "agent": "homesteader"} - identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) - self.store.enqueue("SM402", "homesteader", identifier, payload) - job = self.store.claim() - self.store.remember_session("homesteader", identifier, "ses_402") - client = OpenCodeClient(settings) - with patch.object(client, "prompt", return_value="raised beds ready"): - with patch("server.Client") as client_factory: - with self.assertLogs("opencode-sms-bridge", level="INFO") as captured: - process_job(settings, self.store, client, job) - create = client_factory.return_value.messages.create - create.assert_called_once_with(to="+15559999999", from_="+15550000003", body="raised beds ready") - self.assertNotIn("messaging_service_sid", create.call_args.kwargs) - telemetry = "\n".join(captured.output) - self.assertIn("event=job_sent channel=homesteader", telemetry) - for unsafe_value in (payload["from"], payload["to"], "raised beds ready", "ses_402"): - self.assertNotIn(unsafe_value, telemetry) - with sqlite3.connect(self.settings.state_path) as connection: - row = connection.execute("SELECT status, detail_code FROM jobs WHERE message_sid='SM402'").fetchone() - self.assertEqual(tuple(row), ("sent", "ok")) - - def test_worker_marks_delivery_unknown_and_hides_provider_detail_on_send_failure(self): - settings = replace( - self.settings, - mode="worker", - opencode_base_url="https://opencode.example.invalid", - twilio_api_key_sid="SKtestkey", - twilio_api_key_secret="testsecret", - twilio_messaging_service_sid="MGtestservice", - ) - payload = {"from": "+15559999999", "to": "+15550000002", "body": "hello", "media": [], "agent": "grillmaster"} - identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) - self.store.enqueue("SM403", "grillmaster", identifier, payload) - job = self.store.claim() - self.store.remember_session("grillmaster", identifier, "ses_403") - client = OpenCodeClient(settings) - with patch.object(client, "prompt", return_value="preheat the grill"): - with patch("server.Client") as client_factory: - client_factory.return_value.messages.create.side_effect = RuntimeError( - "provider detail 21606 credential" - ) - with self.assertLogs("opencode-sms-bridge", level="WARNING") as captured: - process_job(settings, self.store, client, job) - create = client_factory.return_value.messages.create - create.assert_called_once_with( - to="+15559999999", - body="preheat the grill", - messaging_service_sid="MGtestservice", - ) - self.assertNotIn("from_", create.call_args.kwargs) - telemetry = "\n".join(captured.output) - self.assertIn("event=job_delivery_unknown stage=twilio channel=grillmaster", telemetry) - for unsafe_value in ( - "provider detail", - "21606", - "credential", - payload["from"], - payload["to"], - "MGtestservice", - "preheat the grill", - "ses_403", - ): - self.assertNotIn(unsafe_value, telemetry) - with sqlite3.connect(self.settings.state_path) as connection: - row = connection.execute("SELECT status, detail_code FROM jobs WHERE message_sid='SM403'").fetchone() - self.assertEqual(tuple(row), ("delivery-unknown", "twilio-send-failed")) - - def test_image_sanitization_removes_exif(self): - image = Image.new("RGB", (8, 8), color="red") - original = tempfile.SpooledTemporaryFile() - image.save(original, "JPEG") - original.seek(0) - sanitized, mime = sanitize_image(original.read(), "image/jpeg") - self.assertEqual(mime, "image/jpeg") - reopened = Image.open(__import__("io").BytesIO(sanitized)) - self.assertFalse(reopened.getexif()) - self.assertTrue(base64.b64encode(sanitized)) - - -if __name__ == "__main__": - unittest.main() From 58f70e793f9767eb908ae0ec827ca9b376d24f02 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 21:36:05 -0600 Subject: [PATCH 4/6] chore(retire-sms-bridge): delete opencode-sms-bridge/test_response_error_classifier.py --- .../test_response_error_classifier.py | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 opencode-sms-bridge/test_response_error_classifier.py diff --git a/opencode-sms-bridge/test_response_error_classifier.py b/opencode-sms-bridge/test_response_error_classifier.py deleted file mode 100644 index 445be23..0000000 --- a/opencode-sms-bridge/test_response_error_classifier.py +++ /dev/null @@ -1,31 +0,0 @@ -import unittest - -from server import BRIDGE_ERROR_CODES, classify_response_error, opencode_response_error_code - - -class ResponseErrorClassifierTests(unittest.TestCase): - def test_malformed_error_names_fall_back_without_raising(self): - for name in ([], {}, 1, None): - with self.subTest(name_type=type(name).__name__): - error = {"name": name, "data": {"message": "private detail"}} - self.assertEqual(classify_response_error(error), "unknown") - self.assertEqual(opencode_response_error_code(error), "opencode-response-error:unknown") - - def test_api_status_and_retryability_are_bounded(self): - cases = ( - ({"statusCode": 100, "isRetryable": True}, "opencode-response-error:api:100:retryable"), - ({"statusCode": 599, "isRetryable": False}, "opencode-response-error:api:599:nonretryable"), - ({"statusCode": 99, "isRetryable": False}, "opencode-response-error:api:no-status:nonretryable"), - ({"statusCode": 600, "isRetryable": "false"}, "opencode-response-error:api:no-status:unknown"), - ) - for data, expected in cases: - with self.subTest(data=data): - error = {"name": "APIError", "data": {"message": "private detail", **data}} - code = opencode_response_error_code(error) - self.assertEqual(code, expected) - self.assertIn(code, BRIDGE_ERROR_CODES) - self.assertNotIn("private detail", code) - - -if __name__ == "__main__": - unittest.main() From 2d8e2a8cec41f0b95820ff050752dd1e1c5333e5 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 21:36:15 -0600 Subject: [PATCH 5/6] chore(retire-sms-bridge): delete opencode-sms-bridge/README.md --- opencode-sms-bridge/README.md | 47 ----------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 opencode-sms-bridge/README.md diff --git a/opencode-sms-bridge/README.md b/opencode-sms-bridge/README.md deleted file mode 100644 index f579475..0000000 --- a/opencode-sms-bridge/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# OpenCode SMS bridge - -> **Deprecated 2026-09-19.** This image is retired from build and publication. Every `make` selection target excludes it, and CI rejects a manual dispatch that names it, so no new `ghcr.io/makeitworkcloud/opencode-sms-bridge` tags will be pushed. Existing published tags and attestations remain available unchanged. The source, `Containerfile`, and tests are retained for history and local test runs. - -`opencode-sms-bridge` is the private Twilio SMS/MMS ingress and worker for four fixed, existing primary OpenCode agents: `lawnmowerman`, `grillmaster`, `homesteader`, and `homerepair`. It is not a general Twilio API proxy and never accepts an agent, model, tool, session, or routing choice from a caller. - -## Runtime modes - -One single-replica pod runs two copies of this image: - -- `BRIDGE_MODE=ingress` exposes `POST /twilio/inbound` and `GET /healthz`. It validates the complete form-encoded Twilio signature against `CANONICAL_WEBHOOK_URL`, verifies the configured account, destination-number mapping, approved sender, and message SID, then writes one encrypted durable job. -- `BRIDGE_MODE=worker` exposes a loopback-only health endpoint on port `8081`. It claims queued work, downloads Twilio media only from configured HTTPS Twilio hosts, validates content type, magic bytes, size, and audio duration, then calls the fixed OpenCode agent session and sends one bounded reply through Twilio. - -The state database stores encrypted message payloads and HMAC sender identifiers. It deliberately marks uncertain outbound sends as `delivery-unknown` rather than retrying and risking duplicate SMS. The first release is intentionally single replica; do not scale it without replacing SQLite queue/session coordination. - -## PII-safe operational telemetry - -The bridge emits structured lifecycle events to container logs without access logs or payload data. Events may include the fixed agent channel, media count, stage, and a bounded reason; they never include phone numbers, message SID values, message bodies, media URLs, sender hashes, session IDs, credentials, or provider exception detail. - -Ingress events distinguish rejected signatures, ignored account/destination/sender combinations, queued messages, and duplicates. Worker events distinguish claimed jobs, unsupported media, OpenCode failures, state-transition skips, uncertain Twilio delivery, and successful sends. The persistent encrypted queue remains authoritative for detailed recovery; do not log or export its contents. - -## Required configuration - -All required values come from cluster-owned Secret mounts or safe chart values. Do not place values in this repository or chart `values.yaml`. - -| Setting | Mode | Purpose | -| --- | --- | --- | -| `ROUTING_CONFIG_PATH` | both | JSON Secret containing the Twilio account ID, approved senders, and exactly four destination-to-primary-agent mappings: one each for `lawnmowerman`, `grillmaster`, `homesteader`, and `homerepair`. | -| `STATE_PATH`, `STATE_ENCRYPTION_KEY`, `SENDER_HASH_KEY` | both | RWO PVC location and independent encryption/HMAC keys. | -| `CANONICAL_WEBHOOK_URL`, `TWILIO_AUTH_TOKEN` | both | Canonical public URL for signature validation and Twilio credential for protected media downloads. | -| `OPENCODE_API_BASE_URL`, `OPENCODE_SERVER_PASSWORD` | worker | Private OpenCode HTTP API endpoint and Basic-auth credential. | -| `TWILIO_API_KEY_SID`, `TWILIO_API_KEY_SECRET` | worker | Least-privilege Twilio API Key used only for outbound replies. | -| `WHISPER_URL` | worker, audio MMS | A local Whisper-compatible transcription endpoint. | - -Only a signed webhook from a configured approved sender is queued or answered. The bridge invokes the existing primary agent ID, so it receives that agent's normal OpenCode configuration, permissions, MCP availability, and shared instructions. The source allowlist is an ingress identity gate, not standing authorization: existing explicit-confirmation requirements still apply to any mutation requested over SMS. - -`OPENCODE_IMAGE_PARTS_ENABLED` defaults to `false`. Set it to `true` only after a configured image-capable OpenCode model and the deployed OpenCode file-part API have been functionally verified. The bridge refuses unsupported image or audio media rather than forwarding unvalidated bytes. - -## Optional Messaging Service delivery - -`TWILIO_MESSAGING_SERVICE_SID` is an optional worker setting holding a Twilio Messaging Service SID. The SID is a non-secret identifier, not a credential, so it may come from safe chart values while the Twilio API Key credentials stay Secret-mounted. When the setting is non-empty, the worker submits each reply through `messages.create` with exactly `to`, `body`, and `messaging_service_sid`; it never passes a `from_` number on that path. When the setting is empty or unset, the worker keeps the exact existing direct-send behavior and replies `from_` the channel's configured destination number. The choice is fixed per deployment, never per message, and the setting is not added to the worker's required-configuration gate. - -Configuring a Messaging Service changes only how replies are submitted to Twilio. A2P 10DLC campaign registration, toll-free verification, and associating the four channel numbers with the Messaging Service are separate manual Twilio-console operations owned by the operator; this bridge neither performs nor validates that association, and an approved-sender end-to-end SMS test remains the delivery gate. - -## Ownership and delivery - -`makeitworkcloud/images` owns this source image. Its `main` workflow publishes `ghcr.io/makeitworkcloud/opencode-sms-bridge` after merge. `makeitworkcloud/charts` owns the portable Deployment and configuration wiring; `makeitworkcloud/kustomize-cluster` owns the state PVC, Service, `TunnelBinding`, and SOPS-encrypted Secrets. Publication, GitOps selection, reconciliation, health, Twilio webhook configuration, and functional messaging are separate delivery stages. From 7e3aa214deac847d57c266f77336cd68f661b503 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 21:36:28 -0600 Subject: [PATCH 6/6] chore(retire-sms-bridge): drop redundant RETIRED_IMAGES exclusion The opencode-sms-bridge directory no longer exists, so find-based image discovery no longer needs to filter it out. The generic workflow_dispatch image-input whitelist validation in buildah.yml is unchanged. --- Makefile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Makefile b/Makefile index 3656bcf..fc854dc 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,6 @@ SHELL := /bin/bash IMAGES := $(shell find . -maxdepth 2 -name Containerfile -printf '%h\n' | cut -d'/' -f2 | sort -u) -RETIRED_IMAGES := opencode-sms-bridge -IMAGES := $(filter-out $(RETIRED_IMAGES),$(IMAGES)) help: @echo "Available targets:"