fix(sms): scope TWILIO_PHONE_NUMBER per profile

__init__ and _standalone_send read TWILIO_PHONE_NUMBER via raw os.getenv,
right next to the already-scope-aware TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN
(_get_scoped_secret, established by merged PR #76664). Under
gateway.multiplex_profiles, a secondary profile with its own Twilio account
would silently send replies from the default profile's bridged
TWILIO_PHONE_NUMBER instead -- Twilio rejects a from-number not owned by
that profile's account (auth failure), or the message gets attributed to
the wrong sender.

__init__'s path is currently unreachable via normal secondary-profile
adapter construction ("sms" is in gateway/config.py's
PORT_BINDING_PLATFORM_VALUES, so _start_one_profile_adapters refuses to
construct a port-binding platform for a secondary profile) -- fixed anyway
for consistency with its sibling reads and to avoid a latent bug if that
guard's scope ever changes. _standalone_send's path IS reachable: it's the
generic cron/home-channel out-of-process delivery hook, which runs inside
_profile_runtime_scope.

Swaps both reads to the adapter's own existing _get_scoped_secret() helper,
matching its sibling account_sid/auth_token reads exactly -- no new
mechanism needed.

Adds a TestMultiplexProfileScope test class to tests/gateway/test_sms.py
mirroring the established Buzz/Discord/Telegram/WhatsApp/LINE/DingTalk/
Teams coverage for this bug class.

(cherry picked from commit 2aeb3148367ecf87598d4458221a5fbf30c0af01)
This commit is contained in:
nftpoetrist
2026-09-01 22:15:46 +03:00
committed by Teknium
parent 08830efd96
commit 8099745a4e
2 changed files with 45 additions and 2 deletions

View File

@@ -90,7 +90,9 @@ class SmsAdapter(BasePlatformAdapter):
super().__init__(config, Platform.SMS)
self._account_sid: str = _get_scoped_secret("TWILIO_ACCOUNT_SID", "")
self._auth_token: str = _get_scoped_secret("TWILIO_AUTH_TOKEN", "")
self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "")
# Scoped like the sibling reads above: a secondary profile must not send from the default
# profile's TWILIO_PHONE_NUMBER (#98738 class).
self._from_number: str = _get_scoped_secret("TWILIO_PHONE_NUMBER", "")
self._webhook_port: int = int(os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)))
self._webhook_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)
self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip()
@@ -297,7 +299,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f
if not AIOHTTP_AVAILABLE:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "")
from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
from_number = _get_scoped_secret("TWILIO_PHONE_NUMBER", "") # scoped like account_sid: never the default's number
if not account_sid or not auth_token or not from_number:
return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}
message = _strip_markdown_for_sms(message)

View File

@@ -321,3 +321,44 @@ class TestWebhookSignatureEnforcement:
request = self._mock_request(oversized, content_length=None)
resp = await adapter._handle_webhook(request)
assert resp.status == 413
class TestMultiplexProfileScope:
"""TWILIO_PHONE_NUMBER must resolve through the same profile scope as the Twilio secrets: under
multiplex, os.environ holds the DEFAULT profile's number."""
@pytest.fixture(autouse=True)
def _default_profile_env(self, monkeypatch):
from agent.secret_scope import set_multiplex_active
for key, value in (("TWILIO_ACCOUNT_SID", "AC-default"), ("TWILIO_AUTH_TOKEN", "token-default"),
("TWILIO_PHONE_NUMBER", "+15550000000")):
monkeypatch.setenv(key, value)
set_multiplex_active(True)
yield
set_multiplex_active(False)
def test_init_pairs_secondary_secrets_with_secondary_from_number(self):
from agent.secret_scope import reset_secret_scope, set_secret_scope
from plugins.platforms.sms.adapter import SmsAdapter
token = set_secret_scope({"TWILIO_ACCOUNT_SID": "AC-profile", "TWILIO_AUTH_TOKEN": "token-profile",
"TWILIO_PHONE_NUMBER": "+15551112222"})
try:
adapter = SmsAdapter(PlatformConfig(enabled=True))
finally:
reset_secret_scope(token)
assert (adapter._account_sid, adapter._from_number) == ("AC-profile", "+15551112222")
@pytest.mark.asyncio
async def test_standalone_send_without_own_number_fails_closed(self):
"""A secondary lacking its own from-number must NOT send from the default's +15550000000."""
from agent.secret_scope import reset_secret_scope, set_secret_scope
from plugins.platforms.sms.adapter import _standalone_send
token = set_secret_scope({"TWILIO_ACCOUNT_SID": "AC-profile", "TWILIO_AUTH_TOKEN": "token-profile"})
try:
result = await _standalone_send(PlatformConfig(enabled=True), "+15559998888", "hi")
finally:
reset_secret_scope(token)
assert "TWILIO_PHONE_NUMBER required" in result["error"]