From bf7efb0209764398997ca9def731e021d8611e0a Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Fri, 4 Sep 2026 21:24:08 -0600 Subject: [PATCH 1/2] feat(sms-bridge): add PII-safe lifecycle telemetry --- opencode-sms-bridge/README.md | 6 ++++++ opencode-sms-bridge/server.py | 18 ++++++++++++++++-- opencode-sms-bridge/test_server.py | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/opencode-sms-bridge/README.md b/opencode-sms-bridge/README.md index e7766b0..2de7a07 100644 --- a/opencode-sms-bridge/README.md +++ b/opencode-sms-bridge/README.md @@ -11,6 +11,12 @@ One single-replica pod runs two copies of this image: 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`. diff --git a/opencode-sms-bridge/server.py b/opencode-sms-bridge/server.py index 2b4e11a..9cec0c2 100644 --- a/opencode-sms-bridge/server.py +++ b/opencode-sms-bridge/server.py @@ -342,12 +342,20 @@ async def inbound(request: FastAPIRequest) -> Response: 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 - store.enqueue(message_sid, channel, source_id, payload) + 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 @@ -538,25 +546,30 @@ def process_job(settings: Settings, store: SQLiteStore, client: OpenCodeClient, session_id = store.remember_session(job["channel"], job["sender_hash"], client.create_session(job["payload"]["agent"])) response = client.prompt(session_id, 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: + LOG.warning("event=job_failed stage=opencode channel=%s", job["channel"]) store.finish(job["message_sid"], "failed", "opencode-failed") 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) 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", "twilio-send-failed") return store.finish(job["message_sid"], "sent", "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 = FastAPI(docs_url=None, redoc=None, openapi_url=None) app.state.last_cycle = 0.0 @app.on_event("startup") @@ -568,6 +581,7 @@ async def loop() -> None: 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()) diff --git a/opencode-sms-bridge/test_server.py b/opencode-sms-bridge/test_server.py index 252a325..50785c6 100644 --- a/opencode-sms-bridge/test_server.py +++ b/opencode-sms-bridge/test_server.py @@ -105,7 +105,12 @@ def test_ingress_accepts_signed_approved_message_once(self): 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} - self.assertEqual(client.post("/twilio/inbound", data=form, headers=headers).status_code, 200) + 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()) @@ -114,8 +119,13 @@ def test_ingress_ignores_unapproved_sender_after_signature_validation(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)) - response = client.post("/twilio/inbound", data=form, headers={"X-Twilio-Signature": signature}) + 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) self.assertIsNone(self.store.claim()) def test_image_sanitization_removes_exif(self): From 4d2bea63ae4229e227220c3ff1cfcce0d4c083a1 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Fri, 4 Sep 2026 21:28:01 -0600 Subject: [PATCH 2/2] fix(sms-bridge): retain disabled Redoc route --- opencode-sms-bridge/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencode-sms-bridge/server.py b/opencode-sms-bridge/server.py index 9cec0c2..4166ca1 100644 --- a/opencode-sms-bridge/server.py +++ b/opencode-sms-bridge/server.py @@ -569,7 +569,7 @@ def process_job(settings: Settings, store: SQLiteStore, client: OpenCodeClient, def create_worker_app(settings: Settings, store: SQLiteStore) -> FastAPI: settings.worker_ready() client = OpenCodeClient(settings) - app = FastAPI(docs_url=None, redoc=None, openapi_url=None) + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app.state.last_cycle = 0.0 @app.on_event("startup")