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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions opencode-sms-bridge/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
8 changes: 3 additions & 5 deletions opencode-sms-bridge/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<?xml version="1.0" encoding="UTF-8"?><Response/>'
MAX_WEBHOOK_BYTES = 64 * 1024

Expand Down Expand Up @@ -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)


Expand Down
46 changes: 38 additions & 8 deletions opencode-sms-bridge/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Loading