pulse-coding-agent 0.1.0__py3-none-any.whl

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 (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/auth.py ADDED
@@ -0,0 +1,670 @@
1
+ """Production-Grade Authentication Management for Pulse CLI.
2
+
3
+ Implements OAuth 2.0 Authorization Code Flow with PKCE (Proof Key for Code Exchange),
4
+ secure OS credential storage via keyring, automatic silent token refresh, authenticated
5
+ Google userinfo lookup, and clean session management APIs for the Pulse developer CLI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import http.server
14
+ import json
15
+ import logging
16
+ import os
17
+ import secrets
18
+ import socketserver
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ import webbrowser
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ try:
29
+ import keyring
30
+ except ImportError: # pragma: no cover
31
+ keyring = None # type: ignore[assignment]
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ KEYRING_SERVICE_NAME = "pulse-cli"
36
+ KEYRING_ACCOUNT_NAME = "current_session"
37
+
38
+
39
+ # --- Exceptions ---
40
+
41
+ class AuthError(Exception):
42
+ """Base exception for authentication errors."""
43
+
44
+
45
+ class AuthTimeoutError(AuthError):
46
+ """Raised when authentication times out waiting for browser callback."""
47
+
48
+
49
+ class StateMismatchError(AuthError):
50
+ """Raised when OAuth state token does not match (potential CSRF)."""
51
+
52
+
53
+ class UserCancelledError(AuthError):
54
+ """Raised when user cancels authentication in browser."""
55
+
56
+
57
+ # --- Data Models ---
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class UserProfile:
61
+ """User profile data extracted from authenticated ID token / userinfo."""
62
+
63
+ email: str
64
+ name: str | None = None
65
+ picture: str | None = None
66
+ sub: str | None = None
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return {
70
+ "email": self.email,
71
+ "name": self.name,
72
+ "picture": self.picture,
73
+ "sub": self.sub,
74
+ }
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> UserProfile:
78
+ return cls(
79
+ email=data.get("email", ""),
80
+ name=data.get("name"),
81
+ picture=data.get("picture"),
82
+ sub=data.get("sub"),
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class TokenSet:
88
+ """OAuth 2.0 Token container."""
89
+
90
+ access_token: str
91
+ refresh_token: str | None = None
92
+ id_token: str | None = None
93
+ expires_at: float = 0.0
94
+
95
+ @property
96
+ def is_expired(self) -> bool:
97
+ """Token is considered expired 60 seconds before actual expiration."""
98
+ return time.time() >= (self.expires_at - 60)
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ return {
102
+ "access_token": self.access_token,
103
+ "refresh_token": self.refresh_token,
104
+ "id_token": self.id_token,
105
+ "expires_at": self.expires_at,
106
+ }
107
+
108
+ @classmethod
109
+ def from_dict(cls, data: dict[str, Any]) -> TokenSet:
110
+ return cls(
111
+ access_token=data.get("access_token", ""),
112
+ refresh_token=data.get("refresh_token"),
113
+ id_token=data.get("id_token"),
114
+ expires_at=float(data.get("expires_at", 0.0)),
115
+ )
116
+
117
+
118
+ # --- PKCE & Cryptographic Utilities ---
119
+
120
+ def generate_pkce_pair() -> tuple[str, str]:
121
+ """Generate PKCE code_verifier and S256 code_challenge.
122
+
123
+ Returns:
124
+ Tuple of (code_verifier, code_challenge)
125
+ """
126
+ raw_bytes = secrets.token_bytes(64)
127
+ code_verifier = base64.urlsafe_b64encode(raw_bytes).decode("utf-8").rstrip("=")
128
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
129
+ code_challenge = base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
130
+ return code_verifier, code_challenge
131
+
132
+
133
+ def generate_state() -> str:
134
+ """Generate a secure cryptographic state token."""
135
+ return secrets.token_urlsafe(32)
136
+
137
+
138
+ def decode_jwt_payload(jwt_token: str) -> dict[str, Any]:
139
+ """Safely decode unverified payload segment of a JWT string.
140
+
141
+ Args:
142
+ jwt_token: Compact JWT string (header.payload.signature).
143
+
144
+ Returns:
145
+ Decoded payload dictionary.
146
+ """
147
+ parts = jwt_token.split(".")
148
+ if len(parts) < 2:
149
+ return {}
150
+ payload_b64 = parts[1]
151
+ padding = "=" * (-len(payload_b64) % 4)
152
+ decoded_bytes = base64.urlsafe_b64decode((payload_b64 + padding).encode("utf-8"))
153
+ return json.loads(decoded_bytes.decode("utf-8"))
154
+
155
+
156
+ # --- Secure Storage (Keyring + File Fallback) ---
157
+
158
+ class SecureTokenStore:
159
+ """Workspace-scoped credential storage backed only by the OS keyring."""
160
+
161
+ def __init__(self, workspace: Path | None = None) -> None:
162
+ self.workspace = (workspace or Path.cwd()).resolve()
163
+ self.fallback_file = self.workspace / ".agent" / ".pulse-auth-session.json"
164
+ digest = hashlib.sha256(
165
+ os.path.normcase(str(self.workspace)).encode("utf-8")
166
+ ).hexdigest()[:24]
167
+ self.account_name = f"{KEYRING_ACCOUNT_NAME}:{digest}"
168
+
169
+ def store_session(self, user: UserProfile, tokens: TokenSet) -> None:
170
+ payload = json.dumps({
171
+ "user": user.to_dict(),
172
+ "tokens": tokens.to_dict(),
173
+ })
174
+
175
+ if keyring is None:
176
+ raise AuthError("The OS credential vault is unavailable; the session was not stored.")
177
+ try:
178
+ keyring.set_password(KEYRING_SERVICE_NAME, self.account_name, payload)
179
+ persisted = keyring.get_password(KEYRING_SERVICE_NAME, self.account_name)
180
+ except Exception as error:
181
+ logger.debug("OS credential vault storage failed.")
182
+ raise AuthError(
183
+ "The OS credential vault rejected the session; no plaintext fallback was written."
184
+ ) from error
185
+ if not persisted or not hmac.compare_digest(persisted, payload):
186
+ raise AuthError("The OS credential vault did not confirm session persistence.")
187
+ if self.fallback_file.exists():
188
+ try:
189
+ self.fallback_file.unlink()
190
+ except OSError:
191
+ logger.warning("A legacy plaintext authentication file could not be removed.")
192
+
193
+ def load_session(self) -> tuple[UserProfile, TokenSet] | None:
194
+ raw_payload: str | None = None
195
+
196
+ if keyring is not None:
197
+ try:
198
+ raw_payload = keyring.get_password(KEYRING_SERVICE_NAME, self.account_name)
199
+ except Exception: # noqa: BLE001
200
+ logger.debug("OS credential vault load failed.")
201
+ raw_payload = None
202
+
203
+ if not raw_payload:
204
+ return None
205
+
206
+ try:
207
+ data = json.loads(raw_payload)
208
+ user = UserProfile.from_dict(data.get("user", {}))
209
+ tokens = TokenSet.from_dict(data.get("tokens", {}))
210
+ if not user.email or not tokens.access_token:
211
+ return None
212
+ return user, tokens
213
+ except (json.JSONDecodeError, TypeError, KeyError):
214
+ return None
215
+
216
+ def clear_session(self) -> None:
217
+ if keyring is not None:
218
+ try:
219
+ keyring.delete_password(KEYRING_SERVICE_NAME, self.account_name)
220
+ except Exception: # noqa: BLE001
221
+ logger.debug("OS credential vault clear was unavailable.")
222
+
223
+ if self.fallback_file.exists():
224
+ try:
225
+ self.fallback_file.unlink()
226
+ except OSError:
227
+ pass
228
+
229
+
230
+ _token_store = SecureTokenStore()
231
+
232
+
233
+ def set_token_store_workspace(workspace: Path) -> None:
234
+ global _token_store
235
+ _token_store = SecureTokenStore(workspace)
236
+
237
+
238
+ # --- OAuth Local Callback Server ---
239
+
240
+ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
241
+ """HTTP handler to receive OAuth authorization code callback."""
242
+
243
+ received_code: str | None = None
244
+ received_state: str | None = None
245
+ received_error: str | None = None
246
+
247
+ def do_GET(self) -> None:
248
+ parsed_url = urllib.parse.urlparse(self.path)
249
+ params = urllib.parse.parse_qs(parsed_url.query)
250
+
251
+ if "error" in params:
252
+ OAuthCallbackHandler.received_error = params["error"][0]
253
+ self._send_response_page(
254
+ status=400,
255
+ title="Authentication Cancelled",
256
+ message="Authentication was cancelled or failed. You can close this window and return to your terminal.",
257
+ is_success=False,
258
+ )
259
+ return
260
+
261
+ if "code" in params and "state" in params:
262
+ OAuthCallbackHandler.received_code = params["code"][0]
263
+ OAuthCallbackHandler.received_state = params["state"][0]
264
+ self._send_response_page(
265
+ status=200,
266
+ title="Authentication Successful",
267
+ message="✓ Successfully authenticated with Pulse! You can close this window and return to your terminal.",
268
+ is_success=True,
269
+ )
270
+ return
271
+
272
+ self._send_response_page(
273
+ status=400,
274
+ title="Invalid Request",
275
+ message="Invalid OAuth callback request.",
276
+ is_success=False,
277
+ )
278
+
279
+ def _send_response_page(self, status: int, title: str, message: str, is_success: bool) -> None:
280
+ color = "#22c55e" if is_success else "#ef4444"
281
+ html = f"""<!DOCTYPE html>
282
+ <html>
283
+ <head>
284
+ <meta charset="utf-8">
285
+ <title>{title}</title>
286
+ <style>
287
+ body {{
288
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
289
+ background-color: #0f172a;
290
+ color: #f8fafc;
291
+ display: flex;
292
+ align-items: center;
293
+ justify-content: center;
294
+ height: 100vh;
295
+ margin: 0;
296
+ }}
297
+ .card {{
298
+ background-color: #1e293b;
299
+ padding: 2.5rem;
300
+ border-radius: 12px;
301
+ box-shadow: 0 10px 25px rgba(0,0,0,0.5);
302
+ text-align: center;
303
+ max-width: 420px;
304
+ border: 1px solid #334155;
305
+ }}
306
+ h1 {{ color: {color}; margin-bottom: 1rem; font-size: 1.5rem; }}
307
+ p {{ color: #94a3b8; font-size: 1rem; line-height: 1.5; }}
308
+ </style>
309
+ </head>
310
+ <body>
311
+ <div class="card">
312
+ <h1>{title}</h1>
313
+ <p>{message}</p>
314
+ </div>
315
+ </body>
316
+ </html>"""
317
+ self.send_response(status)
318
+ self.send_header("Content-Type", "text/html; charset=utf-8")
319
+ self.end_headers()
320
+ self.wfile.write(html.encode("utf-8"))
321
+
322
+ def log_message(self, format: str, *args: Any) -> None:
323
+ pass
324
+
325
+
326
+ class SingleRequestHTTPServer(socketserver.TCPServer):
327
+ allow_reuse_address = True
328
+
329
+
330
+ # --- OAuth Client Helper ---
331
+
332
+ def get_google_config() -> dict[str, str]:
333
+ """Retrieve only the OAuth values Pulse recognizes, without mutating the process."""
334
+ from pulse.config import load_env_file
335
+
336
+ try:
337
+ file_values = load_env_file(Path.cwd() / ".env")
338
+ except (OSError, UnicodeError, ValueError):
339
+ file_values = {}
340
+ client_id = os.environ.get("GOOGLE_CLIENT_ID") or file_values.get("GOOGLE_CLIENT_ID", "")
341
+ client_secret = os.environ.get("GOOGLE_CLIENT_SECRET") or file_values.get(
342
+ "GOOGLE_CLIENT_SECRET", ""
343
+ )
344
+ redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI") or file_values.get(
345
+ "GOOGLE_REDIRECT_URI", "http://localhost:8080"
346
+ )
347
+ parsed = urllib.parse.urlparse(redirect_uri)
348
+ if (
349
+ parsed.scheme != "http"
350
+ or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
351
+ or parsed.username is not None
352
+ or parsed.password is not None
353
+ or parsed.query
354
+ or parsed.fragment
355
+ ):
356
+ raise ValueError("GOOGLE_REDIRECT_URI must be an HTTP loopback URL without credentials or query data.")
357
+
358
+ return {
359
+ "client_id": client_id or "",
360
+ "client_secret": client_secret or "",
361
+ "redirect_uri": redirect_uri or "http://localhost:8080",
362
+ }
363
+
364
+
365
+ def build_authorization_url(state: str, code_challenge: str) -> str:
366
+ config = get_google_config()
367
+ client_id = config["client_id"]
368
+ if not client_id or client_id == "replace_me":
369
+ raise ValueError("GOOGLE_CLIENT_ID environment variable is not set or contains default 'replace_me'")
370
+
371
+ params = {
372
+ "client_id": client_id,
373
+ "redirect_uri": config["redirect_uri"],
374
+ "response_type": "code",
375
+ "scope": "openid email profile",
376
+ "access_type": "offline",
377
+ "prompt": "consent",
378
+ "state": state,
379
+ "code_challenge": code_challenge,
380
+ "code_challenge_method": "S256",
381
+ }
382
+ return f"https://accounts.google.com/o/oauth2/v2/auth?{urllib.parse.urlencode(params)}"
383
+
384
+
385
+ def exchange_code_for_tokens(code: str, code_verifier: str) -> tuple[TokenSet, UserProfile]:
386
+ config = get_google_config()
387
+ data = urllib.parse.urlencode({
388
+ "code": code,
389
+ "client_id": config["client_id"],
390
+ "client_secret": config["client_secret"],
391
+ "redirect_uri": config["redirect_uri"],
392
+ "grant_type": "authorization_code",
393
+ "code_verifier": code_verifier,
394
+ }).encode("utf-8")
395
+
396
+ req = urllib.request.Request(
397
+ "https://oauth2.googleapis.com/token",
398
+ data=data,
399
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
400
+ )
401
+
402
+ try:
403
+ with urllib.request.urlopen(req, timeout=15) as resp:
404
+ token_res = json.loads(resp.read().decode("utf-8"))
405
+ except urllib.error.URLError as error:
406
+ raise AuthError("Failed to exchange the authorization code with Google.") from error
407
+
408
+ access_token = token_res.get("access_token")
409
+ if not access_token:
410
+ raise AuthError("Token endpoint returned no access_token.")
411
+
412
+ refresh_token = token_res.get("refresh_token")
413
+ id_token = token_res.get("id_token")
414
+ expires_in = int(token_res.get("expires_in", 3600))
415
+ expires_at = time.time() + expires_in
416
+
417
+ token_set = TokenSet(
418
+ access_token=access_token,
419
+ refresh_token=refresh_token,
420
+ id_token=id_token,
421
+ expires_at=expires_at,
422
+ )
423
+
424
+ user_profile = _extract_user_profile(id_token, access_token)
425
+ return token_set, user_profile
426
+
427
+
428
+ def _extract_user_profile(id_token: str | None, access_token: str) -> UserProfile:
429
+ # The ID token payload can be decoded for diagnostics, but it must not be
430
+ # trusted without signature verification. The authenticated userinfo
431
+ # endpoint is authoritative for the public CLI login identity.
432
+ del id_token
433
+ try:
434
+ req = urllib.request.Request(
435
+ "https://www.googleapis.com/oauth2/v3/userinfo",
436
+ headers={"Authorization": f"Bearer {access_token}"},
437
+ )
438
+ with urllib.request.urlopen(req, timeout=15) as resp:
439
+ info = json.loads(resp.read().decode("utf-8"))
440
+ except (urllib.error.URLError, json.JSONDecodeError, OSError, ValueError) as error:
441
+ raise AuthError("Could not verify the Google user profile.") from error
442
+
443
+ email = info.get("email")
444
+ name = info.get("name")
445
+ picture = info.get("picture")
446
+ sub = info.get("sub")
447
+ if not email or not sub:
448
+ raise AuthError("Google userinfo response did not include email and subject identifiers.")
449
+
450
+ return UserProfile(
451
+ email=email,
452
+ name=name or email.split("@")[0],
453
+ picture=picture,
454
+ sub=sub,
455
+ )
456
+
457
+
458
+ def revoke_token(token: str) -> bool:
459
+ if not token:
460
+ return False
461
+ try:
462
+ data = urllib.parse.urlencode({"token": token}).encode("utf-8")
463
+ req = urllib.request.Request(
464
+ "https://oauth2.googleapis.com/revoke",
465
+ data=data,
466
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
467
+ )
468
+ with urllib.request.urlopen(req, timeout=10) as resp:
469
+ return resp.status == 200
470
+ except (urllib.error.URLError, OSError, ValueError):
471
+ return False
472
+
473
+
474
+ # --- Primary Authentication Manager ---
475
+
476
+ class AuthenticationManager:
477
+ """Primary Authentication Manager class wrapping OAuth 2.0 PKCE, Keyring, and session logic."""
478
+
479
+ def __init__(self, workspace: Path | None = None) -> None:
480
+ self.workspace = (workspace or Path.cwd()).resolve()
481
+ set_token_store_workspace(self.workspace)
482
+ self.database_path = self.workspace / ".agent" / "pulse-auth.sqlite3"
483
+
484
+ def is_authenticated(self) -> bool:
485
+ session = _token_store.load_session()
486
+ if not session:
487
+ return False
488
+
489
+ _, tokens = session
490
+ if not tokens.is_expired:
491
+ return True
492
+
493
+ return self.refresh_google_token()
494
+
495
+ def get_current_user(self) -> UserProfile | None:
496
+ if not self.is_authenticated():
497
+ return None
498
+ session = _token_store.load_session()
499
+ if session:
500
+ return session[0]
501
+ return None
502
+
503
+ def current_user(self) -> str | None:
504
+ user = self.get_current_user()
505
+ return user.email.split("@")[0] if user else None
506
+
507
+ def get_current_user_info(self) -> tuple[str, str | None, str | None] | None:
508
+ user = self.get_current_user()
509
+ if not user:
510
+ return None
511
+ username = user.email.split("@")[0]
512
+ return (username, user.name, user.email)
513
+
514
+ def logout(self) -> None:
515
+ session = _token_store.load_session()
516
+ if session:
517
+ _, tokens = session
518
+ if tokens.refresh_token:
519
+ revoke_token(tokens.refresh_token)
520
+ elif tokens.access_token:
521
+ revoke_token(tokens.access_token)
522
+
523
+ _token_store.clear_session()
524
+
525
+ def refresh_google_token(self, username: str | None = None) -> bool:
526
+ session = _token_store.load_session()
527
+ if not session:
528
+ return False
529
+
530
+ user, tokens = session
531
+ if not tokens.refresh_token:
532
+ return False
533
+
534
+ config = get_google_config()
535
+ data = urllib.parse.urlencode({
536
+ "client_id": config["client_id"],
537
+ "client_secret": config["client_secret"],
538
+ "refresh_token": tokens.refresh_token,
539
+ "grant_type": "refresh_token",
540
+ }).encode("utf-8")
541
+
542
+ req = urllib.request.Request(
543
+ "https://oauth2.googleapis.com/token",
544
+ data=data,
545
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
546
+ )
547
+
548
+ try:
549
+ with urllib.request.urlopen(req, timeout=15) as resp:
550
+ token_res = json.loads(resp.read().decode("utf-8"))
551
+ except (urllib.error.URLError, json.JSONDecodeError, OSError):
552
+ return False
553
+
554
+ new_access_token = token_res.get("access_token")
555
+ if not new_access_token:
556
+ return False
557
+
558
+ expires_in = int(token_res.get("expires_in", 3600))
559
+ expires_at = time.time() + expires_in
560
+ new_refresh_token = token_res.get("refresh_token") or tokens.refresh_token
561
+ new_id_token = token_res.get("id_token") or tokens.id_token
562
+
563
+ updated_tokens = TokenSet(
564
+ access_token=new_access_token,
565
+ refresh_token=new_refresh_token,
566
+ id_token=new_id_token,
567
+ expires_at=expires_at,
568
+ )
569
+
570
+ _token_store.store_session(user, updated_tokens)
571
+ return True
572
+
573
+ def get_google_config(self) -> dict:
574
+ return get_google_config()
575
+
576
+ def get_access_token(self, auto_refresh: bool = True) -> str | None:
577
+ session = _token_store.load_session()
578
+ if not session:
579
+ return None
580
+ _, tokens = session
581
+ if auto_refresh and tokens.is_expired:
582
+ if self.refresh_google_token():
583
+ session = _token_store.load_session()
584
+ return session[1].access_token if session else None
585
+ return None
586
+ return tokens.access_token
587
+
588
+
589
+ # --- Top-Level Standalone Functions (Delegating to AuthenticationManager) ---
590
+
591
+ def is_authenticated() -> bool:
592
+ return AuthenticationManager(_token_store.workspace).is_authenticated()
593
+
594
+
595
+ def get_current_user() -> UserProfile | None:
596
+ return AuthenticationManager(_token_store.workspace).get_current_user()
597
+
598
+
599
+ def logout() -> bool:
600
+ AuthenticationManager(_token_store.workspace).logout()
601
+ return True
602
+
603
+
604
+ def refresh_session() -> bool:
605
+ return AuthenticationManager(_token_store.workspace).refresh_google_token()
606
+
607
+
608
+ def login(timeout_seconds: int = 120) -> UserProfile | None:
609
+ """Execute OAuth 2.0 PKCE browser authorization flow.
610
+
611
+ Args:
612
+ timeout_seconds: Maximum seconds to wait for browser callback.
613
+
614
+ Returns:
615
+ UserProfile of authenticated user.
616
+
617
+ Raises:
618
+ AuthError, AuthTimeoutError, StateMismatchError, UserCancelledError
619
+ """
620
+ if is_authenticated():
621
+ return get_current_user()
622
+
623
+ state = generate_state()
624
+ code_verifier, code_challenge = generate_pkce_pair()
625
+ auth_url = build_authorization_url(state, code_challenge)
626
+
627
+ config = get_google_config()
628
+ parsed_redirect = urllib.parse.urlparse(config.get("redirect_uri", "http://localhost:8080"))
629
+ port = parsed_redirect.port or 8080
630
+
631
+ OAuthCallbackHandler.received_code = None
632
+ OAuthCallbackHandler.received_state = None
633
+ OAuthCallbackHandler.received_error = None
634
+
635
+ try:
636
+ server = SingleRequestHTTPServer(("localhost", port), OAuthCallbackHandler)
637
+ except OSError as e:
638
+ raise AuthError(f"Could not start local HTTP server on port {port}: {e}") from e
639
+
640
+ server.timeout = timeout_seconds
641
+
642
+ print("Pulse Authentication\n")
643
+ print("Opening your browser...\n")
644
+ print("If your browser doesn't open automatically, visit:\n")
645
+ print(f"{auth_url}\n")
646
+ print("Waiting for authentication...\n")
647
+
648
+ try:
649
+ webbrowser.open(auth_url)
650
+ except (webbrowser.Error, OSError) as err:
651
+ logger.debug(f"Webbrowser open failed: {err}")
652
+
653
+ server.handle_request()
654
+ server.server_close()
655
+
656
+ if OAuthCallbackHandler.received_error:
657
+ raise UserCancelledError("Authentication was cancelled in browser.")
658
+
659
+ code = OAuthCallbackHandler.received_code
660
+ cb_state = OAuthCallbackHandler.received_state
661
+
662
+ if not code:
663
+ raise AuthTimeoutError("Authentication timed out waiting for browser login callback.")
664
+
665
+ if cb_state != state:
666
+ raise StateMismatchError("OAuth state verification failed. Possible CSRF attack.")
667
+
668
+ tokens, user_profile = exchange_code_for_tokens(code, code_verifier)
669
+ _token_store.store_session(user_profile, tokens)
670
+ return user_profile