stravinsky 0.1.12__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 +5 -0
- mcp_bridge/auth/__init__.py +32 -0
- mcp_bridge/auth/cli.py +208 -0
- mcp_bridge/auth/oauth.py +418 -0
- mcp_bridge/auth/openai_oauth.py +350 -0
- mcp_bridge/auth/token_store.py +195 -0
- mcp_bridge/config/__init__.py +14 -0
- mcp_bridge/config/hooks.py +174 -0
- mcp_bridge/prompts/__init__.py +18 -0
- mcp_bridge/prompts/delphi.py +110 -0
- mcp_bridge/prompts/dewey.py +183 -0
- mcp_bridge/prompts/document_writer.py +155 -0
- mcp_bridge/prompts/explore.py +118 -0
- mcp_bridge/prompts/frontend.py +112 -0
- mcp_bridge/prompts/multimodal.py +58 -0
- mcp_bridge/prompts/stravinsky.py +329 -0
- mcp_bridge/server.py +866 -0
- mcp_bridge/tools/__init__.py +31 -0
- mcp_bridge/tools/agent_manager.py +665 -0
- mcp_bridge/tools/background_tasks.py +166 -0
- mcp_bridge/tools/code_search.py +301 -0
- mcp_bridge/tools/continuous_loop.py +67 -0
- mcp_bridge/tools/lsp/__init__.py +29 -0
- mcp_bridge/tools/lsp/tools.py +526 -0
- mcp_bridge/tools/model_invoke.py +233 -0
- mcp_bridge/tools/project_context.py +141 -0
- mcp_bridge/tools/session_manager.py +302 -0
- mcp_bridge/tools/skill_loader.py +212 -0
- mcp_bridge/tools/task_runner.py +97 -0
- mcp_bridge/utils/__init__.py +1 -0
- stravinsky-0.1.12.dist-info/METADATA +198 -0
- stravinsky-0.1.12.dist-info/RECORD +34 -0
- stravinsky-0.1.12.dist-info/WHEEL +4 -0
- stravinsky-0.1.12.dist-info/entry_points.txt +3 -0
mcp_bridge/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Authentication module
|
|
2
|
+
from .token_store import TokenStore, TokenData
|
|
3
|
+
from .oauth import (
|
|
4
|
+
perform_oauth_flow as gemini_oauth_flow,
|
|
5
|
+
refresh_access_token as gemini_refresh_token,
|
|
6
|
+
ANTIGRAVITY_CLIENT_ID,
|
|
7
|
+
ANTIGRAVITY_SCOPES,
|
|
8
|
+
ANTIGRAVITY_HEADERS,
|
|
9
|
+
)
|
|
10
|
+
from .openai_oauth import (
|
|
11
|
+
perform_oauth_flow as openai_oauth_flow,
|
|
12
|
+
refresh_access_token as openai_refresh_token,
|
|
13
|
+
CLIENT_ID as OPENAI_CLIENT_ID,
|
|
14
|
+
OPENAI_CALLBACK_PORT,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Token Store
|
|
19
|
+
"TokenStore",
|
|
20
|
+
"TokenData",
|
|
21
|
+
# Gemini OAuth
|
|
22
|
+
"gemini_oauth_flow",
|
|
23
|
+
"gemini_refresh_token",
|
|
24
|
+
"ANTIGRAVITY_CLIENT_ID",
|
|
25
|
+
"ANTIGRAVITY_SCOPES",
|
|
26
|
+
"ANTIGRAVITY_HEADERS",
|
|
27
|
+
# OpenAI OAuth
|
|
28
|
+
"openai_oauth_flow",
|
|
29
|
+
"openai_refresh_token",
|
|
30
|
+
"OPENAI_CLIENT_ID",
|
|
31
|
+
"OPENAI_CALLBACK_PORT",
|
|
32
|
+
]
|
mcp_bridge/auth/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication CLI for Claude Superagent MCP Bridge
|
|
3
|
+
|
|
4
|
+
Handles OAuth authentication for Gemini and OpenAI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .token_store import TokenStore
|
|
12
|
+
from .oauth import perform_oauth_flow as gemini_oauth, refresh_access_token as gemini_refresh
|
|
13
|
+
from .openai_oauth import (
|
|
14
|
+
perform_oauth_flow as openai_oauth,
|
|
15
|
+
refresh_access_token as openai_refresh,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_login(provider: str, token_store: TokenStore) -> int:
|
|
20
|
+
"""
|
|
21
|
+
Perform OAuth login for a provider.
|
|
22
|
+
|
|
23
|
+
For Gemini: Uses Google OAuth with Antigravity credentials
|
|
24
|
+
For OpenAI: Uses OpenAI OAuth (ChatGPT Plus/Pro subscription)
|
|
25
|
+
"""
|
|
26
|
+
if provider == "gemini":
|
|
27
|
+
print(f"Starting Google OAuth for Gemini...")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
result = gemini_oauth()
|
|
31
|
+
|
|
32
|
+
expires_at = int(time.time()) + result.tokens.expires_in
|
|
33
|
+
|
|
34
|
+
token_store.set_token(
|
|
35
|
+
provider="gemini",
|
|
36
|
+
access_token=result.tokens.access_token,
|
|
37
|
+
refresh_token=result.tokens.refresh_token,
|
|
38
|
+
expires_at=expires_at,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
print(f"\n✓ Successfully authenticated as: {result.user_info.email}")
|
|
42
|
+
print(f" Token expires in: {result.tokens.expires_in // 60} minutes")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
elif provider == "openai":
|
|
50
|
+
print(f"Starting OpenAI OAuth for ChatGPT Plus/Pro...")
|
|
51
|
+
print("Note: Requires ChatGPT Plus/Pro subscription and port 1455 available")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
result = openai_oauth()
|
|
55
|
+
|
|
56
|
+
expires_at = int(time.time()) + result.expires_in
|
|
57
|
+
|
|
58
|
+
token_store.set_token(
|
|
59
|
+
provider="openai",
|
|
60
|
+
access_token=result.access_token,
|
|
61
|
+
refresh_token=result.refresh_token,
|
|
62
|
+
expires_at=expires_at,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
print(f"\n✓ Successfully authenticated with OpenAI")
|
|
66
|
+
print(f" Token expires in: {result.expires_in // 60} minutes")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
|
|
71
|
+
print("\nTroubleshooting:")
|
|
72
|
+
print(" - Ensure you have a ChatGPT Plus/Pro subscription")
|
|
73
|
+
print(" - Stop Codex CLI if running: killall codex")
|
|
74
|
+
print(" - Or use Codex CLI directly: codex login")
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
else:
|
|
78
|
+
print(f"Unknown provider: {provider}", file=sys.stderr)
|
|
79
|
+
print("Supported providers: gemini, openai")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_logout(provider: str, token_store: TokenStore) -> int:
|
|
84
|
+
"""Remove stored tokens for a provider."""
|
|
85
|
+
token_store.delete_token(provider)
|
|
86
|
+
print(f"✓ Logged out from {provider}")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_status(token_store: TokenStore) -> int:
|
|
91
|
+
"""Show authentication status for all providers."""
|
|
92
|
+
print("Authentication Status:\n")
|
|
93
|
+
|
|
94
|
+
for provider in ["gemini", "openai"]:
|
|
95
|
+
has_token = token_store.has_valid_token(provider)
|
|
96
|
+
status = "✓ Authenticated" if has_token else "✗ Not authenticated"
|
|
97
|
+
print(f" {provider.capitalize()}: {status}")
|
|
98
|
+
|
|
99
|
+
if has_token:
|
|
100
|
+
token = token_store.get_token(provider)
|
|
101
|
+
if token and token.get("expires_at"):
|
|
102
|
+
expires = token["expires_at"]
|
|
103
|
+
remaining = expires - int(time.time())
|
|
104
|
+
if remaining > 0:
|
|
105
|
+
hours = remaining // 3600
|
|
106
|
+
minutes = (remaining % 3600) // 60
|
|
107
|
+
print(f" Expires in: {hours}h {minutes}m")
|
|
108
|
+
else:
|
|
109
|
+
print(f" Token expired")
|
|
110
|
+
|
|
111
|
+
print()
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_refresh(provider: str, token_store: TokenStore) -> int:
|
|
116
|
+
"""Refresh access token for a provider."""
|
|
117
|
+
token = token_store.get_token(provider)
|
|
118
|
+
if not token or not token.get("refresh_token"):
|
|
119
|
+
print(f"No refresh token found for {provider}")
|
|
120
|
+
print(f"Please run: python -m mcp_bridge.auth.cli login {provider}")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
print(f"Refreshing {provider} token...")
|
|
125
|
+
|
|
126
|
+
if provider == "gemini":
|
|
127
|
+
result = gemini_refresh(token["refresh_token"])
|
|
128
|
+
elif provider == "openai":
|
|
129
|
+
result = openai_refresh(token["refresh_token"])
|
|
130
|
+
else:
|
|
131
|
+
print(f"Refresh not supported for {provider}")
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
expires_at = int(time.time()) + result.expires_in
|
|
135
|
+
|
|
136
|
+
token_store.set_token(
|
|
137
|
+
provider=provider,
|
|
138
|
+
access_token=result.access_token,
|
|
139
|
+
refresh_token=result.refresh_token or token["refresh_token"],
|
|
140
|
+
expires_at=expires_at,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
print(f"✓ Token refreshed, expires in {result.expires_in // 60} minutes")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print(f"✗ Refresh failed: {e}", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main():
|
|
152
|
+
"""CLI entry point."""
|
|
153
|
+
parser = argparse.ArgumentParser(
|
|
154
|
+
description="Stravinsky Authentication CLI",
|
|
155
|
+
prog="python -m mcp_bridge.auth.cli",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
159
|
+
|
|
160
|
+
# login command
|
|
161
|
+
login_parser = subparsers.add_parser("login", help="Authenticate with a provider")
|
|
162
|
+
login_parser.add_argument(
|
|
163
|
+
"provider",
|
|
164
|
+
choices=["gemini", "openai"],
|
|
165
|
+
help="Provider to authenticate with",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# logout command
|
|
169
|
+
logout_parser = subparsers.add_parser("logout", help="Remove stored credentials")
|
|
170
|
+
logout_parser.add_argument(
|
|
171
|
+
"provider",
|
|
172
|
+
choices=["gemini", "openai"],
|
|
173
|
+
help="Provider to logout from",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# status command
|
|
177
|
+
subparsers.add_parser("status", help="Show authentication status")
|
|
178
|
+
|
|
179
|
+
# refresh command
|
|
180
|
+
refresh_parser = subparsers.add_parser("refresh", help="Refresh access token")
|
|
181
|
+
refresh_parser.add_argument(
|
|
182
|
+
"provider",
|
|
183
|
+
choices=["gemini", "openai"],
|
|
184
|
+
help="Provider to refresh token for",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
args = parser.parse_args()
|
|
188
|
+
|
|
189
|
+
if not args.command:
|
|
190
|
+
parser.print_help()
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
token_store = TokenStore()
|
|
194
|
+
|
|
195
|
+
if args.command == "login":
|
|
196
|
+
return cmd_login(args.provider, token_store)
|
|
197
|
+
elif args.command == "logout":
|
|
198
|
+
return cmd_logout(args.provider, token_store)
|
|
199
|
+
elif args.command == "status":
|
|
200
|
+
return cmd_status(token_store)
|
|
201
|
+
elif args.command == "refresh":
|
|
202
|
+
return cmd_refresh(args.provider, token_store)
|
|
203
|
+
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|
mcp_bridge/auth/oauth.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Antigravity OAuth 2.0 Implementation with PKCE
|
|
3
|
+
|
|
4
|
+
Implements Google OAuth for Antigravity (Gemini) authentication.
|
|
5
|
+
Based on the original TypeScript implementation from Stravinsky.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import webbrowser
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# OAuth 2.0 Client Credentials (from constants.ts)
|
|
24
|
+
ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
|
25
|
+
ANTIGRAVITY_CLIENT_SECRET = "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"
|
|
26
|
+
|
|
27
|
+
# OAuth Callback
|
|
28
|
+
ANTIGRAVITY_CALLBACK_PORT = 51121
|
|
29
|
+
|
|
30
|
+
# OAuth Scopes
|
|
31
|
+
ANTIGRAVITY_SCOPES = [
|
|
32
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
33
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
34
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
35
|
+
"https://www.googleapis.com/auth/cclog",
|
|
36
|
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# API Endpoints
|
|
40
|
+
ANTIGRAVITY_ENDPOINTS = [
|
|
41
|
+
"https://daily-cloudcode-pa.sandbox.googleapis.com", # dev
|
|
42
|
+
"https://autopush-cloudcode-pa.sandbox.googleapis.com", # staging
|
|
43
|
+
"https://cloudcode-pa.googleapis.com", # prod
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Default Project ID
|
|
47
|
+
ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
|
48
|
+
|
|
49
|
+
# API Version
|
|
50
|
+
ANTIGRAVITY_API_VERSION = "v1internal"
|
|
51
|
+
|
|
52
|
+
# Request Headers
|
|
53
|
+
ANTIGRAVITY_HEADERS = {
|
|
54
|
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
|
55
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
56
|
+
"Client-Metadata": json.dumps({
|
|
57
|
+
"ideType": "IDE_UNSPECIFIED",
|
|
58
|
+
"platform": "PLATFORM_UNSPECIFIED",
|
|
59
|
+
"pluginType": "GEMINI",
|
|
60
|
+
}),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Google OAuth endpoints
|
|
64
|
+
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
65
|
+
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
66
|
+
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
|
|
67
|
+
|
|
68
|
+
# Token refresh buffer (60 seconds before expiry)
|
|
69
|
+
TOKEN_REFRESH_BUFFER_MS = 60_000
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class PKCEPair:
|
|
74
|
+
"""PKCE verifier and challenge pair."""
|
|
75
|
+
verifier: str
|
|
76
|
+
challenge: str
|
|
77
|
+
method: str = "S256"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class TokenResult:
|
|
82
|
+
"""OAuth token exchange result."""
|
|
83
|
+
access_token: str
|
|
84
|
+
refresh_token: str
|
|
85
|
+
expires_in: int
|
|
86
|
+
token_type: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class UserInfo:
|
|
91
|
+
"""User info from Google."""
|
|
92
|
+
email: str
|
|
93
|
+
name: str | None = None
|
|
94
|
+
picture: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class OAuthResult:
|
|
99
|
+
"""Complete OAuth flow result."""
|
|
100
|
+
tokens: TokenResult
|
|
101
|
+
user_info: UserInfo
|
|
102
|
+
verifier: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def generate_pkce_pair() -> PKCEPair:
|
|
106
|
+
"""
|
|
107
|
+
Generate PKCE verifier and challenge pair.
|
|
108
|
+
|
|
109
|
+
Uses SHA-256 for the challenge (S256 method).
|
|
110
|
+
"""
|
|
111
|
+
# Generate 32-byte random verifier
|
|
112
|
+
verifier_bytes = secrets.token_bytes(32)
|
|
113
|
+
verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode("ascii")
|
|
114
|
+
|
|
115
|
+
# Generate challenge from verifier using SHA-256
|
|
116
|
+
challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
117
|
+
challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
|
|
118
|
+
|
|
119
|
+
return PKCEPair(verifier=verifier, challenge=challenge, method="S256")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def encode_state(verifier: str, project_id: str | None = None) -> str:
|
|
123
|
+
"""Encode OAuth state as URL-safe base64 JSON."""
|
|
124
|
+
state = {"verifier": verifier}
|
|
125
|
+
if project_id:
|
|
126
|
+
state["projectId"] = project_id
|
|
127
|
+
return base64.urlsafe_b64encode(json.dumps(state).encode()).decode()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def decode_state(encoded: str) -> dict[str, Any]:
|
|
131
|
+
"""Decode OAuth state from base64 JSON."""
|
|
132
|
+
# Handle both base64url and standard base64
|
|
133
|
+
encoded = encoded.replace("-", "+").replace("_", "/")
|
|
134
|
+
# Add padding
|
|
135
|
+
padding = 4 - len(encoded) % 4
|
|
136
|
+
if padding != 4:
|
|
137
|
+
encoded += "=" * padding
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
decoded = base64.b64decode(encoded).decode("utf-8")
|
|
141
|
+
return json.loads(decoded)
|
|
142
|
+
except Exception:
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_auth_url(
|
|
147
|
+
port: int,
|
|
148
|
+
project_id: str | None = None,
|
|
149
|
+
client_id: str = ANTIGRAVITY_CLIENT_ID,
|
|
150
|
+
) -> tuple[str, str]:
|
|
151
|
+
"""
|
|
152
|
+
Build Google OAuth authorization URL with PKCE.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Tuple of (auth_url, pkce_verifier)
|
|
156
|
+
"""
|
|
157
|
+
pkce = generate_pkce_pair()
|
|
158
|
+
|
|
159
|
+
redirect_uri = f"http://localhost:{port}/oauth-callback"
|
|
160
|
+
state = encode_state(pkce.verifier, project_id)
|
|
161
|
+
|
|
162
|
+
params = {
|
|
163
|
+
"client_id": client_id,
|
|
164
|
+
"redirect_uri": redirect_uri,
|
|
165
|
+
"response_type": "code",
|
|
166
|
+
"scope": " ".join(ANTIGRAVITY_SCOPES),
|
|
167
|
+
"state": state,
|
|
168
|
+
"code_challenge": pkce.challenge,
|
|
169
|
+
"code_challenge_method": "S256",
|
|
170
|
+
"access_type": "offline",
|
|
171
|
+
"prompt": "consent",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
|
|
175
|
+
return url, pkce.verifier
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def exchange_code(
|
|
179
|
+
code: str,
|
|
180
|
+
verifier: str,
|
|
181
|
+
port: int,
|
|
182
|
+
client_id: str = ANTIGRAVITY_CLIENT_ID,
|
|
183
|
+
client_secret: str = ANTIGRAVITY_CLIENT_SECRET,
|
|
184
|
+
) -> TokenResult:
|
|
185
|
+
"""
|
|
186
|
+
Exchange authorization code for tokens.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
code: Authorization code from OAuth callback
|
|
190
|
+
verifier: PKCE verifier from initial auth request
|
|
191
|
+
port: Callback server port
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Token exchange result with access and refresh tokens.
|
|
195
|
+
"""
|
|
196
|
+
redirect_uri = f"http://localhost:{port}/oauth-callback"
|
|
197
|
+
|
|
198
|
+
data = {
|
|
199
|
+
"client_id": client_id,
|
|
200
|
+
"client_secret": client_secret,
|
|
201
|
+
"code": code,
|
|
202
|
+
"grant_type": "authorization_code",
|
|
203
|
+
"redirect_uri": redirect_uri,
|
|
204
|
+
"code_verifier": verifier,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
with httpx.Client() as client:
|
|
208
|
+
response = client.post(
|
|
209
|
+
GOOGLE_TOKEN_URL,
|
|
210
|
+
data=data,
|
|
211
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if response.status_code != 200:
|
|
215
|
+
raise Exception(f"Token exchange failed: {response.status_code} - {response.text}")
|
|
216
|
+
|
|
217
|
+
result = response.json()
|
|
218
|
+
|
|
219
|
+
return TokenResult(
|
|
220
|
+
access_token=result["access_token"],
|
|
221
|
+
refresh_token=result.get("refresh_token", ""),
|
|
222
|
+
expires_in=result.get("expires_in", 3600),
|
|
223
|
+
token_type=result.get("token_type", "Bearer"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def refresh_access_token(
|
|
228
|
+
refresh_token: str,
|
|
229
|
+
client_id: str = ANTIGRAVITY_CLIENT_ID,
|
|
230
|
+
client_secret: str = ANTIGRAVITY_CLIENT_SECRET,
|
|
231
|
+
) -> TokenResult:
|
|
232
|
+
"""
|
|
233
|
+
Refresh an access token using a refresh token.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
refresh_token: Valid refresh token
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
New token result.
|
|
240
|
+
"""
|
|
241
|
+
data = {
|
|
242
|
+
"client_id": client_id,
|
|
243
|
+
"client_secret": client_secret,
|
|
244
|
+
"refresh_token": refresh_token,
|
|
245
|
+
"grant_type": "refresh_token",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
with httpx.Client() as client:
|
|
249
|
+
response = client.post(
|
|
250
|
+
GOOGLE_TOKEN_URL,
|
|
251
|
+
data=data,
|
|
252
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if response.status_code != 200:
|
|
256
|
+
raise Exception(f"Token refresh failed: {response.status_code} - {response.text}")
|
|
257
|
+
|
|
258
|
+
result = response.json()
|
|
259
|
+
|
|
260
|
+
return TokenResult(
|
|
261
|
+
access_token=result["access_token"],
|
|
262
|
+
refresh_token=refresh_token, # Keep original refresh token
|
|
263
|
+
expires_in=result.get("expires_in", 3600),
|
|
264
|
+
token_type=result.get("token_type", "Bearer"),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def fetch_user_info(access_token: str) -> UserInfo:
|
|
269
|
+
"""
|
|
270
|
+
Fetch user info from Google's userinfo API.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
access_token: Valid access token
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
User info with email, name, and picture.
|
|
277
|
+
"""
|
|
278
|
+
with httpx.Client() as client:
|
|
279
|
+
response = client.get(
|
|
280
|
+
f"{GOOGLE_USERINFO_URL}?alt=json",
|
|
281
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if response.status_code != 200:
|
|
285
|
+
raise Exception(f"Failed to fetch user info: {response.status_code}")
|
|
286
|
+
|
|
287
|
+
data = response.json()
|
|
288
|
+
|
|
289
|
+
return UserInfo(
|
|
290
|
+
email=data.get("email", ""),
|
|
291
|
+
name=data.get("name"),
|
|
292
|
+
picture=data.get("picture"),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
297
|
+
"""HTTP request handler for OAuth callback."""
|
|
298
|
+
|
|
299
|
+
callback_result: dict[str, Any] = {}
|
|
300
|
+
server_ready = threading.Event()
|
|
301
|
+
|
|
302
|
+
def log_message(self, format, *args):
|
|
303
|
+
"""Suppress HTTP server logs."""
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
def do_GET(self):
|
|
307
|
+
"""Handle OAuth callback GET request."""
|
|
308
|
+
parsed = urlparse(self.path)
|
|
309
|
+
|
|
310
|
+
if parsed.path == "/oauth-callback":
|
|
311
|
+
params = parse_qs(parsed.query)
|
|
312
|
+
|
|
313
|
+
OAuthCallbackHandler.callback_result = {
|
|
314
|
+
"code": params.get("code", [""])[0],
|
|
315
|
+
"state": params.get("state", [""])[0],
|
|
316
|
+
"error": params.get("error", [None])[0],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if OAuthCallbackHandler.callback_result["code"] and not OAuthCallbackHandler.callback_result["error"]:
|
|
320
|
+
body = b"<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>"
|
|
321
|
+
else:
|
|
322
|
+
body = b"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
|
|
323
|
+
|
|
324
|
+
self.send_response(200)
|
|
325
|
+
self.send_header("Content-Type", "text/html")
|
|
326
|
+
self.send_header("Content-Length", str(len(body)))
|
|
327
|
+
self.end_headers()
|
|
328
|
+
self.wfile.write(body)
|
|
329
|
+
|
|
330
|
+
# Signal completion
|
|
331
|
+
OAuthCallbackHandler.server_ready.set()
|
|
332
|
+
else:
|
|
333
|
+
self.send_response(404)
|
|
334
|
+
self.end_headers()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def perform_oauth_flow(
|
|
338
|
+
project_id: str | None = None,
|
|
339
|
+
timeout: int = 300,
|
|
340
|
+
) -> OAuthResult:
|
|
341
|
+
"""
|
|
342
|
+
Perform full OAuth flow with browser-based authentication.
|
|
343
|
+
|
|
344
|
+
1. Start local callback server
|
|
345
|
+
2. Open browser for Google auth
|
|
346
|
+
3. Wait for callback
|
|
347
|
+
4. Exchange code for tokens
|
|
348
|
+
5. Fetch user info
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
project_id: Optional project ID for state
|
|
352
|
+
timeout: Timeout in seconds (default 5 minutes)
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Complete OAuth result with tokens and user info.
|
|
356
|
+
"""
|
|
357
|
+
# Reset callback state
|
|
358
|
+
OAuthCallbackHandler.callback_result = {}
|
|
359
|
+
OAuthCallbackHandler.server_ready.clear()
|
|
360
|
+
|
|
361
|
+
# Start callback server
|
|
362
|
+
server = HTTPServer(("localhost", 0), OAuthCallbackHandler)
|
|
363
|
+
port = server.server_address[1]
|
|
364
|
+
|
|
365
|
+
server_thread = threading.Thread(target=lambda: server.handle_request())
|
|
366
|
+
server_thread.daemon = True
|
|
367
|
+
server_thread.start()
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
# Build auth URL and open browser
|
|
371
|
+
auth_url, verifier = build_auth_url(port, project_id)
|
|
372
|
+
|
|
373
|
+
print(f"\nOpening browser for Google authentication...")
|
|
374
|
+
print(f"If browser doesn't open, visit:\n{auth_url}\n")
|
|
375
|
+
|
|
376
|
+
webbrowser.open(auth_url)
|
|
377
|
+
|
|
378
|
+
# Wait for callback
|
|
379
|
+
if not OAuthCallbackHandler.server_ready.wait(timeout):
|
|
380
|
+
raise Exception("OAuth callback timeout")
|
|
381
|
+
|
|
382
|
+
result = OAuthCallbackHandler.callback_result
|
|
383
|
+
|
|
384
|
+
if result.get("error"):
|
|
385
|
+
raise Exception(f"OAuth error: {result['error']}")
|
|
386
|
+
|
|
387
|
+
if not result.get("code"):
|
|
388
|
+
raise Exception("No authorization code received")
|
|
389
|
+
|
|
390
|
+
# Verify state
|
|
391
|
+
state = decode_state(result["state"])
|
|
392
|
+
if state.get("verifier") != verifier:
|
|
393
|
+
raise Exception("PKCE verifier mismatch - possible security issue")
|
|
394
|
+
|
|
395
|
+
# Exchange code for tokens
|
|
396
|
+
tokens = exchange_code(result["code"], verifier, port)
|
|
397
|
+
|
|
398
|
+
# Fetch user info
|
|
399
|
+
user_info = fetch_user_info(tokens.access_token)
|
|
400
|
+
|
|
401
|
+
print(f"✓ Authenticated as: {user_info.email}")
|
|
402
|
+
|
|
403
|
+
return OAuthResult(tokens=tokens, user_info=user_info, verifier=verifier)
|
|
404
|
+
|
|
405
|
+
finally:
|
|
406
|
+
server.server_close()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
# Test the OAuth flow
|
|
411
|
+
try:
|
|
412
|
+
result = perform_oauth_flow()
|
|
413
|
+
print(f"\nAccess Token: {result.tokens.access_token[:20]}...")
|
|
414
|
+
print(f"Refresh Token: {result.tokens.refresh_token[:20]}...")
|
|
415
|
+
print(f"Expires In: {result.tokens.expires_in}s")
|
|
416
|
+
print(f"User: {result.user_info.email}")
|
|
417
|
+
except Exception as e:
|
|
418
|
+
print(f"OAuth failed: {e}")
|