stravinsky 0.2.7__py3-none-any.whl → 0.2.38__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.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/cli.py +84 -46
- mcp_bridge/auth/oauth.py +88 -63
- mcp_bridge/hooks/__init__.py +29 -8
- mcp_bridge/hooks/agent_reminder.py +61 -0
- mcp_bridge/hooks/auto_slash_command.py +186 -0
- mcp_bridge/hooks/comment_checker.py +136 -0
- mcp_bridge/hooks/context_monitor.py +58 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
- mcp_bridge/hooks/keyword_detector.py +122 -0
- mcp_bridge/hooks/manager.py +27 -8
- mcp_bridge/hooks/preemptive_compaction.py +157 -0
- mcp_bridge/hooks/session_recovery.py +186 -0
- mcp_bridge/hooks/todo_enforcer.py +75 -0
- mcp_bridge/hooks/truncator.py +1 -1
- mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
- mcp_bridge/native_hooks/truncator.py +1 -1
- mcp_bridge/prompts/delphi.py +3 -2
- mcp_bridge/prompts/dewey.py +105 -21
- mcp_bridge/prompts/stravinsky.py +451 -127
- mcp_bridge/server.py +304 -38
- mcp_bridge/server_tools.py +21 -3
- mcp_bridge/tools/__init__.py +2 -1
- mcp_bridge/tools/agent_manager.py +307 -230
- mcp_bridge/tools/init.py +1 -1
- mcp_bridge/tools/model_invoke.py +534 -52
- mcp_bridge/tools/skill_loader.py +51 -47
- mcp_bridge/tools/task_runner.py +74 -30
- mcp_bridge/tools/templates.py +101 -12
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/METADATA +6 -12
- stravinsky-0.2.38.dist-info/RECORD +57 -0
- stravinsky-0.2.7.dist-info/RECORD +0 -47
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
mcp_bridge/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.2.
|
|
1
|
+
__version__ = "0.2.32"
|
mcp_bridge/auth/cli.py
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Authentication CLI
|
|
2
|
+
Stravinsky Authentication CLI (stravinsky-auth)
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
OAuth authentication for Gemini (Google) and OpenAI (ChatGPT Plus/Pro).
|
|
5
|
+
No API keys required - uses browser-based OAuth flows.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
stravinsky-auth login gemini # Authenticate with Google (Gemini)
|
|
9
|
+
stravinsky-auth login openai # Authenticate with OpenAI (ChatGPT Plus/Pro)
|
|
10
|
+
stravinsky-auth status # Check authentication status
|
|
11
|
+
stravinsky-auth logout gemini # Remove stored credentials
|
|
12
|
+
stravinsky-auth refresh gemini # Manually refresh access token
|
|
13
|
+
stravinsky-auth init # Bootstrap current repository
|
|
5
14
|
"""
|
|
6
15
|
|
|
7
16
|
import argparse
|
|
@@ -20,53 +29,53 @@ from .openai_oauth import (
|
|
|
20
29
|
def cmd_login(provider: str, token_store: TokenStore) -> int:
|
|
21
30
|
"""
|
|
22
31
|
Perform OAuth login for a provider.
|
|
23
|
-
|
|
32
|
+
|
|
24
33
|
For Gemini: Uses Google OAuth with Antigravity credentials
|
|
25
34
|
For OpenAI: Uses OpenAI OAuth (ChatGPT Plus/Pro subscription)
|
|
26
35
|
"""
|
|
27
36
|
if provider == "gemini":
|
|
28
37
|
print(f"Starting Google OAuth for Gemini...")
|
|
29
|
-
|
|
38
|
+
|
|
30
39
|
try:
|
|
31
40
|
result = gemini_oauth()
|
|
32
|
-
|
|
41
|
+
|
|
33
42
|
expires_at = int(time.time()) + result.tokens.expires_in
|
|
34
|
-
|
|
43
|
+
|
|
35
44
|
token_store.set_token(
|
|
36
45
|
provider="gemini",
|
|
37
46
|
access_token=result.tokens.access_token,
|
|
38
47
|
refresh_token=result.tokens.refresh_token,
|
|
39
48
|
expires_at=expires_at,
|
|
40
49
|
)
|
|
41
|
-
|
|
50
|
+
|
|
42
51
|
print(f"\n✓ Successfully authenticated as: {result.user_info.email}")
|
|
43
52
|
print(f" Token expires in: {result.tokens.expires_in // 60} minutes")
|
|
44
53
|
return 0
|
|
45
|
-
|
|
54
|
+
|
|
46
55
|
except Exception as e:
|
|
47
56
|
print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
|
|
48
57
|
return 1
|
|
49
|
-
|
|
58
|
+
|
|
50
59
|
elif provider == "openai":
|
|
51
60
|
print(f"Starting OpenAI OAuth for ChatGPT Plus/Pro...")
|
|
52
61
|
print("Note: Requires ChatGPT Plus/Pro subscription and port 1455 available")
|
|
53
|
-
|
|
62
|
+
|
|
54
63
|
try:
|
|
55
64
|
result = openai_oauth()
|
|
56
|
-
|
|
65
|
+
|
|
57
66
|
expires_at = int(time.time()) + result.expires_in
|
|
58
|
-
|
|
67
|
+
|
|
59
68
|
token_store.set_token(
|
|
60
69
|
provider="openai",
|
|
61
70
|
access_token=result.access_token,
|
|
62
71
|
refresh_token=result.refresh_token,
|
|
63
72
|
expires_at=expires_at,
|
|
64
73
|
)
|
|
65
|
-
|
|
74
|
+
|
|
66
75
|
print(f"\n✓ Successfully authenticated with OpenAI")
|
|
67
76
|
print(f" Token expires in: {result.expires_in // 60} minutes")
|
|
68
77
|
return 0
|
|
69
|
-
|
|
78
|
+
|
|
70
79
|
except Exception as e:
|
|
71
80
|
print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
|
|
72
81
|
print("\nTroubleshooting:")
|
|
@@ -74,7 +83,7 @@ def cmd_login(provider: str, token_store: TokenStore) -> int:
|
|
|
74
83
|
print(" - Stop Codex CLI if running: killall codex")
|
|
75
84
|
print(" - Or use Codex CLI directly: codex login")
|
|
76
85
|
return 1
|
|
77
|
-
|
|
86
|
+
|
|
78
87
|
else:
|
|
79
88
|
print(f"Unknown provider: {provider}", file=sys.stderr)
|
|
80
89
|
print("Supported providers: gemini, openai")
|
|
@@ -91,12 +100,12 @@ def cmd_logout(provider: str, token_store: TokenStore) -> int:
|
|
|
91
100
|
def cmd_status(token_store: TokenStore) -> int:
|
|
92
101
|
"""Show authentication status for all providers."""
|
|
93
102
|
print("Authentication Status:\n")
|
|
94
|
-
|
|
103
|
+
|
|
95
104
|
for provider in ["gemini", "openai"]:
|
|
96
105
|
has_token = token_store.has_valid_token(provider)
|
|
97
106
|
status = "✓ Authenticated" if has_token else "✗ Not authenticated"
|
|
98
107
|
print(f" {provider.capitalize()}: {status}")
|
|
99
|
-
|
|
108
|
+
|
|
100
109
|
if has_token:
|
|
101
110
|
token = token_store.get_token(provider)
|
|
102
111
|
if token and token.get("expires_at"):
|
|
@@ -108,7 +117,7 @@ def cmd_status(token_store: TokenStore) -> int:
|
|
|
108
117
|
print(f" Expires in: {hours}h {minutes}m")
|
|
109
118
|
else:
|
|
110
119
|
print(f" Token expired")
|
|
111
|
-
|
|
120
|
+
|
|
112
121
|
print()
|
|
113
122
|
return 0
|
|
114
123
|
|
|
@@ -120,10 +129,10 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
|
|
|
120
129
|
print(f"No refresh token found for {provider}")
|
|
121
130
|
print(f"Please run: python -m mcp_bridge.auth.cli login {provider}")
|
|
122
131
|
return 1
|
|
123
|
-
|
|
132
|
+
|
|
124
133
|
try:
|
|
125
134
|
print(f"Refreshing {provider} token...")
|
|
126
|
-
|
|
135
|
+
|
|
127
136
|
if provider == "gemini":
|
|
128
137
|
result = gemini_refresh(token["refresh_token"])
|
|
129
138
|
elif provider == "openai":
|
|
@@ -131,19 +140,19 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
|
|
|
131
140
|
else:
|
|
132
141
|
print(f"Refresh not supported for {provider}")
|
|
133
142
|
return 1
|
|
134
|
-
|
|
143
|
+
|
|
135
144
|
expires_at = int(time.time()) + result.expires_in
|
|
136
|
-
|
|
145
|
+
|
|
137
146
|
token_store.set_token(
|
|
138
147
|
provider=provider,
|
|
139
148
|
access_token=result.access_token,
|
|
140
149
|
refresh_token=result.refresh_token or token["refresh_token"],
|
|
141
150
|
expires_at=expires_at,
|
|
142
151
|
)
|
|
143
|
-
|
|
152
|
+
|
|
144
153
|
print(f"✓ Token refreshed, expires in {result.expires_in // 60} minutes")
|
|
145
154
|
return 0
|
|
146
|
-
|
|
155
|
+
|
|
147
156
|
except Exception as e:
|
|
148
157
|
print(f"✗ Refresh failed: {e}", file=sys.stderr)
|
|
149
158
|
return 1
|
|
@@ -152,50 +161,79 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
|
|
|
152
161
|
def main():
|
|
153
162
|
"""CLI entry point."""
|
|
154
163
|
parser = argparse.ArgumentParser(
|
|
155
|
-
description="Stravinsky
|
|
156
|
-
|
|
164
|
+
description="Stravinsky OAuth authentication for Gemini and OpenAI. "
|
|
165
|
+
"Authenticate via browser-based OAuth flows (no API keys).",
|
|
166
|
+
prog="stravinsky-auth",
|
|
167
|
+
epilog="Examples:\n"
|
|
168
|
+
" stravinsky-auth login gemini # Login to Google (Gemini)\n"
|
|
169
|
+
" stravinsky-auth login openai # Login to OpenAI (ChatGPT Plus/Pro)\n"
|
|
170
|
+
" stravinsky-auth status # Check all provider status\n",
|
|
171
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
157
172
|
)
|
|
158
|
-
|
|
159
|
-
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
160
|
-
|
|
173
|
+
|
|
174
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands", metavar="COMMAND")
|
|
175
|
+
|
|
161
176
|
# login command
|
|
162
|
-
login_parser = subparsers.add_parser(
|
|
177
|
+
login_parser = subparsers.add_parser(
|
|
178
|
+
"login",
|
|
179
|
+
help="Authenticate with a provider via browser OAuth",
|
|
180
|
+
description="Opens browser for OAuth authentication. Tokens are stored securely in system keyring.",
|
|
181
|
+
)
|
|
163
182
|
login_parser.add_argument(
|
|
164
183
|
"provider",
|
|
165
184
|
choices=["gemini", "openai"],
|
|
166
|
-
|
|
185
|
+
metavar="PROVIDER",
|
|
186
|
+
help="Provider to authenticate with: gemini (Google) or openai (ChatGPT Plus/Pro)",
|
|
167
187
|
)
|
|
168
|
-
|
|
188
|
+
|
|
169
189
|
# logout command
|
|
170
|
-
logout_parser = subparsers.add_parser(
|
|
190
|
+
logout_parser = subparsers.add_parser(
|
|
191
|
+
"logout",
|
|
192
|
+
help="Remove stored OAuth credentials",
|
|
193
|
+
description="Deletes stored access and refresh tokens for the specified provider.",
|
|
194
|
+
)
|
|
171
195
|
logout_parser.add_argument(
|
|
172
196
|
"provider",
|
|
173
197
|
choices=["gemini", "openai"],
|
|
174
|
-
|
|
198
|
+
metavar="PROVIDER",
|
|
199
|
+
help="Provider to logout from: gemini or openai",
|
|
175
200
|
)
|
|
176
|
-
|
|
201
|
+
|
|
177
202
|
# status command
|
|
178
|
-
subparsers.add_parser(
|
|
179
|
-
|
|
203
|
+
subparsers.add_parser(
|
|
204
|
+
"status",
|
|
205
|
+
help="Show authentication status for all providers",
|
|
206
|
+
description="Displays authentication status and token expiration for Gemini and OpenAI.",
|
|
207
|
+
)
|
|
208
|
+
|
|
180
209
|
# refresh command
|
|
181
|
-
refresh_parser = subparsers.add_parser(
|
|
210
|
+
refresh_parser = subparsers.add_parser(
|
|
211
|
+
"refresh",
|
|
212
|
+
help="Manually refresh access token",
|
|
213
|
+
description="Force-refresh the access token using the stored refresh token.",
|
|
214
|
+
)
|
|
182
215
|
refresh_parser.add_argument(
|
|
183
216
|
"provider",
|
|
184
217
|
choices=["gemini", "openai"],
|
|
185
|
-
|
|
218
|
+
metavar="PROVIDER",
|
|
219
|
+
help="Provider to refresh token for: gemini or openai",
|
|
186
220
|
)
|
|
187
|
-
|
|
221
|
+
|
|
188
222
|
# init command
|
|
189
|
-
subparsers.add_parser(
|
|
190
|
-
|
|
223
|
+
subparsers.add_parser(
|
|
224
|
+
"init",
|
|
225
|
+
help="Bootstrap current repository for Stravinsky",
|
|
226
|
+
description="Creates .stravinsky/ directory structure and copies default configuration files.",
|
|
227
|
+
)
|
|
228
|
+
|
|
191
229
|
args = parser.parse_args()
|
|
192
|
-
|
|
230
|
+
|
|
193
231
|
if not args.command:
|
|
194
232
|
parser.print_help()
|
|
195
233
|
return 1
|
|
196
|
-
|
|
234
|
+
|
|
197
235
|
token_store = TokenStore()
|
|
198
|
-
|
|
236
|
+
|
|
199
237
|
if args.command == "login":
|
|
200
238
|
return cmd_login(args.provider, token_store)
|
|
201
239
|
elif args.command == "logout":
|
|
@@ -207,7 +245,7 @@ def main():
|
|
|
207
245
|
elif args.command == "init":
|
|
208
246
|
print(bootstrap_repo())
|
|
209
247
|
return 0
|
|
210
|
-
|
|
248
|
+
|
|
211
249
|
return 0
|
|
212
250
|
|
|
213
251
|
|
mcp_bridge/auth/oauth.py
CHANGED
|
@@ -37,12 +37,26 @@ ANTIGRAVITY_SCOPES = [
|
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
# API Endpoints
|
|
40
|
+
# NOTE: Prefer production; sandbox endpoints may require special access.
|
|
41
|
+
ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"
|
|
42
|
+
ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
|
43
|
+
ANTIGRAVITY_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"
|
|
44
|
+
|
|
45
|
+
# Default to production only.
|
|
46
|
+
# Set STRAVINSKY_ANTIGRAVITY_ENABLE_SANDBOX_ENDPOINTS=1 to also try sandbox endpoints.
|
|
40
47
|
ANTIGRAVITY_ENDPOINTS = [
|
|
41
|
-
|
|
42
|
-
"https://autopush-cloudcode-pa.sandbox.googleapis.com", # staging
|
|
43
|
-
"https://cloudcode-pa.googleapis.com", # prod
|
|
48
|
+
ANTIGRAVITY_ENDPOINT_PROD,
|
|
44
49
|
]
|
|
45
50
|
|
|
51
|
+
if os.getenv("STRAVINSKY_ANTIGRAVITY_ENABLE_SANDBOX_ENDPOINTS") in {"1", "true", "True"}:
|
|
52
|
+
ANTIGRAVITY_ENDPOINTS.extend(
|
|
53
|
+
[
|
|
54
|
+
ANTIGRAVITY_ENDPOINT_DAILY,
|
|
55
|
+
ANTIGRAVITY_ENDPOINT_AUTOPUSH,
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
46
60
|
# Default Project ID
|
|
47
61
|
ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
|
48
62
|
|
|
@@ -50,14 +64,18 @@ ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
|
|
50
64
|
ANTIGRAVITY_API_VERSION = "v1internal"
|
|
51
65
|
|
|
52
66
|
# Request Headers
|
|
67
|
+
# Per API spec: User-Agent should be "antigravity/{version} {platform}/{arch}"
|
|
53
68
|
ANTIGRAVITY_HEADERS = {
|
|
54
|
-
"User-Agent": "
|
|
69
|
+
"User-Agent": "antigravity/1.11.5 windows/amd64",
|
|
55
70
|
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
56
|
-
"Client-Metadata": json.dumps(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
"Client-Metadata": json.dumps(
|
|
72
|
+
{
|
|
73
|
+
"ideType": "IDE_UNSPECIFIED",
|
|
74
|
+
"platform": "PLATFORM_UNSPECIFIED",
|
|
75
|
+
"pluginType": "GEMINI",
|
|
76
|
+
},
|
|
77
|
+
separators=(",", ":"),
|
|
78
|
+
),
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
# Google OAuth endpoints
|
|
@@ -72,6 +90,7 @@ TOKEN_REFRESH_BUFFER_MS = 60_000
|
|
|
72
90
|
@dataclass
|
|
73
91
|
class PKCEPair:
|
|
74
92
|
"""PKCE verifier and challenge pair."""
|
|
93
|
+
|
|
75
94
|
verifier: str
|
|
76
95
|
challenge: str
|
|
77
96
|
method: str = "S256"
|
|
@@ -80,6 +99,7 @@ class PKCEPair:
|
|
|
80
99
|
@dataclass
|
|
81
100
|
class TokenResult:
|
|
82
101
|
"""OAuth token exchange result."""
|
|
102
|
+
|
|
83
103
|
access_token: str
|
|
84
104
|
refresh_token: str
|
|
85
105
|
expires_in: int
|
|
@@ -89,6 +109,7 @@ class TokenResult:
|
|
|
89
109
|
@dataclass
|
|
90
110
|
class UserInfo:
|
|
91
111
|
"""User info from Google."""
|
|
112
|
+
|
|
92
113
|
email: str
|
|
93
114
|
name: str | None = None
|
|
94
115
|
picture: str | None = None
|
|
@@ -97,6 +118,7 @@ class UserInfo:
|
|
|
97
118
|
@dataclass
|
|
98
119
|
class OAuthResult:
|
|
99
120
|
"""Complete OAuth flow result."""
|
|
121
|
+
|
|
100
122
|
tokens: TokenResult
|
|
101
123
|
user_info: UserInfo
|
|
102
124
|
verifier: str
|
|
@@ -105,17 +127,17 @@ class OAuthResult:
|
|
|
105
127
|
def generate_pkce_pair() -> PKCEPair:
|
|
106
128
|
"""
|
|
107
129
|
Generate PKCE verifier and challenge pair.
|
|
108
|
-
|
|
130
|
+
|
|
109
131
|
Uses SHA-256 for the challenge (S256 method).
|
|
110
132
|
"""
|
|
111
133
|
# Generate 32-byte random verifier
|
|
112
134
|
verifier_bytes = secrets.token_bytes(32)
|
|
113
135
|
verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode("ascii")
|
|
114
|
-
|
|
136
|
+
|
|
115
137
|
# Generate challenge from verifier using SHA-256
|
|
116
138
|
challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
117
139
|
challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
|
|
118
|
-
|
|
140
|
+
|
|
119
141
|
return PKCEPair(verifier=verifier, challenge=challenge, method="S256")
|
|
120
142
|
|
|
121
143
|
|
|
@@ -135,7 +157,7 @@ def decode_state(encoded: str) -> dict[str, Any]:
|
|
|
135
157
|
padding = 4 - len(encoded) % 4
|
|
136
158
|
if padding != 4:
|
|
137
159
|
encoded += "=" * padding
|
|
138
|
-
|
|
160
|
+
|
|
139
161
|
try:
|
|
140
162
|
decoded = base64.b64decode(encoded).decode("utf-8")
|
|
141
163
|
return json.loads(decoded)
|
|
@@ -150,15 +172,15 @@ def build_auth_url(
|
|
|
150
172
|
) -> tuple[str, str]:
|
|
151
173
|
"""
|
|
152
174
|
Build Google OAuth authorization URL with PKCE.
|
|
153
|
-
|
|
175
|
+
|
|
154
176
|
Returns:
|
|
155
177
|
Tuple of (auth_url, pkce_verifier)
|
|
156
178
|
"""
|
|
157
179
|
pkce = generate_pkce_pair()
|
|
158
|
-
|
|
180
|
+
|
|
159
181
|
redirect_uri = f"http://localhost:{port}/oauth-callback"
|
|
160
182
|
state = encode_state(pkce.verifier, project_id)
|
|
161
|
-
|
|
183
|
+
|
|
162
184
|
params = {
|
|
163
185
|
"client_id": client_id,
|
|
164
186
|
"redirect_uri": redirect_uri,
|
|
@@ -170,7 +192,7 @@ def build_auth_url(
|
|
|
170
192
|
"access_type": "offline",
|
|
171
193
|
"prompt": "consent",
|
|
172
194
|
}
|
|
173
|
-
|
|
195
|
+
|
|
174
196
|
url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
|
|
175
197
|
return url, pkce.verifier
|
|
176
198
|
|
|
@@ -184,17 +206,17 @@ def exchange_code(
|
|
|
184
206
|
) -> TokenResult:
|
|
185
207
|
"""
|
|
186
208
|
Exchange authorization code for tokens.
|
|
187
|
-
|
|
209
|
+
|
|
188
210
|
Args:
|
|
189
211
|
code: Authorization code from OAuth callback
|
|
190
212
|
verifier: PKCE verifier from initial auth request
|
|
191
213
|
port: Callback server port
|
|
192
|
-
|
|
214
|
+
|
|
193
215
|
Returns:
|
|
194
216
|
Token exchange result with access and refresh tokens.
|
|
195
217
|
"""
|
|
196
218
|
redirect_uri = f"http://localhost:{port}/oauth-callback"
|
|
197
|
-
|
|
219
|
+
|
|
198
220
|
data = {
|
|
199
221
|
"client_id": client_id,
|
|
200
222
|
"client_secret": client_secret,
|
|
@@ -203,19 +225,19 @@ def exchange_code(
|
|
|
203
225
|
"redirect_uri": redirect_uri,
|
|
204
226
|
"code_verifier": verifier,
|
|
205
227
|
}
|
|
206
|
-
|
|
228
|
+
|
|
207
229
|
with httpx.Client() as client:
|
|
208
230
|
response = client.post(
|
|
209
231
|
GOOGLE_TOKEN_URL,
|
|
210
232
|
data=data,
|
|
211
233
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
212
234
|
)
|
|
213
|
-
|
|
235
|
+
|
|
214
236
|
if response.status_code != 200:
|
|
215
237
|
raise Exception(f"Token exchange failed: {response.status_code} - {response.text}")
|
|
216
|
-
|
|
238
|
+
|
|
217
239
|
result = response.json()
|
|
218
|
-
|
|
240
|
+
|
|
219
241
|
return TokenResult(
|
|
220
242
|
access_token=result["access_token"],
|
|
221
243
|
refresh_token=result.get("refresh_token", ""),
|
|
@@ -231,10 +253,10 @@ def refresh_access_token(
|
|
|
231
253
|
) -> TokenResult:
|
|
232
254
|
"""
|
|
233
255
|
Refresh an access token using a refresh token.
|
|
234
|
-
|
|
256
|
+
|
|
235
257
|
Args:
|
|
236
258
|
refresh_token: Valid refresh token
|
|
237
|
-
|
|
259
|
+
|
|
238
260
|
Returns:
|
|
239
261
|
New token result.
|
|
240
262
|
"""
|
|
@@ -244,19 +266,19 @@ def refresh_access_token(
|
|
|
244
266
|
"refresh_token": refresh_token,
|
|
245
267
|
"grant_type": "refresh_token",
|
|
246
268
|
}
|
|
247
|
-
|
|
269
|
+
|
|
248
270
|
with httpx.Client() as client:
|
|
249
271
|
response = client.post(
|
|
250
272
|
GOOGLE_TOKEN_URL,
|
|
251
273
|
data=data,
|
|
252
274
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
253
275
|
)
|
|
254
|
-
|
|
276
|
+
|
|
255
277
|
if response.status_code != 200:
|
|
256
278
|
raise Exception(f"Token refresh failed: {response.status_code} - {response.text}")
|
|
257
|
-
|
|
279
|
+
|
|
258
280
|
result = response.json()
|
|
259
|
-
|
|
281
|
+
|
|
260
282
|
return TokenResult(
|
|
261
283
|
access_token=result["access_token"],
|
|
262
284
|
refresh_token=refresh_token, # Keep original refresh token
|
|
@@ -268,10 +290,10 @@ def refresh_access_token(
|
|
|
268
290
|
def fetch_user_info(access_token: str) -> UserInfo:
|
|
269
291
|
"""
|
|
270
292
|
Fetch user info from Google's userinfo API.
|
|
271
|
-
|
|
293
|
+
|
|
272
294
|
Args:
|
|
273
295
|
access_token: Valid access token
|
|
274
|
-
|
|
296
|
+
|
|
275
297
|
Returns:
|
|
276
298
|
User info with email, name, and picture.
|
|
277
299
|
"""
|
|
@@ -280,12 +302,12 @@ def fetch_user_info(access_token: str) -> UserInfo:
|
|
|
280
302
|
f"{GOOGLE_USERINFO_URL}?alt=json",
|
|
281
303
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
282
304
|
)
|
|
283
|
-
|
|
305
|
+
|
|
284
306
|
if response.status_code != 200:
|
|
285
307
|
raise Exception(f"Failed to fetch user info: {response.status_code}")
|
|
286
|
-
|
|
308
|
+
|
|
287
309
|
data = response.json()
|
|
288
|
-
|
|
310
|
+
|
|
289
311
|
return UserInfo(
|
|
290
312
|
email=data.get("email", ""),
|
|
291
313
|
name=data.get("name"),
|
|
@@ -295,38 +317,41 @@ def fetch_user_info(access_token: str) -> UserInfo:
|
|
|
295
317
|
|
|
296
318
|
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
297
319
|
"""HTTP request handler for OAuth callback."""
|
|
298
|
-
|
|
320
|
+
|
|
299
321
|
callback_result: dict[str, Any] = {}
|
|
300
322
|
server_ready = threading.Event()
|
|
301
|
-
|
|
323
|
+
|
|
302
324
|
def log_message(self, format, *args):
|
|
303
325
|
"""Suppress HTTP server logs."""
|
|
304
326
|
pass
|
|
305
|
-
|
|
327
|
+
|
|
306
328
|
def do_GET(self):
|
|
307
329
|
"""Handle OAuth callback GET request."""
|
|
308
330
|
parsed = urlparse(self.path)
|
|
309
|
-
|
|
331
|
+
|
|
310
332
|
if parsed.path == "/oauth-callback":
|
|
311
333
|
params = parse_qs(parsed.query)
|
|
312
|
-
|
|
334
|
+
|
|
313
335
|
OAuthCallbackHandler.callback_result = {
|
|
314
336
|
"code": params.get("code", [""])[0],
|
|
315
337
|
"state": params.get("state", [""])[0],
|
|
316
338
|
"error": params.get("error", [None])[0],
|
|
317
339
|
}
|
|
318
|
-
|
|
319
|
-
if
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
OAuthCallbackHandler.callback_result["code"]
|
|
343
|
+
and not OAuthCallbackHandler.callback_result["error"]
|
|
344
|
+
):
|
|
320
345
|
body = b"<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>"
|
|
321
346
|
else:
|
|
322
347
|
body = b"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
|
|
323
|
-
|
|
348
|
+
|
|
324
349
|
self.send_response(200)
|
|
325
350
|
self.send_header("Content-Type", "text/html")
|
|
326
351
|
self.send_header("Content-Length", str(len(body)))
|
|
327
352
|
self.end_headers()
|
|
328
353
|
self.wfile.write(body)
|
|
329
|
-
|
|
354
|
+
|
|
330
355
|
# Signal completion
|
|
331
356
|
OAuthCallbackHandler.server_ready.set()
|
|
332
357
|
else:
|
|
@@ -340,68 +365,68 @@ def perform_oauth_flow(
|
|
|
340
365
|
) -> OAuthResult:
|
|
341
366
|
"""
|
|
342
367
|
Perform full OAuth flow with browser-based authentication.
|
|
343
|
-
|
|
368
|
+
|
|
344
369
|
1. Start local callback server
|
|
345
370
|
2. Open browser for Google auth
|
|
346
371
|
3. Wait for callback
|
|
347
372
|
4. Exchange code for tokens
|
|
348
373
|
5. Fetch user info
|
|
349
|
-
|
|
374
|
+
|
|
350
375
|
Args:
|
|
351
376
|
project_id: Optional project ID for state
|
|
352
377
|
timeout: Timeout in seconds (default 5 minutes)
|
|
353
|
-
|
|
378
|
+
|
|
354
379
|
Returns:
|
|
355
380
|
Complete OAuth result with tokens and user info.
|
|
356
381
|
"""
|
|
357
382
|
# Reset callback state
|
|
358
383
|
OAuthCallbackHandler.callback_result = {}
|
|
359
384
|
OAuthCallbackHandler.server_ready.clear()
|
|
360
|
-
|
|
385
|
+
|
|
361
386
|
# Start callback server
|
|
362
387
|
server = HTTPServer(("localhost", 0), OAuthCallbackHandler)
|
|
363
388
|
port = server.server_address[1]
|
|
364
|
-
|
|
389
|
+
|
|
365
390
|
server_thread = threading.Thread(target=lambda: server.handle_request())
|
|
366
391
|
server_thread.daemon = True
|
|
367
392
|
server_thread.start()
|
|
368
|
-
|
|
393
|
+
|
|
369
394
|
try:
|
|
370
395
|
# Build auth URL and open browser
|
|
371
396
|
auth_url, verifier = build_auth_url(port, project_id)
|
|
372
|
-
|
|
373
|
-
print(
|
|
397
|
+
|
|
398
|
+
print("\nOpening browser for Google authentication...")
|
|
374
399
|
print(f"If browser doesn't open, visit:\n{auth_url}\n")
|
|
375
|
-
|
|
400
|
+
|
|
376
401
|
webbrowser.open(auth_url)
|
|
377
|
-
|
|
402
|
+
|
|
378
403
|
# Wait for callback
|
|
379
404
|
if not OAuthCallbackHandler.server_ready.wait(timeout):
|
|
380
405
|
raise Exception("OAuth callback timeout")
|
|
381
|
-
|
|
406
|
+
|
|
382
407
|
result = OAuthCallbackHandler.callback_result
|
|
383
|
-
|
|
408
|
+
|
|
384
409
|
if result.get("error"):
|
|
385
410
|
raise Exception(f"OAuth error: {result['error']}")
|
|
386
|
-
|
|
411
|
+
|
|
387
412
|
if not result.get("code"):
|
|
388
413
|
raise Exception("No authorization code received")
|
|
389
|
-
|
|
414
|
+
|
|
390
415
|
# Verify state
|
|
391
416
|
state = decode_state(result["state"])
|
|
392
417
|
if state.get("verifier") != verifier:
|
|
393
418
|
raise Exception("PKCE verifier mismatch - possible security issue")
|
|
394
|
-
|
|
419
|
+
|
|
395
420
|
# Exchange code for tokens
|
|
396
421
|
tokens = exchange_code(result["code"], verifier, port)
|
|
397
|
-
|
|
422
|
+
|
|
398
423
|
# Fetch user info
|
|
399
424
|
user_info = fetch_user_info(tokens.access_token)
|
|
400
|
-
|
|
425
|
+
|
|
401
426
|
print(f"✓ Authenticated as: {user_info.email}")
|
|
402
|
-
|
|
427
|
+
|
|
403
428
|
return OAuthResult(tokens=tokens, user_info=user_info, verifier=verifier)
|
|
404
|
-
|
|
429
|
+
|
|
405
430
|
finally:
|
|
406
431
|
server.server_close()
|
|
407
432
|
|