diff --git a/opencode-sms-bridge/README.md b/opencode-sms-bridge/README.md index 7cdc02b..e7766b0 100644 --- a/opencode-sms-bridge/README.md +++ b/opencode-sms-bridge/README.md @@ -1,6 +1,6 @@ # OpenCode SMS bridge -`opencode-sms-bridge` is the private Twilio SMS/MMS ingress and worker for the four fixed mobile OpenCode agents. It is not a general Twilio API proxy and never accepts an agent, model, tool, session, or routing choice from a caller. +`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 @@ -17,13 +17,15 @@ All required values come from cluster-owned Secret mounts or safe chart values. | Setting | Mode | Purpose | | --- | --- | --- | -| `ROUTING_CONFIG_PATH` | both | JSON Secret containing the Twilio account ID, approved senders, and exactly four destination-to-fixed-agent mappings. | +| `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. ## Ownership and delivery diff --git a/opencode-sms-bridge/server.py b/opencode-sms-bridge/server.py index 90c16bb..2b4e11a 100644 --- a/opencode-sms-bridge/server.py +++ b/opencode-sms-bridge/server.py @@ -28,9 +28,7 @@ LOG = logging.getLogger("opencode-sms-bridge") -MOBILE_AGENTS = frozenset( - {"lawnmowerman-sms", "grillmaster-sms", "homesteader-sms", "homerepair-sms"} -) +CHANNEL_AGENTS = frozenset({"lawnmowerman", "grillmaster", "homesteader", "homerepair"}) EMPTY_TWIML = '' MAX_WEBHOOK_BYTES = 64 * 1024 @@ -161,8 +159,8 @@ def load_routing(path: Path) -> Routing: 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()) != MOBILE_AGENTS: - raise BridgeError("routing configuration must map four numbers to the four fixed mobile agents") + 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) diff --git a/opencode-sms-bridge/test_server.py b/opencode-sms-bridge/test_server.py index 70238b7..252a325 100644 --- a/opencode-sms-bridge/test_server.py +++ b/opencode-sms-bridge/test_server.py @@ -10,7 +10,17 @@ from PIL import Image from twilio.request_validator import RequestValidator -from server import Routing, SQLiteStore, Settings, create_ingress_app, normalize_e164, sanitize_image, sender_hash +from server import ( + BridgeError, + Routing, + SQLiteStore, + Settings, + create_ingress_app, + load_routing, + normalize_e164, + sanitize_image, + sender_hash, +) class BridgeTests(unittest.TestCase): @@ -21,10 +31,10 @@ def setUp(self): account_sid="AC1234567890", approved_senders=frozenset({"+15559999999"}), channels={ - "+15550000001": "lawnmowerman-sms", - "+15550000002": "grillmaster-sms", - "+15550000003": "homesteader-sms", - "+15550000004": "homerepair-sms", + "+15550000001": "lawnmowerman", + "+15550000002": "grillmaster", + "+15550000003": "homesteader", + "+15550000004": "homerepair", }, ) self.settings = Settings( @@ -59,11 +69,31 @@ def test_e164_rejects_noncanonical_values(self): 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-sms"} + 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-sms", identifier, payload)) - self.assertFalse(self.store.enqueue("SM123", "lawnmowerman-sms", identifier, payload)) + 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)