voice-vibecoder 2.3.0__tar.gz → 2.4.0__tar.gz

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 (42) hide show
  1. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/CHANGELOG.md +8 -0
  2. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/PKG-INFO +1 -2
  3. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/pyproject.toml +1 -2
  4. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/app_config.py +7 -0
  6. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/cli_serve.py +0 -2
  7. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/server.py +16 -94
  8. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/.github/workflows/ci.yml +0 -0
  9. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/.github/workflows/release.yml +0 -0
  10. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/.gitignore +0 -0
  11. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/LICENSE +0 -0
  12. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/README.md +0 -0
  13. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/app.py +0 -0
  14. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/index.html +0 -0
  15. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/audio.py +0 -0
  16. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  17. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  18. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  19. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  20. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/config.py +0 -0
  21. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/instances.py +0 -0
  22. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/session.py +0 -0
  23. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  24. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  25. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  26. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  27. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  28. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  29. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  30. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  31. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  32. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  33. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/panels.py +0 -0
  34. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/screen.py +0 -0
  35. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/setup.py +0 -0
  36. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/state.py +0 -0
  37. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/styles.py +0 -0
  38. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  39. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  40. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  41. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  42. {voice_vibecoder-2.3.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,14 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.4.0 (2026-02-19)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **server**: Handle unexpected errors during authentication
10
+ ([`f0ac102`](https://github.com/snokam/voice-vibecoder/commit/f0ac1025cac939f8fc048c796d63668c1d687f9e))
11
+
12
+
5
13
  ## v2.3.0 (2026-02-19)
6
14
 
7
15
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code
5
5
  Project-URL: Homepage, https://github.com/snokam/voice-vibecoder
6
6
  Project-URL: Repository, https://github.com/snokam/voice-vibecoder
@@ -23,7 +23,6 @@ Requires-Python: >=3.10
23
23
  Requires-Dist: claude-agent-sdk>=0.1.30
24
24
  Requires-Dist: numpy>=1.24
25
25
  Requires-Dist: platformdirs>=4.0
26
- Requires-Dist: pyjwt[crypto]>=2.8
27
26
  Requires-Dist: rich>=13.0
28
27
  Requires-Dist: sounddevice>=0.4
29
28
  Requires-Dist: textual>=0.50
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.3.0"
7
+ version = "2.4.0"
8
8
  description = "Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -31,7 +31,6 @@ dependencies = [
31
31
  "numpy>=1.24",
32
32
  "claude-agent-sdk>=0.1.30",
33
33
  "platformdirs>=4.0",
34
- "PyJWT[crypto]>=2.8",
35
34
  ]
36
35
 
37
36
  [project.urls]
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.3.0"
6
+ __version__ = "2.4.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -9,6 +9,7 @@ Embedders can override any directory to colocate everything (e.g. ~/.snokam/).
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from collections.abc import Awaitable
12
13
  from dataclasses import dataclass, field
13
14
  from pathlib import Path
14
15
  from typing import Callable
@@ -65,3 +66,9 @@ class VoiceConfig:
65
66
  # Called before an agent task runs. Receives (mcp_servers: dict, user_jwt: str | None)
66
67
  # and can mutate mcp_servers in place (e.g. inject OBO tokens).
67
68
  pre_agent_hook: Callable[[dict, str | None], None] | None = None
69
+
70
+ # Async callback for WebSocket authentication.
71
+ # Receives the raw token string, returns (user_email, raw_token).
72
+ # Raises ValueError on validation failure.
73
+ # When None, auth is disabled (no auth frame expected from clients).
74
+ authenticate: Callable[[str], Awaitable[tuple[str, str]]] | None = None
@@ -61,7 +61,6 @@ def main(voice_config: VoiceConfig | None = None) -> None:
61
61
  parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
62
62
  parser.add_argument("--port", type=int, default=8765, help="Port (default: 8765)")
63
63
  parser.add_argument("--repo", type=str, default=None, help="Repo root (auto-detects git root)")
64
- parser.add_argument("--no-auth", action="store_true", help="Disable JWT authentication (for local dev)")
65
64
  parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
66
65
  args = parser.parse_args()
67
66
 
@@ -94,7 +93,6 @@ def main(voice_config: VoiceConfig | None = None) -> None:
94
93
  repo_root=repo_root,
95
94
  voice_config=config,
96
95
  settings=settings,
97
- no_auth=args.no_auth,
98
96
  ))
99
97
 
100
98
 
@@ -29,12 +29,9 @@ import asyncio
29
29
  import json
30
30
  import logging
31
31
  import os
32
- import time
33
32
  from pathlib import Path
34
33
  from typing import Any
35
- from urllib.parse import urlparse
36
34
 
37
- import jwt
38
35
  import websockets
39
36
  from websockets.asyncio.server import ServerConnection
40
37
  from websockets.http11 import Request, Response
@@ -47,70 +44,6 @@ from voice_vibecoder.voice_providers.openai import OpenAIVoiceProvider
47
44
 
48
45
  logger = logging.getLogger(__name__)
49
46
 
50
- # ── JWT validation for Azure AD ──────────────────────────────────────────────
51
-
52
- _JWKS_CACHE_TTL = 3600 # 1 hour
53
-
54
-
55
- class _JWKSEntry:
56
- __slots__ = ("client", "fetched_at")
57
-
58
- def __init__(self, client: jwt.PyJWKClient, fetched_at: float) -> None:
59
- self.client = client
60
- self.fetched_at = fetched_at
61
-
62
-
63
- _jwks_cache: dict[str, _JWKSEntry] = {}
64
-
65
-
66
- def _get_jwks_client(tenant_id: str) -> jwt.PyJWKClient:
67
- """Return a cached JWKS client for the given Azure AD tenant."""
68
- now = time.monotonic()
69
- entry = _jwks_cache.get(tenant_id)
70
- if entry and (now - entry.fetched_at) < _JWKS_CACHE_TTL:
71
- return entry.client
72
-
73
- jwks_url = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
74
- client = jwt.PyJWKClient(jwks_url)
75
- _jwks_cache[tenant_id] = _JWKSEntry(client, now)
76
- return client
77
-
78
-
79
- def validate_token(token: str, tenant_id: str, audience: str) -> dict:
80
- """Validate an Azure AD JWT and return decoded claims.
81
-
82
- Raises ValueError if the token is invalid.
83
- """
84
- try:
85
- jwks_client = _get_jwks_client(tenant_id)
86
- signing_key = jwks_client.get_signing_key_from_jwt(token)
87
- # Accept both v1 (aud=client_id) and v2 (aud=api://client_id) tokens,
88
- # and both v1/v2 issuer formats.
89
- audiences = [audience]
90
- if audience.startswith("api://"):
91
- audiences.append(audience[len("api://"):])
92
- issuers = [
93
- f"https://login.microsoftonline.com/{tenant_id}/v2.0",
94
- f"https://sts.windows.net/{tenant_id}/",
95
- ]
96
- claims = jwt.decode(
97
- token,
98
- signing_key.key,
99
- algorithms=["RS256"],
100
- audience=audiences,
101
- issuer=issuers,
102
- )
103
- return claims
104
- except jwt.ExpiredSignatureError:
105
- raise ValueError("Token has expired")
106
- except jwt.InvalidAudienceError:
107
- raise ValueError("Invalid audience")
108
- except jwt.InvalidIssuerError:
109
- raise ValueError("Invalid issuer")
110
- except Exception as exc:
111
- raise ValueError(f"Token validation failed: {exc}")
112
-
113
-
114
47
  # ── Auth timeout ─────────────────────────────────────────────────────────────
115
48
 
116
49
  AUTH_TIMEOUT_SECONDS = 10
@@ -313,11 +246,11 @@ def _log_send_error(future: asyncio.Future) -> None:
313
246
 
314
247
  async def _authenticate_ws(
315
248
  ws: ServerConnection,
316
- tenant_id: str,
317
- audience: str,
249
+ authenticate: Any,
318
250
  ) -> tuple[str, str] | None:
319
251
  """Wait for an auth message as the first frame.
320
252
 
253
+ *authenticate* is the ``VoiceConfig.authenticate`` async callback.
321
254
  Returns ``(user_email, raw_token)`` on success, ``None`` on failure.
322
255
  """
323
256
  try:
@@ -338,15 +271,18 @@ async def _authenticate_ws(
338
271
  token = token[7:]
339
272
 
340
273
  try:
341
- claims = validate_token(token, tenant_id, audience)
274
+ result = await authenticate(token)
342
275
  except ValueError as exc:
343
- logger.warning("JWT validation failed: %s", exc)
276
+ logger.warning("Authentication failed: %s", exc)
277
+ await ws.send(json.dumps({"type": "error", "message": "Authentication failed"}))
278
+ return None
279
+ except Exception as exc:
280
+ logger.error("Unexpected error during authentication: %s", exc, exc_info=True)
344
281
  await ws.send(json.dumps({"type": "error", "message": "Authentication failed"}))
345
282
  return None
346
283
 
347
284
  await ws.send(json.dumps({"type": "auth_ok"}))
348
- user_email = claims.get("email") or claims.get("preferred_username") or claims.get("upn") or "unknown"
349
- return (user_email, token)
285
+ return result
350
286
 
351
287
 
352
288
  async def _handle_connection(
@@ -354,17 +290,15 @@ async def _handle_connection(
354
290
  settings: RealtimeSettings,
355
291
  repo_root: Path,
356
292
  voice_config: VoiceConfig | None,
357
- auth_enabled: bool,
358
- tenant_id: str,
359
- audience: str,
360
293
  ) -> None:
361
294
  remote = ws.remote_address
362
295
  logger.info("Client connected from %s", remote)
363
296
 
364
297
  user_email = None
365
298
  user_jwt = None
366
- if auth_enabled:
367
- auth_result = await _authenticate_ws(ws, tenant_id, audience)
299
+ authenticate = voice_config.authenticate if voice_config else None
300
+ if authenticate:
301
+ auth_result = await _authenticate_ws(ws, authenticate)
368
302
  if not auth_result:
369
303
  logger.warning("Auth failed for %s — closing", remote)
370
304
  await ws.close(4001, "Unauthorized")
@@ -387,7 +321,6 @@ async def serve(
387
321
  repo_root: Path | None = None,
388
322
  voice_config: VoiceConfig | None = None,
389
323
  settings: RealtimeSettings | None = None,
390
- no_auth: bool = False,
391
324
  allowed_origins: list[str] | None = None,
392
325
  ) -> None:
393
326
  """Start the WebSocket server. Blocks until stopped."""
@@ -404,22 +337,11 @@ async def serve(
404
337
  logger.error("Not configured. Run 'vibecoder' TUI first to set API keys.")
405
338
  return
406
339
 
407
- # JWT auth enabled when both env vars are set
408
- tenant_id = os.environ.get("AZURE_AD_TENANT_ID", "")
409
- client_id = os.environ.get("AZURE_AD_CLIENT_ID", "")
410
- auth_enabled = bool(tenant_id and client_id) and not no_auth
340
+ auth_enabled = voice_config is not None and voice_config.authenticate is not None
411
341
  if auth_enabled:
412
- audience = f"api://{client_id}"
413
- logger.info("JWT auth enabled tenant=%s audience=%s", tenant_id, audience)
414
- elif no_auth:
415
- audience = ""
416
- logger.warning("JWT auth explicitly disabled via --no-auth")
342
+ logger.info("WebSocket auth enabled (authenticate callback provided)")
417
343
  else:
418
- audience = ""
419
- logger.warning(
420
- "JWT auth disabled (AZURE_AD_TENANT_ID / AZURE_AD_CLIENT_ID not set). "
421
- "Set these env vars or pass --no-auth to suppress this warning."
422
- )
344
+ logger.info("WebSocket auth disabled (no authenticate callback)")
423
345
 
424
346
  # Origin checking — build allowed set from env var + explicit param
425
347
  origin_env = os.environ.get("VOICE_ALLOWED_ORIGINS", "")
@@ -444,7 +366,7 @@ async def serve(
444
366
  )
445
367
 
446
368
  async def handler(ws: ServerConnection) -> None:
447
- await _handle_connection(ws, settings, repo_root, voice_config, auth_enabled, tenant_id, audience)
369
+ await _handle_connection(ws, settings, repo_root, voice_config)
448
370
 
449
371
  async with websockets.serve(handler, host, port, process_request=process_request):
450
372
  await asyncio.Future() # Block forever
File without changes
File without changes