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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- 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
|