code-puppy 0.0.214__py3-none-any.whl → 0.0.366__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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +2 -0
- code_puppy/agents/agent_c_reviewer.py +59 -6
- code_puppy/agents/agent_code_puppy.py +7 -1
- code_puppy/agents/agent_code_reviewer.py +12 -2
- code_puppy/agents/agent_cpp_reviewer.py +73 -6
- code_puppy/agents/agent_creator_agent.py +45 -4
- code_puppy/agents/agent_golang_reviewer.py +92 -3
- code_puppy/agents/agent_javascript_reviewer.py +101 -8
- code_puppy/agents/agent_manager.py +81 -4
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +28 -6
- code_puppy/agents/agent_qa_expert.py +98 -6
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_security_auditor.py +113 -3
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +106 -7
- code_puppy/agents/base_agent.py +802 -176
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +142 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +10 -5
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +176 -738
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +0 -3
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +15 -26
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +2 -2
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +16 -10
- code_puppy/command_line/mcp/start_all_command.py +18 -6
- code_puppy/command_line/mcp/start_command.py +47 -25
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +7 -1
- code_puppy/command_line/mcp/stop_command.py +8 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +75 -25
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +463 -63
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +898 -112
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +210 -148
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -698
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/blocking_startup.py +70 -43
- code_puppy/mcp_/captured_stdio_server.py +2 -2
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +65 -38
- code_puppy/mcp_/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/mcp_/server_registry_catalog.py +24 -5
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +21 -5
- code_puppy/messaging/spinner/console_spinner.py +86 -51
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +634 -83
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +66 -68
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +9 -12
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +41 -13
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +536 -52
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +19 -23
- code_puppy/tools/browser/browser_interactions.py +41 -48
- code_puppy/tools/browser/browser_locators.py +36 -38
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +16 -16
- code_puppy/tools/browser/browser_screenshot.py +79 -143
- code_puppy/tools/browser/browser_scripts.py +32 -42
- code_puppy/tools/browser/browser_workflows.py +44 -27
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +930 -147
- code_puppy/tools/common.py +1113 -5
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +226 -154
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/messaging/spinner/textual_spinner.py +0 -106
- code_puppy/tools/browser/camoufox_manager.py +0 -216
- code_puppy/tools/browser/vqa_agent.py +0 -70
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -1105
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -551
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -185
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -17
- code_puppy/tui/screens/autosave_picker.py +0 -175
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -306
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
- code_puppy-0.0.214.dist-info/RECORD +0 -131
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Tests for the Antigravity OAuth plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from .accounts import AccountManager
|
|
10
|
+
from .config import ANTIGRAVITY_OAUTH_CONFIG
|
|
11
|
+
from .constants import ANTIGRAVITY_MODELS, ANTIGRAVITY_SCOPES
|
|
12
|
+
from .oauth import (
|
|
13
|
+
_compute_code_challenge,
|
|
14
|
+
_decode_state,
|
|
15
|
+
_encode_state,
|
|
16
|
+
_generate_code_verifier,
|
|
17
|
+
prepare_oauth_context,
|
|
18
|
+
)
|
|
19
|
+
from .storage import (
|
|
20
|
+
_migrate_v1_to_v2,
|
|
21
|
+
_migrate_v2_to_v3,
|
|
22
|
+
)
|
|
23
|
+
from .token import (
|
|
24
|
+
RefreshParts,
|
|
25
|
+
format_refresh_parts,
|
|
26
|
+
is_token_expired,
|
|
27
|
+
parse_refresh_parts,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestPKCE:
|
|
32
|
+
"""Test PKCE code generation and verification."""
|
|
33
|
+
|
|
34
|
+
def test_code_verifier_length(self):
|
|
35
|
+
"""Code verifier should be URL-safe base64 encoded."""
|
|
36
|
+
verifier = _generate_code_verifier()
|
|
37
|
+
assert len(verifier) > 40 # At least 43 chars for 32 bytes
|
|
38
|
+
assert "=" not in verifier # No padding
|
|
39
|
+
assert " " not in verifier
|
|
40
|
+
|
|
41
|
+
def test_code_challenge_is_sha256(self):
|
|
42
|
+
"""Code challenge should be S256 of verifier."""
|
|
43
|
+
verifier = "test_verifier_string"
|
|
44
|
+
challenge = _compute_code_challenge(verifier)
|
|
45
|
+
assert len(challenge) > 20
|
|
46
|
+
assert "=" not in challenge
|
|
47
|
+
|
|
48
|
+
def test_different_verifiers_produce_different_challenges(self):
|
|
49
|
+
"""Each verifier should produce a unique challenge."""
|
|
50
|
+
v1 = _generate_code_verifier()
|
|
51
|
+
v2 = _generate_code_verifier()
|
|
52
|
+
c1 = _compute_code_challenge(v1)
|
|
53
|
+
c2 = _compute_code_challenge(v2)
|
|
54
|
+
assert c1 != c2
|
|
55
|
+
|
|
56
|
+
def test_prepare_oauth_context(self):
|
|
57
|
+
"""OAuth context should have all required fields."""
|
|
58
|
+
ctx = prepare_oauth_context()
|
|
59
|
+
assert ctx.state
|
|
60
|
+
assert ctx.code_verifier
|
|
61
|
+
assert ctx.code_challenge
|
|
62
|
+
assert ctx.redirect_uri is None # Not assigned yet
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestStateEncoding:
|
|
66
|
+
"""Test OAuth state encoding/decoding."""
|
|
67
|
+
|
|
68
|
+
def test_encode_decode_roundtrip(self):
|
|
69
|
+
"""State should survive encode/decode roundtrip."""
|
|
70
|
+
verifier = "test-verifier-123"
|
|
71
|
+
project_id = "my-project"
|
|
72
|
+
|
|
73
|
+
encoded = _encode_state(verifier, project_id)
|
|
74
|
+
decoded_verifier, decoded_project = _decode_state(encoded)
|
|
75
|
+
|
|
76
|
+
assert decoded_verifier == verifier
|
|
77
|
+
assert decoded_project == project_id
|
|
78
|
+
|
|
79
|
+
def test_encode_without_project_id(self):
|
|
80
|
+
"""Should handle empty project ID."""
|
|
81
|
+
verifier = "test-verifier"
|
|
82
|
+
encoded = _encode_state(verifier, "")
|
|
83
|
+
decoded_verifier, decoded_project = _decode_state(encoded)
|
|
84
|
+
|
|
85
|
+
assert decoded_verifier == verifier
|
|
86
|
+
assert decoded_project == ""
|
|
87
|
+
|
|
88
|
+
def test_decode_invalid_state_raises(self):
|
|
89
|
+
"""Invalid state should raise ValueError."""
|
|
90
|
+
with pytest.raises(ValueError):
|
|
91
|
+
_decode_state("not-valid-base64!!!")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestRefreshParts:
|
|
95
|
+
"""Test refresh token parsing and formatting."""
|
|
96
|
+
|
|
97
|
+
def test_parse_simple_token(self):
|
|
98
|
+
"""Parse a token without project IDs."""
|
|
99
|
+
parts = parse_refresh_parts("my-refresh-token")
|
|
100
|
+
assert parts.refresh_token == "my-refresh-token"
|
|
101
|
+
assert parts.project_id is None
|
|
102
|
+
assert parts.managed_project_id is None
|
|
103
|
+
|
|
104
|
+
def test_parse_with_project_id(self):
|
|
105
|
+
"""Parse a token with project ID."""
|
|
106
|
+
parts = parse_refresh_parts("my-token|project-123")
|
|
107
|
+
assert parts.refresh_token == "my-token"
|
|
108
|
+
assert parts.project_id == "project-123"
|
|
109
|
+
assert parts.managed_project_id is None
|
|
110
|
+
|
|
111
|
+
def test_parse_with_managed_project(self):
|
|
112
|
+
"""Parse a token with both project IDs."""
|
|
113
|
+
parts = parse_refresh_parts("token|proj|managed")
|
|
114
|
+
assert parts.refresh_token == "token"
|
|
115
|
+
assert parts.project_id == "proj"
|
|
116
|
+
assert parts.managed_project_id == "managed"
|
|
117
|
+
|
|
118
|
+
def test_parse_empty_string(self):
|
|
119
|
+
"""Empty string should produce empty parts."""
|
|
120
|
+
parts = parse_refresh_parts("")
|
|
121
|
+
assert parts.refresh_token == ""
|
|
122
|
+
assert parts.project_id is None
|
|
123
|
+
|
|
124
|
+
def test_format_roundtrip(self):
|
|
125
|
+
"""Format and parse should be inverse operations."""
|
|
126
|
+
original = RefreshParts(
|
|
127
|
+
refresh_token="token",
|
|
128
|
+
project_id="project",
|
|
129
|
+
managed_project_id="managed",
|
|
130
|
+
)
|
|
131
|
+
formatted = format_refresh_parts(original)
|
|
132
|
+
parsed = parse_refresh_parts(formatted)
|
|
133
|
+
|
|
134
|
+
assert parsed.refresh_token == original.refresh_token
|
|
135
|
+
assert parsed.project_id == original.project_id
|
|
136
|
+
assert parsed.managed_project_id == original.managed_project_id
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestTokenExpiry:
|
|
140
|
+
"""Test token expiry checking."""
|
|
141
|
+
|
|
142
|
+
def test_none_expires_is_expired(self):
|
|
143
|
+
"""None expiry should be treated as expired."""
|
|
144
|
+
assert is_token_expired(None) is True
|
|
145
|
+
|
|
146
|
+
def test_past_time_is_expired(self):
|
|
147
|
+
"""Past time should be expired."""
|
|
148
|
+
past = time.time() - 3600
|
|
149
|
+
assert is_token_expired(past) is True
|
|
150
|
+
|
|
151
|
+
def test_future_time_not_expired(self):
|
|
152
|
+
"""Future time should not be expired."""
|
|
153
|
+
future = time.time() + 3600
|
|
154
|
+
assert is_token_expired(future) is False
|
|
155
|
+
|
|
156
|
+
def test_expiry_buffer(self):
|
|
157
|
+
"""Token expiring soon should be treated as expired (60s buffer)."""
|
|
158
|
+
almost_expired = time.time() + 30 # 30 seconds from now
|
|
159
|
+
assert is_token_expired(almost_expired) is True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestStorageMigration:
|
|
163
|
+
"""Test storage format migrations."""
|
|
164
|
+
|
|
165
|
+
def test_migrate_v1_to_v2(self):
|
|
166
|
+
"""V1 format should migrate to V2."""
|
|
167
|
+
v1_data = {
|
|
168
|
+
"version": 1,
|
|
169
|
+
"accounts": [
|
|
170
|
+
{
|
|
171
|
+
"email": "test@example.com",
|
|
172
|
+
"refreshToken": "token123",
|
|
173
|
+
"addedAt": 1000,
|
|
174
|
+
"lastUsed": 2000,
|
|
175
|
+
"isRateLimited": False,
|
|
176
|
+
}
|
|
177
|
+
],
|
|
178
|
+
"activeIndex": 0,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
v2_data = _migrate_v1_to_v2(v1_data)
|
|
182
|
+
|
|
183
|
+
assert v2_data["version"] == 2
|
|
184
|
+
assert len(v2_data["accounts"]) == 1
|
|
185
|
+
assert v2_data["accounts"][0]["email"] == "test@example.com"
|
|
186
|
+
|
|
187
|
+
def test_migrate_v2_to_v3(self):
|
|
188
|
+
"""V2 format should migrate to V3."""
|
|
189
|
+
v2_data = {
|
|
190
|
+
"version": 2,
|
|
191
|
+
"accounts": [
|
|
192
|
+
{
|
|
193
|
+
"email": "test@example.com",
|
|
194
|
+
"refreshToken": "token123",
|
|
195
|
+
"addedAt": 1000,
|
|
196
|
+
"lastUsed": 2000,
|
|
197
|
+
"rateLimitResetTimes": {"gemini": time.time() * 1000 + 60000},
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
"activeIndex": 0,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
v3_data = _migrate_v2_to_v3(v2_data)
|
|
204
|
+
|
|
205
|
+
assert v3_data["version"] == 3
|
|
206
|
+
assert "activeIndexByFamily" in v3_data
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestAccountManager:
|
|
210
|
+
"""Test multi-account management."""
|
|
211
|
+
|
|
212
|
+
def test_empty_manager(self):
|
|
213
|
+
"""Empty manager should have no accounts."""
|
|
214
|
+
manager = AccountManager()
|
|
215
|
+
assert manager.account_count == 0
|
|
216
|
+
|
|
217
|
+
def test_add_account(self):
|
|
218
|
+
"""Should be able to add accounts."""
|
|
219
|
+
manager = AccountManager()
|
|
220
|
+
acc = manager.add_account("token123", email="test@example.com")
|
|
221
|
+
|
|
222
|
+
assert manager.account_count == 1
|
|
223
|
+
assert acc.email == "test@example.com"
|
|
224
|
+
|
|
225
|
+
def test_get_current_for_family(self):
|
|
226
|
+
"""Should get current account for family."""
|
|
227
|
+
manager = AccountManager()
|
|
228
|
+
manager.add_account("token1", email="user1@example.com")
|
|
229
|
+
manager.add_account("token2", email="user2@example.com")
|
|
230
|
+
|
|
231
|
+
acc = manager.get_current_or_next_for_family("claude")
|
|
232
|
+
assert acc is not None
|
|
233
|
+
assert acc.email in ["user1@example.com", "user2@example.com"]
|
|
234
|
+
|
|
235
|
+
def test_rate_limit_switches_account(self):
|
|
236
|
+
"""Rate limiting should cause account switch."""
|
|
237
|
+
manager = AccountManager()
|
|
238
|
+
acc1 = manager.add_account("token1", email="user1@example.com")
|
|
239
|
+
manager.add_account("token2", email="user2@example.com")
|
|
240
|
+
|
|
241
|
+
# Mark first account as rate limited for Claude
|
|
242
|
+
manager.mark_rate_limited(acc1, 60000, "claude")
|
|
243
|
+
|
|
244
|
+
# Should get the second account
|
|
245
|
+
current = manager.get_current_or_next_for_family("claude")
|
|
246
|
+
assert current is not None
|
|
247
|
+
assert current.email == "user2@example.com"
|
|
248
|
+
|
|
249
|
+
def test_min_wait_time_calculation(self):
|
|
250
|
+
"""Should calculate minimum wait time correctly."""
|
|
251
|
+
manager = AccountManager()
|
|
252
|
+
acc = manager.add_account("token", email="test@example.com")
|
|
253
|
+
|
|
254
|
+
# No rate limits = 0 wait time
|
|
255
|
+
assert manager.get_min_wait_time_for_family("claude") == 0
|
|
256
|
+
|
|
257
|
+
# Add rate limit
|
|
258
|
+
manager.mark_rate_limited(acc, 5000, "claude")
|
|
259
|
+
wait = manager.get_min_wait_time_for_family("claude")
|
|
260
|
+
assert 0 < wait <= 5000
|
|
261
|
+
|
|
262
|
+
def test_gemini_dual_quota(self):
|
|
263
|
+
"""Gemini should try both quota pools."""
|
|
264
|
+
manager = AccountManager()
|
|
265
|
+
acc = manager.add_account("token", email="test@example.com")
|
|
266
|
+
|
|
267
|
+
# Initially, antigravity should be available
|
|
268
|
+
style = manager.get_available_header_style(acc, "gemini")
|
|
269
|
+
assert style == "antigravity"
|
|
270
|
+
|
|
271
|
+
# Rate limit antigravity
|
|
272
|
+
manager.mark_rate_limited(acc, 60000, "gemini", "antigravity")
|
|
273
|
+
|
|
274
|
+
# Now gemini-cli should be available
|
|
275
|
+
style = manager.get_available_header_style(acc, "gemini")
|
|
276
|
+
assert style == "gemini-cli"
|
|
277
|
+
|
|
278
|
+
# Rate limit gemini-cli too
|
|
279
|
+
manager.mark_rate_limited(acc, 60000, "gemini", "gemini-cli")
|
|
280
|
+
|
|
281
|
+
# No style available
|
|
282
|
+
style = manager.get_available_header_style(acc, "gemini")
|
|
283
|
+
assert style is None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestConstants:
|
|
287
|
+
"""Test plugin constants are properly configured."""
|
|
288
|
+
|
|
289
|
+
def test_models_have_required_fields(self):
|
|
290
|
+
"""All models should have required configuration."""
|
|
291
|
+
for model_id, config in ANTIGRAVITY_MODELS.items():
|
|
292
|
+
assert "name" in config, f"{model_id} missing name"
|
|
293
|
+
assert "family" in config, f"{model_id} missing family"
|
|
294
|
+
assert "context_length" in config, f"{model_id} missing context_length"
|
|
295
|
+
assert "max_output" in config, f"{model_id} missing max_output"
|
|
296
|
+
|
|
297
|
+
def test_thinking_models_have_budget(self):
|
|
298
|
+
"""Thinking models should have thinking_budget."""
|
|
299
|
+
for model_id, config in ANTIGRAVITY_MODELS.items():
|
|
300
|
+
if "thinking" in model_id:
|
|
301
|
+
assert "thinking_budget" in config, (
|
|
302
|
+
f"{model_id} missing thinking_budget"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def test_scopes_are_valid(self):
|
|
306
|
+
"""OAuth scopes should be valid URLs."""
|
|
307
|
+
for scope in ANTIGRAVITY_SCOPES:
|
|
308
|
+
assert scope.startswith("https://"), f"Invalid scope: {scope}"
|
|
309
|
+
|
|
310
|
+
def test_config_has_required_fields(self):
|
|
311
|
+
"""Plugin config should have required fields."""
|
|
312
|
+
assert "auth_url" in ANTIGRAVITY_OAUTH_CONFIG
|
|
313
|
+
assert "token_url" in ANTIGRAVITY_OAUTH_CONFIG
|
|
314
|
+
assert "callback_port_range" in ANTIGRAVITY_OAUTH_CONFIG
|
|
315
|
+
assert "prefix" in ANTIGRAVITY_OAUTH_CONFIG
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Token management for Antigravity OAuth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .constants import ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Buffer before expiry to trigger refresh (60 seconds)
|
|
17
|
+
ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RefreshParts:
|
|
22
|
+
"""Parsed components of a stored refresh token string."""
|
|
23
|
+
|
|
24
|
+
refresh_token: str
|
|
25
|
+
project_id: Optional[str] = None
|
|
26
|
+
managed_project_id: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class OAuthTokens:
|
|
31
|
+
"""OAuth token data."""
|
|
32
|
+
|
|
33
|
+
access_token: str
|
|
34
|
+
refresh_token: str # Composite: "token|projectId|managedProjectId"
|
|
35
|
+
expires_at: float # Unix timestamp
|
|
36
|
+
email: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TokenRefreshError(Exception):
|
|
40
|
+
"""Error during token refresh."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
code: Optional[str] = None,
|
|
46
|
+
status: Optional[int] = None,
|
|
47
|
+
):
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.code = code
|
|
50
|
+
self.status = status
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_refresh_parts(refresh: str) -> RefreshParts:
|
|
54
|
+
"""Split a packed refresh string into its components."""
|
|
55
|
+
parts = (refresh or "").split("|")
|
|
56
|
+
return RefreshParts(
|
|
57
|
+
refresh_token=parts[0] if len(parts) > 0 else "",
|
|
58
|
+
project_id=parts[1] if len(parts) > 1 and parts[1] else None,
|
|
59
|
+
managed_project_id=parts[2] if len(parts) > 2 and parts[2] else None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_refresh_parts(parts: RefreshParts) -> str:
|
|
64
|
+
"""Serialize refresh token parts into the stored string format."""
|
|
65
|
+
project_segment = parts.project_id or ""
|
|
66
|
+
base = f"{parts.refresh_token}|{project_segment}"
|
|
67
|
+
if parts.managed_project_id:
|
|
68
|
+
return f"{base}|{parts.managed_project_id}"
|
|
69
|
+
return base
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_token_expired(expires_at: Optional[float]) -> bool:
|
|
73
|
+
"""Check if a token is expired or missing, with buffer for clock skew."""
|
|
74
|
+
if expires_at is None:
|
|
75
|
+
return True
|
|
76
|
+
# Convert buffer to seconds
|
|
77
|
+
buffer_seconds = ACCESS_TOKEN_EXPIRY_BUFFER_MS / 1000
|
|
78
|
+
return expires_at <= time.time() + buffer_seconds
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def refresh_access_token(
|
|
82
|
+
refresh_token_composite: str,
|
|
83
|
+
current_access: Optional[str] = None,
|
|
84
|
+
current_expires: Optional[float] = None,
|
|
85
|
+
) -> Optional[OAuthTokens]:
|
|
86
|
+
"""Refresh an Antigravity OAuth access token.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
refresh_token_composite: The stored refresh token (may include project IDs)
|
|
90
|
+
current_access: Current access token (for returning if refresh fails non-fatally)
|
|
91
|
+
current_expires: Current expiry time
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Updated OAuthTokens or None if refresh failed
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
TokenRefreshError: If refresh fails due to revoked token
|
|
98
|
+
"""
|
|
99
|
+
parts = parse_refresh_parts(refresh_token_composite)
|
|
100
|
+
|
|
101
|
+
if not parts.refresh_token:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
response = requests.post(
|
|
106
|
+
"https://oauth2.googleapis.com/token",
|
|
107
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
108
|
+
data={
|
|
109
|
+
"grant_type": "refresh_token",
|
|
110
|
+
"refresh_token": parts.refresh_token,
|
|
111
|
+
"client_id": ANTIGRAVITY_CLIENT_ID,
|
|
112
|
+
"client_secret": ANTIGRAVITY_CLIENT_SECRET,
|
|
113
|
+
},
|
|
114
|
+
timeout=30,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not response.ok:
|
|
118
|
+
error_data = {}
|
|
119
|
+
try:
|
|
120
|
+
error_data = response.json()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
error_code = error_data.get("error", "")
|
|
125
|
+
error_desc = error_data.get("error_description", response.text)
|
|
126
|
+
|
|
127
|
+
if error_code == "invalid_grant":
|
|
128
|
+
logger.warning(
|
|
129
|
+
"Google revoked the stored refresh token - reauthentication required"
|
|
130
|
+
)
|
|
131
|
+
raise TokenRefreshError(
|
|
132
|
+
f"Token revoked: {error_desc}",
|
|
133
|
+
code="invalid_grant",
|
|
134
|
+
status=response.status_code,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
logger.warning(
|
|
138
|
+
"Token refresh failed: %s %s - %s",
|
|
139
|
+
response.status_code,
|
|
140
|
+
error_code,
|
|
141
|
+
error_desc,
|
|
142
|
+
)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
payload = response.json()
|
|
146
|
+
new_access = payload.get("access_token", "")
|
|
147
|
+
expires_in = payload.get("expires_in", 3600)
|
|
148
|
+
new_refresh = payload.get("refresh_token", parts.refresh_token)
|
|
149
|
+
|
|
150
|
+
# Rebuild composite refresh token
|
|
151
|
+
updated_parts = RefreshParts(
|
|
152
|
+
refresh_token=new_refresh,
|
|
153
|
+
project_id=parts.project_id,
|
|
154
|
+
managed_project_id=parts.managed_project_id,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return OAuthTokens(
|
|
158
|
+
access_token=new_access,
|
|
159
|
+
refresh_token=format_refresh_parts(updated_parts),
|
|
160
|
+
expires_at=time.time() + expires_in,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except TokenRefreshError:
|
|
164
|
+
raise
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.exception("Unexpected token refresh error: %s", e)
|
|
167
|
+
return None
|