dsh-live-voice 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/deploy/attendee/README.md +34 -0
  2. package/deploy/attendee/audio_bridge.py +186 -0
  3. package/deploy/attendee/compose.dsh-audio.yaml +54 -0
  4. package/deploy/attendee/cross_runtime_fixture.py +46 -0
  5. package/deploy/attendee/test_audio_bridge.py +170 -0
  6. package/lib/asr/asr.worker.js +1 -1
  7. package/lib/asr/manifest.json +1 -1
  8. package/lib/client.js +14848 -11796
  9. package/lib/server.js +1051 -14
  10. package/lib/vad/manifest.json +2 -2
  11. package/lib/vad/vad.worker.js +1 -1
  12. package/package.json +5 -3
  13. package/src/app/ARCHITECTURE.md +2 -0
  14. package/src/app/client/apply.tsx +74 -15
  15. package/src/app/client/i18n/catalogs/base.ts +87 -1
  16. package/src/app/client/i18n/catalogs/en.ts +119 -3
  17. package/src/app/client/i18n/catalogs/es.ts +120 -4
  18. package/src/app/client/i18n/catalogs/fr.ts +121 -4
  19. package/src/app/client/i18n/catalogs/hi.ts +118 -4
  20. package/src/app/client/i18n/catalogs/pt-BR.ts +119 -3
  21. package/src/app/client/i18n/catalogs/zh.ts +114 -3
  22. package/src/app/server/apply.ts +5 -0
  23. package/src/app/server/attendeeConnection.ts +133 -0
  24. package/src/app/server/attendeeCredentials.ts +144 -0
  25. package/src/app/server/attendeeRelay.ts +104 -0
  26. package/src/app/server/attendeeRoutes.ts +476 -0
  27. package/src/app/server/chatterboxRoutes.ts +73 -0
  28. package/src/modules/conversation/ARCHITECTURE.md +1 -0
  29. package/src/modules/conversation/components/AttendeeControls.tsx +376 -0
  30. package/src/modules/conversation/components/AutoPlaybackToggle.tsx +32 -13
  31. package/src/modules/conversation/components/ConversationControls.tsx +8 -5
  32. package/src/modules/conversation/components/ConversationStatusBar.tsx +18 -2
  33. package/src/modules/conversation/components/DevicePopover.tsx +31 -0
  34. package/src/modules/conversation/components/MeetingControls.tsx +102 -15
  35. package/src/modules/conversation/components/MicrophoneButton.tsx +1 -1
  36. package/src/modules/conversation/components/RecoveryFeedback.tsx +39 -0
  37. package/src/modules/conversation/components/SpeechStatusBar.tsx +15 -3
  38. package/src/modules/conversation/components/conversationStatus.ts +7 -0
  39. package/src/modules/conversation/components/createConversationComponents.tsx +6 -1
  40. package/src/modules/conversation/models/AttendeeClient.ts +742 -0
  41. package/src/modules/conversation/models/AttendeeHub.ts +98 -0
  42. package/src/modules/conversation/models/ParticipantPcmMeter.ts +58 -0
  43. package/src/modules/conversation/models/attendeeProtocol.ts +53 -0
  44. package/src/modules/conversation/models/meeting.ts +244 -41
  45. package/src/modules/conversation/models/meetingTranscript.ts +14 -3
  46. package/src/modules/core/ARCHITECTURE.md +2 -0
  47. package/src/modules/core/coordinator.ts +305 -22
  48. package/src/modules/core/engineSelection.ts +2 -2
  49. package/src/modules/core/networkFailure.ts +10 -0
  50. package/src/modules/core/recovery.ts +26 -0
  51. package/src/modules/core/settings.ts +16 -1
  52. package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +1 -1
  53. package/src/modules/recognition/engines/browser/transcribeRecording.ts +241 -0
  54. package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +5 -1
  55. package/src/modules/recognition/engines/whisper/whisperRecognitionHost.ts +5 -2
  56. package/src/modules/recognition/recordingTranscriber.ts +143 -0
  57. package/src/modules/recording/ARCHITECTURE.md +12 -1
  58. package/src/modules/recording/RecordingRecognition.ts +95 -16
  59. package/src/modules/recording/repository.ts +279 -16
  60. package/src/modules/recording/requestMetadata.ts +30 -0
  61. package/src/modules/recording/storage.ts +61 -0
  62. package/src/modules/recording/types.ts +63 -1
  63. package/src/modules/settings/ARCHITECTURE.md +2 -0
  64. package/src/modules/settings/components/AudioDeviceField.tsx +43 -0
  65. package/src/modules/settings/models/browserVoiceSelection.ts +15 -0
  66. package/src/modules/settings/sections/conversation/ConversationSettingsSection.tsx +3 -0
  67. package/src/modules/settings/sections/conversation/MeetingSourceSettings.tsx +14 -2
  68. package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +4 -11
  69. package/src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx +0 -2
  70. package/src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx +6 -20
  71. package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +5 -0
  72. package/src/modules/speak/ARCHITECTURE.md +3 -1
  73. package/src/modules/speak/chatterbox/ChatterboxVoiceSettings.tsx +60 -0
  74. package/src/modules/speak/engines/audio/HostAudioEngine.ts +105 -38
  75. package/src/modules/speak/engines/chatterbox/ChatterboxHttpHost.ts +130 -0
  76. package/src/modules/speak/engines/chatterbox/ChatterboxSpeakingEngine.ts +14 -0
  77. package/src/modules/speak/index.ts +1 -0
  78. package/src/shared/ARCHITECTURE.md +2 -0
  79. package/src/shared/design-system/feedback/ContextPopover.tsx +107 -0
  80. package/src/styles/index.ts +6 -1
@@ -0,0 +1,34 @@
1
+ # Compose-managed Attendee audio
2
+
3
+ These deployment assets are included in the Live Voice plugin. They complement the existing Attendee development Compose stack; they are not a separate package, host daemon or public proxy.
4
+
5
+ ## Start
6
+
7
+ Set `DSH_LIVE_VOICE_DEPLOY` to this directory’s absolute path. Append `-f "$DSH_LIVE_VOICE_DEPLOY/compose.dsh-audio.yaml"` to the same Compose command you already use for Attendee, preserving every ARM64/FakeCloud override, then run `up -d`. The default image is `attendee-local:arm64`; set `DSH_ATTENDEE_IMAGE` to a compatible existing Attendee image if needed. The image must include Python, cryptography and websockets 14.1. No new image download is required when that image already exists.
8
+
9
+ Compose provisions deployment-local TLS material in two named volumes and configures the worker’s `SSL_CERT_FILE`. The private key is mounted only in the initializer/relay, never the worker. A loopback-only descriptor (18766) gives the local plugin the public certificate; the host verifies TLS on its receiving connection (18765). The worker verifies the internal `dsh-audio-bridge` name with the public bundle. This trusts the local machine/Docker environment, not a hostile process that can impersonate loopback services. Never forward these ports. The relay validates the project API key against `attendee-app-local` before providing an ephemeral producer URL. The request uses the fixed `localhost:8000` Host header expected by the upstream development Django configuration, not a caller-controlled host. The loopback ports are 18765/18766, avoiding the memory service’s common 8765 port.
10
+
11
+ In DSH, connect to Attendee without setting an advanced receiver URL. The plugin discovers the bridge and coordinates its secret URL automatically when creating a bot. Disconnecting DSH only revokes reception; explicitly use Leave meeting for the bot to depart.
12
+
13
+ ## Lifecycle and limits
14
+
15
+ - No Docker socket, arbitrary proxy destination, DSH credentials or host filesystem is exposed by the relay.
16
+ - One consumer and one producer at a time; 128 KB messages, bounded WebSocket queues, no compression or replay. Slow/invalid/disconnected sessions close rather than silently archive/drop audio.
17
+ - The certificate lasts 90 days and is reused until near expiry. The initializer renews it on its next execution; recreate the initializer and bridge together for renewal. Mounted public roots are used for new worker TLS connections. Restart dependent workers outside meetings if required. Continuous unattended renewal is not implemented.
18
+ - Recreating workers to apply the override interrupts their active bots. Do not apply it during a meeting.
19
+ - Stop with the same Compose file set. Removing named volumes deletes TLS material; regenerate it by rerunning the initializer. Do not remove the existing Postgres/FakeCloud volumes.
20
+ - This override targets upstream development service names and the same-machine Docker setup. Remote Docker, custom service names and arbitrary bridge ports are not automatically discovered.
21
+
22
+ ## Isolated test
23
+
24
+ From the plugin checkout, with its compatible Attendee image already built:
25
+
26
+ ```sh
27
+ docker run --rm --network none --entrypoint python -v "$PWD/packages/live-voice/deploy/attendee:/dsh:ro" attendee-local:arm64 /dsh/test_audio_bridge.py
28
+ ```
29
+
30
+ This checks TLS, certificate/bundle reuse, capability rejection, binary/oversize rejection, receive-only policy, forwarding and revocation in a disposable container. A separate HTTP test exercises the real credential-verification function against a local fixture API, including 401, malformed headers and redirect refusal. The WebSocket test stubs only its API admission callback. It does not start a bot or contact Meet. See the repository Attendee guide for full runtime acceptance limits.
31
+
32
+ ## Cross-runtime verification
33
+
34
+ From the repository root, run `node scripts/test-attendee-relay.mjs` to connect the actual Node host consumer to the actual Python relay in a disposable container. It publishes random loopback ports, sends PCM from two UUIDs, and checks producer revocation when the receiver closes. Its authentication callback accepts only a fixture key; it never creates a bot, calls Meet, imports host certificates or recreates production services. The runner stops its exact named container on failure and removes it on normal exit. It requires Docker and the existing compatible image, so it is an explicit integration check rather than part of `npm test`.
@@ -0,0 +1,186 @@
1
+ """DSH-local audio relay. No shell, generic proxy, recordings, or audio logs."""
2
+ import asyncio
3
+ import datetime
4
+ import hmac
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ import secrets
10
+ import ssl
11
+ import urllib.error
12
+ import urllib.request
13
+ from http import HTTPStatus
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from threading import Thread
16
+ from urllib.parse import urlsplit, parse_qs
17
+ from websockets.exceptions import ConnectionClosed
18
+
19
+ MAX_MESSAGE = 128000
20
+ PUBLIC = Path(os.environ.get("BRIDGE_PUBLIC", "/bridge-public"))
21
+ PRIVATE = Path(os.environ.get("BRIDGE_PRIVATE", "/bridge-private"))
22
+
23
+
24
+ def provision():
25
+ from cryptography import x509
26
+ from cryptography.hazmat.primitives import hashes, serialization
27
+ from cryptography.hazmat.primitives.asymmetric import rsa
28
+ from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
29
+ import ipaddress
30
+ PUBLIC.mkdir(parents=True, exist_ok=True)
31
+ PRIVATE.mkdir(parents=True, exist_ok=True)
32
+ if (PUBLIC / "server.pem").exists() and (PRIVATE / "server.key").exists():
33
+ try:
34
+ cert = x509.load_pem_x509_certificate((PUBLIC / "server.pem").read_bytes())
35
+ existing_key = serialization.load_pem_private_key((PRIVATE / "server.key").read_bytes(), password=None)
36
+ same_key = existing_key.public_key().public_numbers() == cert.public_key().public_numbers()
37
+ if same_key and cert.not_valid_after_utc > datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=2):
38
+ # Repair/rebuild the public bundle if an earlier initializer was interrupted.
39
+ roots = Path("/etc/ssl/certs/ca-certificates.crt").read_bytes()
40
+ (PUBLIC / "ca-bundle.pem").write_bytes(roots + b"\n" + (PUBLIC / "server.pem").read_bytes())
41
+ (PRIVATE / "server.key").chmod(0o600)
42
+ return
43
+ except (ValueError, OSError):
44
+ pass
45
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
46
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "DSH local Attendee audio")])
47
+ now = datetime.datetime.now(datetime.timezone.utc)
48
+ cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
49
+ .serial_number(x509.random_serial_number()).not_valid_before(now - datetime.timedelta(minutes=5))
50
+ .not_valid_after(now + datetime.timedelta(days=90))
51
+ .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
52
+ .add_extension(x509.SubjectAlternativeName([x509.DNSName("dsh-audio-bridge"), x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
53
+ .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
54
+ .sign(key, hashes.SHA256()))
55
+ path = PRIVATE / "server.key"
56
+ path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
57
+ path.chmod(0o600)
58
+ pem = cert.public_bytes(serialization.Encoding.PEM)
59
+ (PUBLIC / "server.pem").write_bytes(pem)
60
+ # Keep public roots for normal Meet HTTPS; add only this deployment's certificate.
61
+ roots = Path("/etc/ssl/certs/ca-certificates.crt").read_bytes()
62
+ (PUBLIC / "ca-bundle.pem").write_bytes(roots + b"\n" + pem)
63
+
64
+
65
+ class Descriptor(BaseHTTPRequestHandler):
66
+ def do_GET(self):
67
+ if self.path != "/v1/descriptor":
68
+ self.send_error(404)
69
+ return
70
+ body = json.dumps({"protocol": "dsh-attendee-relay-v1", "certificate": (PUBLIC / "server.pem").read_text()}).encode()
71
+ self.send_response(200)
72
+ self.send_header("Content-Type", "application/json")
73
+ self.send_header("Cache-Control", "no-store")
74
+ self.send_header("Content-Length", str(len(body)))
75
+ self.end_headers()
76
+ self.wfile.write(body)
77
+
78
+ def setup(self):
79
+ super().setup()
80
+ self.connection.settimeout(5)
81
+
82
+ def log_message(self, *_):
83
+ pass
84
+
85
+
86
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
87
+ def redirect_request(self, *args, **kwargs):
88
+ return None
89
+
90
+
91
+ def authorized(header):
92
+ if not header or not re.fullmatch(r"Token [\x21-\x7e]{8,512}", header):
93
+ return False
94
+ request = urllib.request.Request("http://attendee-app-local:8000/api/v1/bots?limit=1", headers={"Authorization": header, "Host": "localhost:8000"})
95
+ try:
96
+ with urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()).open(request, timeout=5) as response:
97
+ return response.status == 200
98
+ except (OSError, urllib.error.URLError):
99
+ return False
100
+
101
+
102
+ class Relay:
103
+ def __init__(self):
104
+ self.lease = None
105
+ self.lock = asyncio.Lock()
106
+ self.auth_slots = asyncio.Semaphore(4)
107
+
108
+ async def process_request(self, connection, request):
109
+ if request.headers.get("Origin"):
110
+ return connection.respond(HTTPStatus.FORBIDDEN, "Forbidden\n")
111
+ path = urlsplit(request.path)
112
+ if path.path == "/v1/receive":
113
+ if self.lease or self.auth_slots.locked():
114
+ return connection.respond(HTTPStatus.SERVICE_UNAVAILABLE, "Busy\n")
115
+ async with self.auth_slots:
116
+ if not await asyncio.to_thread(authorized, request.headers.get("Authorization")):
117
+ return connection.respond(HTTPStatus.FORBIDDEN, "Forbidden\n")
118
+ elif path.path == "/ingest":
119
+ token = parse_qs(path.query).get("token", [""])[0]
120
+ lease = self.lease
121
+ if not lease or not re.fullmatch(r"[a-f0-9]{64}", token) or not hmac.compare_digest(token, lease["token"]) or lease["producer"]:
122
+ return connection.respond(HTTPStatus.FORBIDDEN, "Forbidden\n")
123
+ else:
124
+ return connection.respond(HTTPStatus.NOT_FOUND, "Not found\n")
125
+
126
+ async def handler(self, socket):
127
+ path = urlsplit(socket.request.path)
128
+ if path.path == "/v1/receive":
129
+ async with self.lock:
130
+ if self.lease:
131
+ await socket.close(1008, "busy")
132
+ return
133
+ lease = {"consumer": socket, "producer": None, "token": secrets.token_hex(32)}
134
+ self.lease = lease
135
+ try:
136
+ await socket.send(json.dumps({"type": "ready", "ingestUrl": "wss://dsh-audio-bridge:18765/ingest?token=" + lease["token"]}))
137
+ async for _ in socket:
138
+ # Consumers receive only; unsolicited application writes are invalid.
139
+ await socket.close(1008, "receive only")
140
+ break
141
+ except ConnectionClosed:
142
+ pass
143
+ finally:
144
+ if self.lease is lease:
145
+ self.lease = None
146
+ if lease["producer"]:
147
+ await lease["producer"].close(1000, "receiver closed")
148
+ else:
149
+ async with self.lock:
150
+ lease = self.lease
151
+ token = parse_qs(path.query).get("token", [""])[0]
152
+ if not lease or lease["producer"] or not re.fullmatch(r"[a-f0-9]{64}", token) or not hmac.compare_digest(token, lease["token"]):
153
+ await socket.close(1008, "ownership")
154
+ return
155
+ lease["producer"] = socket
156
+ try:
157
+ async for message in socket:
158
+ if not isinstance(message, str):
159
+ raise ValueError("binary")
160
+ # The plugin performs strict protocol, bot, UUID and PCM validation.
161
+ await asyncio.wait_for(lease["consumer"].send(message), timeout=5)
162
+ except Exception:
163
+ pass
164
+ finally:
165
+ await lease["consumer"].close(1008, "audio disconnected")
166
+
167
+
168
+ async def run():
169
+ from websockets.asyncio.server import serve
170
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
171
+ context.load_cert_chain(PUBLIC / "server.pem", PRIVATE / "server.key")
172
+ relay = Relay()
173
+ descriptor = ThreadingHTTPServer(("0.0.0.0", 18766), Descriptor)
174
+ Thread(target=descriptor.serve_forever, daemon=True).start()
175
+ async with serve(relay.handler, "0.0.0.0", 18765, ssl=context, process_request=relay.process_request,
176
+ max_size=MAX_MESSAGE, max_queue=16, write_limit=64000, compression=None,
177
+ open_timeout=8, close_timeout=2, ping_interval=10, ping_timeout=10):
178
+ await asyncio.Future()
179
+
180
+
181
+ if __name__ == "__main__":
182
+ import sys
183
+ if "--init" in sys.argv:
184
+ provision()
185
+ else:
186
+ asyncio.run(run())
@@ -0,0 +1,54 @@
1
+ # Add after Attendee base + ARM64/FakeCloud overrides. No host process or public port.
2
+ x-bridge-image: &bridge-image
3
+ image: ${DSH_ATTENDEE_IMAGE:-attendee-local:arm64}
4
+ entrypoint: []
5
+ services:
6
+ dsh-audio-init:
7
+ <<: *bridge-image
8
+ user: "0:0"
9
+ command: ["python", "/dsh/audio_bridge.py", "--init"]
10
+ volumes:
11
+ - ${DSH_LIVE_VOICE_DEPLOY:-../packages/live-voice/deploy/attendee}/audio_bridge.py:/dsh/audio_bridge.py:ro
12
+ - dsh-audio-public:/bridge-public
13
+ - dsh-audio-private:/bridge-private
14
+ network_mode: none
15
+ restart: "no"
16
+ dsh-audio-bridge:
17
+ <<: *bridge-image
18
+ user: "0:0"
19
+ command: ["python", "/dsh/audio_bridge.py"]
20
+ depends_on:
21
+ dsh-audio-init:
22
+ condition: service_completed_successfully
23
+ volumes:
24
+ - ${DSH_LIVE_VOICE_DEPLOY:-../packages/live-voice/deploy/attendee}/audio_bridge.py:/dsh/audio_bridge.py:ro
25
+ - dsh-audio-public:/bridge-public:ro
26
+ - dsh-audio-private:/bridge-private:ro
27
+ ports:
28
+ - "127.0.0.1:18765:18765"
29
+ - "127.0.0.1:18766:18766"
30
+ networks:
31
+ - attendee_network
32
+ restart: unless-stopped
33
+ read_only: true
34
+ tmpfs:
35
+ - /tmp
36
+ cap_drop: [ALL]
37
+ security_opt: ["no-new-privileges:true"]
38
+ healthcheck:
39
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18766/v1/descriptor', timeout=2).close()"]
40
+ interval: 5s
41
+ timeout: 3s
42
+ retries: 10
43
+ attendee-worker-local:
44
+ image: ${DSH_ATTENDEE_IMAGE:-attendee-local:arm64}
45
+ environment:
46
+ SSL_CERT_FILE: /dsh-audio-public/ca-bundle.pem
47
+ volumes:
48
+ - dsh-audio-public:/dsh-audio-public:ro
49
+ depends_on:
50
+ dsh-audio-bridge:
51
+ condition: service_healthy
52
+ volumes:
53
+ dsh-audio-public:
54
+ dsh-audio-private:
@@ -0,0 +1,46 @@
1
+ """Disposable cross-runtime fixture. Fake API admission, real relay/TLS/PCM."""
2
+ import asyncio
3
+ import json
4
+ from pathlib import Path
5
+ import ssl
6
+ import tempfile
7
+ from threading import Thread
8
+ from http.server import ThreadingHTTPServer
9
+ import audio_bridge as bridge
10
+ from websockets.asyncio.server import serve
11
+ from websockets.asyncio.client import connect
12
+
13
+
14
+ async def main():
15
+ with tempfile.TemporaryDirectory() as directory:
16
+ bridge.PUBLIC = Path(directory) / "public"
17
+ bridge.PRIVATE = Path(directory) / "private"
18
+ bridge.provision()
19
+ bridge.authorized = lambda header: header == "Token cross-runtime-test-key"
20
+ tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
21
+ tls.load_cert_chain(bridge.PUBLIC / "server.pem", bridge.PRIVATE / "server.key")
22
+ client_tls = ssl.create_default_context(cafile=str(bridge.PUBLIC / "ca-bundle.pem"))
23
+ relay = bridge.Relay()
24
+ descriptor = ThreadingHTTPServer(("0.0.0.0", 18766), bridge.Descriptor)
25
+ Thread(target=descriptor.serve_forever, daemon=True).start()
26
+ async with serve(relay.handler, "0.0.0.0", 18765, ssl=tls, process_request=relay.process_request,
27
+ max_size=bridge.MAX_MESSAGE, max_queue=16, compression=None):
28
+ print("fixture-ready", flush=True)
29
+ for _ in range(2000):
30
+ if relay.lease:
31
+ break
32
+ await asyncio.sleep(0.01)
33
+ if not relay.lease:
34
+ raise RuntimeError("receiver deadline exceeded")
35
+ token = relay.lease["token"]
36
+ async with connect("wss://localhost:18765/ingest?token=" + token, ssl=client_tls) as producer:
37
+ for uuid in ["device/python-one", "device/python-two"]:
38
+ await producer.send(json.dumps({"trigger":"realtime_audio.per_participant","bot_id":"bot_CrossRuntime","data":{"participant_uuid":uuid,"chunk":"AID/fw==","sample_rate":16000,"timestamp_ms":1000}}))
39
+ await asyncio.wait_for(producer.wait_closed(), 15)
40
+ print("producer-revoked", flush=True)
41
+ descriptor.shutdown()
42
+ descriptor.server_close()
43
+
44
+
45
+ if __name__ == "__main__":
46
+ asyncio.run(main())
@@ -0,0 +1,170 @@
1
+ """Isolated integration checks; never starts a bot or contacts a meeting."""
2
+ import asyncio
3
+ import importlib.util
4
+ import json
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from threading import Thread
7
+ import urllib.request
8
+ from pathlib import Path
9
+ import ssl
10
+ import tempfile
11
+ import unittest
12
+ from unittest.mock import patch
13
+
14
+ spec = importlib.util.spec_from_file_location("audio_bridge", Path(__file__).with_name("audio_bridge.py"))
15
+ bridge = importlib.util.module_from_spec(spec)
16
+ spec.loader.exec_module(bridge)
17
+
18
+
19
+ class RelayTest(unittest.IsolatedAsyncioTestCase):
20
+ async def test_api_admission_is_bounded_without_queueing_extra_auth_requests(self):
21
+ from types import SimpleNamespace
22
+ from threading import Event, Lock
23
+ gate = Event()
24
+ lock = Lock()
25
+ started = []
26
+ def slow_auth(_):
27
+ with lock:
28
+ started.append(True)
29
+ gate.wait(timeout=2)
30
+ return True
31
+ relay = bridge.Relay()
32
+ connection = SimpleNamespace(respond=lambda status, body: status)
33
+ request = SimpleNamespace(path="/v1/receive", headers={"Authorization": "Token test-only-key"})
34
+ with patch.object(bridge, "authorized", slow_auth):
35
+ tasks = [asyncio.create_task(relay.process_request(connection, request)) for _ in range(4)]
36
+ try:
37
+ for _ in range(100):
38
+ if len(started) == 4:
39
+ break
40
+ await asyncio.sleep(0.005)
41
+ self.assertEqual(len(started), 4)
42
+ self.assertEqual(await relay.process_request(connection, request), bridge.HTTPStatus.SERVICE_UNAVAILABLE)
43
+ self.assertEqual(len(started), 4)
44
+ finally:
45
+ gate.set()
46
+ await asyncio.gather(*tasks)
47
+
48
+ async def test_real_tls_audio_ownership_and_revocation(self):
49
+ from websockets.asyncio.server import serve
50
+ from websockets.asyncio.client import connect
51
+ from websockets.exceptions import InvalidStatus
52
+ with tempfile.TemporaryDirectory() as directory:
53
+ bridge.PUBLIC = Path(directory) / "public"
54
+ bridge.PRIVATE = Path(directory) / "private"
55
+ bridge.provision()
56
+ first = (bridge.PUBLIC / "server.pem").read_bytes()
57
+ (bridge.PUBLIC / "ca-bundle.pem").unlink()
58
+ bridge.provision()
59
+ self.assertIn(first, (bridge.PUBLIC / "ca-bundle.pem").read_bytes())
60
+ self.assertEqual(first, (bridge.PUBLIC / "server.pem").read_bytes())
61
+ self.assertEqual((bridge.PRIVATE / "server.key").stat().st_mode & 0o777, 0o600)
62
+ server_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
63
+ server_tls.load_cert_chain(bridge.PUBLIC / "server.pem", bridge.PRIVATE / "server.key")
64
+ client_tls = ssl.create_default_context(cafile=str(bridge.PUBLIC / "ca-bundle.pem"))
65
+ relay = bridge.Relay()
66
+ with patch.object(bridge, "authorized", lambda value: value == "Token test-only-key"):
67
+ async with serve(relay.handler, "127.0.0.1", 0, ssl=server_tls, process_request=relay.process_request,
68
+ max_size=bridge.MAX_MESSAGE, max_queue=16, compression=None) as server:
69
+ origin = "wss://127.0.0.1:" + str(server.sockets[0].getsockname()[1])
70
+ loop = asyncio.get_running_loop()
71
+ original_handler = loop.get_exception_handler()
72
+ def expected_tls_reset(loop, context):
73
+ if isinstance(context.get("exception"), ConnectionResetError):
74
+ return
75
+ if original_handler:
76
+ original_handler(loop, context)
77
+ else:
78
+ loop.default_exception_handler(context)
79
+ loop.set_exception_handler(expected_tls_reset)
80
+ self.addCleanup(loop.set_exception_handler, original_handler)
81
+ with self.assertRaises(ssl.SSLCertVerificationError):
82
+ async with connect(origin + "/v1/receive"):
83
+ self.fail("Untrusted certificate admitted")
84
+ for path, headers in [("/v1/receive", {}), ("/v1/receive", {"Authorization": "Token test-only-key", "Origin": "https://foreign.example"}), ("/ingest?token=bad", {}), ("/ingest?token=%C3%A9", {})]:
85
+ with self.assertRaises(InvalidStatus):
86
+ async with connect(origin + path, ssl=client_tls, additional_headers=headers):
87
+ self.fail("Unauthorized peer admitted")
88
+ async with connect(origin + "/v1/receive", ssl=client_tls, additional_headers={"Authorization": "Token test-only-key"}) as consumer:
89
+ ready = json.loads(await consumer.recv())
90
+ self.assertEqual(ready["type"], "ready")
91
+ token = ready["ingestUrl"].split("token=")[1]
92
+ self.assertEqual(len(token), 64)
93
+ async with connect(origin + "/ingest?token=" + token, ssl=client_tls) as producer:
94
+ packet = json.dumps({"trigger": "realtime_audio.per_participant", "bot_id": "bot_Test", "data": {"participant_uuid": "device/1", "chunk": "AAAAAA==", "timestamp_ms": 1000, "sample_rate": 16000}})
95
+ await producer.send(packet)
96
+ self.assertEqual(await consumer.recv(), packet)
97
+ with self.assertRaises(InvalidStatus):
98
+ async with connect(origin + "/ingest?token=" + token, ssl=client_tls):
99
+ self.fail("Second producer admitted")
100
+ await consumer.close()
101
+ await asyncio.wait_for(producer.wait_closed(), 2)
102
+ await asyncio.sleep(0)
103
+ with self.assertRaises(InvalidStatus):
104
+ async with connect(origin + "/ingest?token=" + token, ssl=client_tls):
105
+ self.fail("Expired token admitted")
106
+ for payload in [b"binary-is-not-audio-json", "x" * (bridge.MAX_MESSAGE + 1)]:
107
+ async with connect(origin + "/v1/receive", ssl=client_tls, additional_headers={"Authorization": "Token test-only-key"}) as consumer:
108
+ ready = json.loads(await consumer.recv())
109
+ token = ready["ingestUrl"].split("token=")[1]
110
+ async with connect(origin + "/ingest?token=" + token, ssl=client_tls) as producer:
111
+ await producer.send(payload)
112
+ await asyncio.wait_for(consumer.wait_closed(), 2)
113
+ self.assertEqual(consumer.close_code, 1008)
114
+ await asyncio.wait_for(producer.wait_closed(), 2)
115
+ await asyncio.sleep(0)
116
+ async with connect(origin + "/v1/receive", ssl=client_tls, additional_headers={"Authorization": "Token test-only-key"}) as consumer:
117
+ await consumer.recv()
118
+ await consumer.send("unsolicited-write")
119
+ await asyncio.wait_for(consumer.wait_closed(), 2)
120
+ self.assertEqual(consumer.close_code, 1008)
121
+ self.assertIsNone(relay.lease)
122
+
123
+
124
+ class AuthenticationTest(unittest.TestCase):
125
+ def test_real_http_auth_rejects_bad_keys_redirects_and_malformed_headers(self):
126
+ calls = []
127
+ class Api(BaseHTTPRequestHandler):
128
+ def do_GET(self):
129
+ calls.append((self.path, self.headers.get("Authorization")))
130
+ if self.headers.get("Host") != "localhost:8000":
131
+ self.send_response(400)
132
+ self.end_headers()
133
+ return
134
+ if self.path == "/redirect-target":
135
+ self.send_response(200)
136
+ elif self.headers.get("Authorization") == "Token redirect-key":
137
+ self.send_response(302)
138
+ self.send_header("Location", "/redirect-target")
139
+ elif self.headers.get("Authorization") == "Token valid-test-key":
140
+ self.send_response(200)
141
+ else:
142
+ self.send_response(401)
143
+ self.end_headers()
144
+ def log_message(self, *_):
145
+ pass
146
+ api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
147
+ thread = Thread(target=api.serve_forever, daemon=True)
148
+ thread.start()
149
+ real_request = urllib.request.Request
150
+ def route_request(url, **kwargs):
151
+ self.assertEqual(url, "http://attendee-app-local:8000/api/v1/bots?limit=1")
152
+ return real_request("http://127.0.0.1:" + str(api.server_port) + "/api/v1/bots?limit=1", **kwargs)
153
+ try:
154
+ with patch.dict("os.environ", {"http_proxy": "http://127.0.0.1:1", "HTTP_PROXY": "http://127.0.0.1:1", "no_proxy": "", "NO_PROXY": ""}), patch.object(bridge.urllib.request, "Request", route_request):
155
+ self.assertTrue(bridge.authorized("Token valid-test-key"))
156
+ self.assertFalse(bridge.authorized("Token invalid-key"))
157
+ self.assertFalse(bridge.authorized("Token redirect-key"))
158
+ count = len(calls)
159
+ for header in [None, "", "Bearer valid-test-key", "Token x\r\nHost: evil", "Token nonascii-é", "Token " + "x" * 520]:
160
+ self.assertFalse(bridge.authorized(header))
161
+ self.assertEqual(len(calls), count)
162
+ self.assertEqual(len(calls), 3, "Redirect must not leak the key to a follow-up request")
163
+ finally:
164
+ api.shutdown()
165
+ api.server_close()
166
+ thread.join(timeout=2)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()
@@ -36682,7 +36682,7 @@ async function createQwenRuntime(options) {
36682
36682
  }
36683
36683
 
36684
36684
  // src/modules/recognition/local/assets.ts
36685
- var browserAsrAssetBase = () => "/api/dsh-live-voice/asr/" + encodeURIComponent(true ? "0.6.2" : "test") + "/";
36685
+ var browserAsrAssetBase = () => "/api/dsh-live-voice/asr/" + encodeURIComponent(true ? "0.7.0" : "test") + "/";
36686
36686
 
36687
36687
  // src/modules/recognition/local/modelCatalog.ts
36688
36688
  var browserAsrModels = [
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.2",
2
+ "version": "0.7.0",
3
3
  "assets": [
4
4
  "asr.worker.js",
5
5
  "manifest.json",