algo-cli-runtime 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. algo_cli/__init__.py +3 -0
  2. algo_cli/__main__.py +7 -0
  3. algo_cli/_internal/__init__.py +12 -0
  4. algo_cli/_internal/policy_chain.py +259 -0
  5. algo_cli/action_registry.py +1047 -0
  6. algo_cli/agent_blocks.py +550 -0
  7. algo_cli/agent_pipeline.py +1457 -0
  8. algo_cli/agent_threads.py +308 -0
  9. algo_cli/animations.py +316 -0
  10. algo_cli/cache_admission.py +209 -0
  11. algo_cli/capability_mask.py +66 -0
  12. algo_cli/chat_protocol.py +116 -0
  13. algo_cli/chatgpt_auth.py +510 -0
  14. algo_cli/chatgpt_client.py +657 -0
  15. algo_cli/code_rag.py +479 -0
  16. algo_cli/config.py +651 -0
  17. algo_cli/context_budget.py +679 -0
  18. algo_cli/credential_helpers.py +315 -0
  19. algo_cli/deliberation.py +29 -0
  20. algo_cli/display.py +1470 -0
  21. algo_cli/evals/__init__.py +21 -0
  22. algo_cli/evals/algorithm_effectiveness.py +560 -0
  23. algo_cli/evals/competitive_harness_rating.py +702 -0
  24. algo_cli/evals/cot_quality.py +220 -0
  25. algo_cli/evals/harness_retrieval_benchmark.py +401 -0
  26. algo_cli/evals/performance_regression.py +136 -0
  27. algo_cli/evals/scorecard_grading.py +308 -0
  28. algo_cli/evals/session_distribution.py +84 -0
  29. algo_cli/execution_guardrails.py +806 -0
  30. algo_cli/extensions_manifest.py +84 -0
  31. algo_cli/git_evidence.py +227 -0
  32. algo_cli/google_workspace.py +407 -0
  33. algo_cli/google_workspace_auth.py +523 -0
  34. algo_cli/harness.py +2587 -0
  35. algo_cli/identity.py +557 -0
  36. algo_cli/index_compute_lab.py +228 -0
  37. algo_cli/inference_harness.py +70 -0
  38. algo_cli/intelligence/__init__.py +1103 -0
  39. algo_cli/intelligence/acrobat_config.py +307 -0
  40. algo_cli/intelligence/acrobat_manifests.py +338 -0
  41. algo_cli/intelligence/acrobat_models.py +195 -0
  42. algo_cli/intelligence/acrobat_pipeline.py +295 -0
  43. algo_cli/intelligence/acrobat_runtime.py +302 -0
  44. algo_cli/intelligence/acrobat_security.py +261 -0
  45. algo_cli/intelligence/acrobat_workflows.py +226 -0
  46. algo_cli/intelligence/actionability.py +165 -0
  47. algo_cli/intelligence/adversarial_audit.py +136 -0
  48. algo_cli/intelligence/agent_arena.py +92 -0
  49. algo_cli/intelligence/agent_benchmark.py +236 -0
  50. algo_cli/intelligence/agent_runtime.py +171 -0
  51. algo_cli/intelligence/agents_as_tools.py +70 -0
  52. algo_cli/intelligence/artifact_binding.py +80 -0
  53. algo_cli/intelligence/autonomous_engineer.py +1976 -0
  54. algo_cli/intelligence/backpressure.py +99 -0
  55. algo_cli/intelligence/bloom_filter.py +186 -0
  56. algo_cli/intelligence/bonferroni.py +66 -0
  57. algo_cli/intelligence/boundary_compaction.py +98 -0
  58. algo_cli/intelligence/catalog_verifier.py +172 -0
  59. algo_cli/intelligence/cavecrew.py +118 -0
  60. algo_cli/intelligence/changelog.py +176 -0
  61. algo_cli/intelligence/checkpoint_resume.py +92 -0
  62. algo_cli/intelligence/circuit_breaker.py +88 -0
  63. algo_cli/intelligence/clarification_gate.py +101 -0
  64. algo_cli/intelligence/code_graph.py +180 -0
  65. algo_cli/intelligence/coderank.py +97 -0
  66. algo_cli/intelligence/consistent_hash.py +150 -0
  67. algo_cli/intelligence/consortium_synthesis.py +139 -0
  68. algo_cli/intelligence/construction/__init__.py +241 -0
  69. algo_cli/intelligence/construction/common.py +273 -0
  70. algo_cli/intelligence/construction/documents.py +496 -0
  71. algo_cli/intelligence/construction/labor_units.py +1395 -0
  72. algo_cli/intelligence/construction/payments.py +470 -0
  73. algo_cli/intelligence/construction/risk.py +784 -0
  74. algo_cli/intelligence/content_extractor.py +132 -0
  75. algo_cli/intelligence/context_adaptive.py +102 -0
  76. algo_cli/intelligence/context_ops.py +95 -0
  77. algo_cli/intelligence/count_min.py +145 -0
  78. algo_cli/intelligence/cow_state.py +103 -0
  79. algo_cli/intelligence/critic_loop.py +119 -0
  80. algo_cli/intelligence/cross_source.py +113 -0
  81. algo_cli/intelligence/daemon_mode.py +99 -0
  82. algo_cli/intelligence/dag_orchestration.py +151 -0
  83. algo_cli/intelligence/deep_research.py +155 -0
  84. algo_cli/intelligence/degenerate_detector.py +78 -0
  85. algo_cli/intelligence/delta_report.py +92 -0
  86. algo_cli/intelligence/discovery_event_log.py +92 -0
  87. algo_cli/intelligence/document_ingest.py +298 -0
  88. algo_cli/intelligence/dual_layer_validate.py +151 -0
  89. algo_cli/intelligence/echo_fidelity.py +73 -0
  90. algo_cli/intelligence/ema_tuning.py +104 -0
  91. algo_cli/intelligence/event_log.py +92 -0
  92. algo_cli/intelligence/evidence_graph.py +114 -0
  93. algo_cli/intelligence/extension_host.py +162 -0
  94. algo_cli/intelligence/extension_manifest.py +115 -0
  95. algo_cli/intelligence/falsification_suite.py +178 -0
  96. algo_cli/intelligence/finance/__init__.py +169 -0
  97. algo_cli/intelligence/finance/anomalies.py +135 -0
  98. algo_cli/intelligence/finance/ap_ar.py +351 -0
  99. algo_cli/intelligence/finance/cash.py +162 -0
  100. algo_cli/intelligence/finance/close.py +332 -0
  101. algo_cli/intelligence/finance/common.py +244 -0
  102. algo_cli/intelligence/finance/construction.py +135 -0
  103. algo_cli/intelligence/finance/controls.py +172 -0
  104. algo_cli/intelligence/finance/evidence.py +119 -0
  105. algo_cli/intelligence/finance/exceptions.py +157 -0
  106. algo_cli/intelligence/finance/reconciliations.py +254 -0
  107. algo_cli/intelligence/finance/revenue.py +109 -0
  108. algo_cli/intelligence/finance/tax.py +74 -0
  109. algo_cli/intelligence/finance/workpapers.py +111 -0
  110. algo_cli/intelligence/finding_record.py +120 -0
  111. algo_cli/intelligence/flow_dag.py +267 -0
  112. algo_cli/intelligence/gatherer.py +223 -0
  113. algo_cli/intelligence/golden_master.py +98 -0
  114. algo_cli/intelligence/graph_rag.py +195 -0
  115. algo_cli/intelligence/group_chat.py +143 -0
  116. algo_cli/intelligence/hash_dedup.py +145 -0
  117. algo_cli/intelligence/hyperloglog.py +128 -0
  118. algo_cli/intelligence/incremental_index.py +316 -0
  119. algo_cli/intelligence/index_store.py +16 -0
  120. algo_cli/intelligence/iteration_plan.py +133 -0
  121. algo_cli/intelligence/kernel_plugins.py +167 -0
  122. algo_cli/intelligence/lesson_catalog.py +135 -0
  123. algo_cli/intelligence/llm_fallback.py +169 -0
  124. algo_cli/intelligence/log2_histogram.py +267 -0
  125. algo_cli/intelligence/lsp_integration.py +147 -0
  126. algo_cli/intelligence/memory_evolution.py +117 -0
  127. algo_cli/intelligence/minhash_lsh.py +182 -0
  128. algo_cli/intelligence/multi_model_score.py +174 -0
  129. algo_cli/intelligence/multi_tier_grade.py +211 -0
  130. algo_cli/intelligence/negative_controls.py +113 -0
  131. algo_cli/intelligence/numeric_clamp.py +63 -0
  132. algo_cli/intelligence/occ_editor.py +66 -0
  133. algo_cli/intelligence/output_normalize.py +112 -0
  134. algo_cli/intelligence/parallel_delegation.py +98 -0
  135. algo_cli/intelligence/parallel_fanout.py +104 -0
  136. algo_cli/intelligence/permission_modes.py +105 -0
  137. algo_cli/intelligence/pre_push_gate.py +68 -0
  138. algo_cli/intelligence/prefetch.py +171 -0
  139. algo_cli/intelligence/process_framework.py +217 -0
  140. algo_cli/intelligence/project_graph.py +387 -0
  141. algo_cli/intelligence/query_expansion.py +146 -0
  142. algo_cli/intelligence/ralph_loop.py +117 -0
  143. algo_cli/intelligence/rate_limiter.py +153 -0
  144. algo_cli/intelligence/refactor_transaction.py +94 -0
  145. algo_cli/intelligence/research_workspace.py +108 -0
  146. algo_cli/intelligence/retraction_ledger.py +72 -0
  147. algo_cli/intelligence/saga_pattern.py +88 -0
  148. algo_cli/intelligence/session_fork.py +100 -0
  149. algo_cli/intelligence/shadow_editor.py +67 -0
  150. algo_cli/intelligence/shell_session.py +213 -0
  151. algo_cli/intelligence/source_registry.py +143 -0
  152. algo_cli/intelligence/spawn_scales.py +99 -0
  153. algo_cli/intelligence/stat_stability.py +104 -0
  154. algo_cli/intelligence/structural_validator.py +148 -0
  155. algo_cli/intelligence/subagent_spawner.py +111 -0
  156. algo_cli/intelligence/symmetric_verify.py +70 -0
  157. algo_cli/intelligence/task_classifier.py +129 -0
  158. algo_cli/intelligence/team_execution.py +122 -0
  159. algo_cli/intelligence/tiered_access.py +121 -0
  160. algo_cli/intelligence/utility_registry.py +159 -0
  161. algo_cli/intuition_engine.py +560 -0
  162. algo_cli/intuition_injector.py +82 -0
  163. algo_cli/kernels/__init__.py +5 -0
  164. algo_cli/kernels/manifest.py +763 -0
  165. algo_cli/main.py +3903 -0
  166. algo_cli/memory_candidates.py +541 -0
  167. algo_cli/memory_echo_veil.py +394 -0
  168. algo_cli/memory_runtime.py +112 -0
  169. algo_cli/model_info.py +548 -0
  170. algo_cli/model_profile.py +160 -0
  171. algo_cli/model_routing.py +74 -0
  172. algo_cli/oneshot.py +331 -0
  173. algo_cli/perf_telemetry.py +389 -0
  174. algo_cli/plugins.py +245 -0
  175. algo_cli/private_event_store.py +654 -0
  176. algo_cli/quantization/__init__.py +24 -0
  177. algo_cli/quantization/lloyd_max.py +98 -0
  178. algo_cli/quantization/turbo_quant.py +308 -0
  179. algo_cli/reasoning/__init__.py +46 -0
  180. algo_cli/reasoning/combinatorial.py +356 -0
  181. algo_cli/reasoning/graph_of_thought.py +297 -0
  182. algo_cli/reasoning/mcts.py +220 -0
  183. algo_cli/reasoning/neuro_symbolic.py +250 -0
  184. algo_cli/reasoning/react.py +246 -0
  185. algo_cli/reasoning/reflexion.py +225 -0
  186. algo_cli/reasoning/tree_of_thought.py +241 -0
  187. algo_cli/reasoning_bridge.py +150 -0
  188. algo_cli/reconciliation.py +284 -0
  189. algo_cli/reflex.py +385 -0
  190. algo_cli/resources/docs/ALGO.md +13958 -0
  191. algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
  192. algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
  193. algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
  194. algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
  195. algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
  196. algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
  197. algo_cli/resources/docs/main-split-map.md +35 -0
  198. algo_cli/resources/docs/privacy-and-context.md +48 -0
  199. algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
  200. algo_cli/resources/skills/README.md +26 -0
  201. algo_cli/resources/skills/algo-cli.md +59 -0
  202. algo_cli/resources/skills/edit-file-precision.md +49 -0
  203. algo_cli/resources/skills/harness-search-first.md +47 -0
  204. algo_cli/resources/skills/memory-recall-ritual.md +51 -0
  205. algo_cli/resources/skills/qol-algorithms.md +224 -0
  206. algo_cli/resources/skills/smart-error-recovery.md +56 -0
  207. algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
  208. algo_cli/retrieval_algorithms.py +127 -0
  209. algo_cli/runtime_qos.py +236 -0
  210. algo_cli/runtime_services.py +320 -0
  211. algo_cli/session_commands.py +95 -0
  212. algo_cli/session_mode.py +113 -0
  213. algo_cli/skills.py +430 -0
  214. algo_cli/slash_dispatch.py +1265 -0
  215. algo_cli/small_context.py +206 -0
  216. algo_cli/spawn_budget.py +89 -0
  217. algo_cli/task_ledger.py +84 -0
  218. algo_cli/task_router.py +197 -0
  219. algo_cli/tool_context.py +94 -0
  220. algo_cli/tool_contract.py +99 -0
  221. algo_cli/tool_policy.py +357 -0
  222. algo_cli/tool_runtime.py +647 -0
  223. algo_cli/tools.py +3056 -0
  224. algo_cli/url_scheme.py +174 -0
  225. algo_cli/verify.py +154 -0
  226. algo_cli/version_manifest.py +178 -0
  227. algo_cli/vision_screenshot_verify.py +76 -0
  228. algo_cli/workspace_resolver.py +68 -0
  229. algo_cli/x_account.py +209 -0
  230. algo_cli/xai_auth.py +374 -0
  231. algo_cli/xai_client.py +600 -0
  232. algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
  233. algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
  234. algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
  235. algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
  236. algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
  237. ollama_cli/__init__.py +67 -0
@@ -0,0 +1,523 @@
1
+ """Google Workspace OAuth 2.0 + PKCE helpers.
2
+
3
+ Read/write access to Drive, Docs, Sheets, and Calendar plus Gmail read/draft
4
+ access over a local-loopback Authorization Code + PKCE flow. Gmail draft
5
+ creation is allowed; direct send is intentionally not exposed. Public client —
6
+ no client secret is required (matches the xai_auth / chatgpt_auth pattern).
7
+
8
+ Configuration via environment variables:
9
+
10
+ - GOOGLE_OAUTH_CLIENT_ID (required for /google-login)
11
+ - GOOGLE_OAUTH_CLIENT_SECRET (optional; needed only for confidential Web-app
12
+ credentials; public Desktop-app credentials can omit it)
13
+ - GOOGLE_OAUTH_REDIRECT_PORT (optional, default 56251)
14
+
15
+ Tokens persist to CONFIG_DIR/google_workspace_auth.json with POSIX 0600
16
+ permissions. Refresh is automatic; revoked tokens are removed.
17
+
18
+ This module intentionally avoids the google-auth / google-api-python-client
19
+ libraries so Algo CLI stays zero new pip deps.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import http.server
27
+ import json
28
+ import os
29
+ import secrets
30
+ import socket
31
+ import time
32
+ import urllib.parse
33
+ import urllib.request
34
+ import webbrowser
35
+ from typing import Any
36
+
37
+ from .config import CONFIG_DIR, _atomic_write_text
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Configuration
41
+ # ---------------------------------------------------------------------------
42
+
43
+ GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
44
+ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
45
+ GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"
46
+ GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
47
+
48
+ GOOGLE_REDIRECT_HOST = "127.0.0.1"
49
+ GOOGLE_REDIRECT_PORT = int(os.environ.get("GOOGLE_OAUTH_REDIRECT_PORT", "56251"))
50
+ GOOGLE_REDIRECT_PORT_RANGE = range(GOOGLE_REDIRECT_PORT, GOOGLE_REDIRECT_PORT + 20)
51
+ GOOGLE_REDIRECT_URI = f"http://{GOOGLE_REDIRECT_HOST}:{GOOGLE_REDIRECT_PORT}/callback"
52
+
53
+ # Full read/write scopes for Drive/Docs/Sheets/Calendar. Gmail includes read +
54
+ # compose so Algo CLI can read mail and create drafts for user review; direct
55
+ # send is intentionally not exposed.
56
+ GOOGLE_DEFAULT_SCOPE = (
57
+ "openid email profile "
58
+ "https://www.googleapis.com/auth/drive "
59
+ "https://www.googleapis.com/auth/documents "
60
+ "https://www.googleapis.com/auth/spreadsheets "
61
+ "https://www.googleapis.com/auth/calendar "
62
+ "https://www.googleapis.com/auth/gmail.readonly "
63
+ "https://www.googleapis.com/auth/gmail.compose"
64
+ )
65
+
66
+ AUTH_FILE = CONFIG_DIR / "google_workspace_auth.json"
67
+ PENDING_AUTH_FILE = CONFIG_DIR / "google_workspace_pending_login.json"
68
+ _REFRESH_WINDOW_SECONDS = 60
69
+ _CALLBACK_TIMEOUT_SECONDS = 300.0
70
+ _PENDING_LOGIN_TTL_SECONDS = 900
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Client id / secret lookup
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def _client_id() -> str:
79
+ return os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "").strip()
80
+
81
+
82
+ def _client_secret() -> str:
83
+ return os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "").strip()
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # PKCE helpers
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _b64url(data: bytes) -> str:
92
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
93
+
94
+
95
+ def generate_pkce_pair() -> tuple[str, str]:
96
+ verifier = _b64url(secrets.token_bytes(32))
97
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
98
+ return verifier, challenge
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # URL builders
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def redirect_uri_for_port(port: int) -> str:
107
+ return f"http://{GOOGLE_REDIRECT_HOST}:{port}/callback"
108
+
109
+
110
+ def build_authorize_url(
111
+ *,
112
+ state: str,
113
+ code_challenge: str,
114
+ redirect_uri: str,
115
+ scope: str = GOOGLE_DEFAULT_SCOPE,
116
+ ) -> str:
117
+ client_id = _client_id()
118
+ if not client_id:
119
+ raise RuntimeError(
120
+ "GOOGLE_OAUTH_CLIENT_ID is not set. Set it to the OAuth client id "
121
+ "of a Google Cloud project with Drive, Docs, Sheets, Calendar, and "
122
+ "Gmail APIs enabled, and redirect URI "
123
+ f"{redirect_uri} registered."
124
+ )
125
+ params = {
126
+ "client_id": client_id,
127
+ "response_type": "code",
128
+ "scope": scope,
129
+ "redirect_uri": redirect_uri,
130
+ "state": state,
131
+ "code_challenge": code_challenge,
132
+ "code_challenge_method": "S256",
133
+ # Always request a refresh token; force `consent` so Google returns
134
+ # one for the first run even if the user has already granted scopes.
135
+ "access_type": "offline",
136
+ "prompt": "consent",
137
+ }
138
+ return f"{GOOGLE_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # HTTP helpers
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ def _post_form(url: str, data: dict[str, str]) -> dict[str, Any]:
147
+ body = urllib.parse.urlencode(data).encode("utf-8")
148
+ request = urllib.request.Request(
149
+ url,
150
+ data=body,
151
+ method="POST",
152
+ headers={
153
+ "Content-Type": "application/x-www-form-urlencoded",
154
+ "Accept": "application/json",
155
+ },
156
+ )
157
+ try:
158
+ with urllib.request.urlopen(request, timeout=30) as response:
159
+ payload = response.read().decode("utf-8") or "{}"
160
+ except urllib.error.HTTPError as exc:
161
+ detail = exc.read().decode("utf-8", errors="replace")
162
+ raise RuntimeError(
163
+ f"Google OAuth token endpoint returned HTTP {exc.code}: {detail}"
164
+ ) from exc
165
+ except urllib.error.URLError as exc:
166
+ raise RuntimeError(f"Google OAuth token request failed: {exc.reason}") from exc
167
+ try:
168
+ return json.loads(payload)
169
+ except json.JSONDecodeError as exc:
170
+ raise RuntimeError(f"Google OAuth returned non-JSON body: {payload!r}") from exc
171
+
172
+
173
+ def exchange_code(code: str, code_verifier: str, *, redirect_uri: str) -> dict[str, Any]:
174
+ data: dict[str, str] = {
175
+ "client_id": _client_id(),
176
+ "code": code,
177
+ "code_verifier": code_verifier,
178
+ "grant_type": "authorization_code",
179
+ "redirect_uri": redirect_uri,
180
+ }
181
+ secret = _client_secret()
182
+ if secret:
183
+ data["client_secret"] = secret
184
+ payload = _post_form(GOOGLE_TOKEN_URL, data)
185
+ return _normalize_token_response(payload)
186
+
187
+
188
+ def refresh_access_token(refresh_token: str) -> dict[str, Any]:
189
+ data: dict[str, str] = {
190
+ "client_id": _client_id(),
191
+ "refresh_token": refresh_token,
192
+ "grant_type": "refresh_token",
193
+ }
194
+ secret = _client_secret()
195
+ if secret:
196
+ data["client_secret"] = secret
197
+ payload = _post_form(GOOGLE_TOKEN_URL, data)
198
+ return _normalize_token_response(payload, fallback_refresh=refresh_token)
199
+
200
+
201
+ def _normalize_token_response(
202
+ payload: dict[str, Any],
203
+ *,
204
+ fallback_refresh: str | None = None,
205
+ ) -> dict[str, Any]:
206
+ if "error" in payload:
207
+ raise RuntimeError(
208
+ f"Google OAuth error: {payload.get('error')}: "
209
+ f"{payload.get('error_description', '(no description)')}"
210
+ )
211
+ access_token = payload.get("access_token")
212
+ if not access_token:
213
+ raise RuntimeError(f"Google OAuth response missing access_token: {payload!r}")
214
+ expires_in = int(payload.get("expires_in", 3600))
215
+ tokens: dict[str, Any] = {
216
+ "access_token": access_token,
217
+ "expires_at": int(time.time()) + expires_in,
218
+ "expires_in": expires_in,
219
+ "scope": payload.get("scope", ""),
220
+ "token_type": payload.get("token_type", "Bearer"),
221
+ }
222
+ refresh = payload.get("refresh_token") or fallback_refresh
223
+ if refresh:
224
+ tokens["refresh_token"] = refresh
225
+ id_token = payload.get("id_token")
226
+ if id_token:
227
+ tokens["id_token"] = id_token
228
+ return tokens
229
+
230
+
231
+ def revoke_token(token: str) -> bool:
232
+ """Best-effort revoke at Google. Failures are non-fatal."""
233
+ body = urllib.parse.urlencode({"token": token}).encode("utf-8")
234
+ request = urllib.request.Request(
235
+ GOOGLE_REVOKE_URL,
236
+ data=body,
237
+ method="POST",
238
+ headers={
239
+ "Content-Type": "application/x-www-form-urlencoded",
240
+ "Content-Length": str(len(body)),
241
+ },
242
+ )
243
+ try:
244
+ with urllib.request.urlopen(request, timeout=15) as response:
245
+ return 200 <= response.status < 300
246
+ except urllib.error.HTTPError:
247
+ return False
248
+ except urllib.error.URLError:
249
+ return False
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Token persistence
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ def save_tokens(tokens: dict[str, Any]) -> None:
258
+ AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
259
+ _atomic_write_text(AUTH_FILE, json.dumps(tokens, indent=2))
260
+ try:
261
+ os.chmod(AUTH_FILE, 0o600)
262
+ except (OSError, NotImplementedError):
263
+ pass
264
+
265
+
266
+ def load_tokens() -> dict[str, Any] | None:
267
+ if not AUTH_FILE.exists():
268
+ return None
269
+ try:
270
+ return json.loads(AUTH_FILE.read_text(encoding="utf-8"))
271
+ except (OSError, json.JSONDecodeError):
272
+ return None
273
+
274
+
275
+ def clear_tokens(*, revoke: bool = True) -> bool:
276
+ tokens = load_tokens() if revoke else None
277
+ if tokens:
278
+ for key in ("refresh_token", "access_token"):
279
+ value = tokens.get(key)
280
+ if value:
281
+ revoke_token(value)
282
+ if not AUTH_FILE.exists():
283
+ return False
284
+ try:
285
+ AUTH_FILE.unlink()
286
+ return True
287
+ except OSError:
288
+ return False
289
+
290
+
291
+ def save_pending_login(prep: dict[str, str]) -> None:
292
+ PENDING_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
293
+ payload = {
294
+ "state": prep.get("state", ""),
295
+ "code_verifier": prep.get("code_verifier", ""),
296
+ "redirect_uri": prep.get("redirect_uri", ""),
297
+ "redirect_port": prep.get("redirect_port", ""),
298
+ "created_at": int(time.time()),
299
+ }
300
+ _atomic_write_text(PENDING_AUTH_FILE, json.dumps(payload, indent=2))
301
+ try:
302
+ os.chmod(PENDING_AUTH_FILE, 0o600)
303
+ except (OSError, NotImplementedError):
304
+ pass
305
+
306
+
307
+ def load_pending_login(*, max_age_seconds: int = _PENDING_LOGIN_TTL_SECONDS) -> dict[str, str] | None:
308
+ if not PENDING_AUTH_FILE.exists():
309
+ return None
310
+ try:
311
+ payload = json.loads(PENDING_AUTH_FILE.read_text(encoding="utf-8"))
312
+ except (OSError, json.JSONDecodeError):
313
+ return None
314
+ if not isinstance(payload, dict):
315
+ return None
316
+ created_at = int(payload.get("created_at", 0) or 0)
317
+ if created_at and int(time.time()) - created_at > max_age_seconds:
318
+ clear_pending_login()
319
+ return None
320
+ required = ("state", "code_verifier", "redirect_uri")
321
+ if not all(str(payload.get(key) or "").strip() for key in required):
322
+ return None
323
+ return {key: str(value) for key, value in payload.items()}
324
+
325
+
326
+ def clear_pending_login() -> bool:
327
+ if not PENDING_AUTH_FILE.exists():
328
+ return False
329
+ try:
330
+ PENDING_AUTH_FILE.unlink()
331
+ return True
332
+ except OSError:
333
+ return False
334
+
335
+
336
+ def is_token_expired(tokens: dict[str, Any], *, window: int = _REFRESH_WINDOW_SECONDS) -> bool:
337
+ return int(tokens.get("expires_at", 0)) - window <= int(time.time())
338
+
339
+
340
+ def get_valid_token() -> str | None:
341
+ tokens = load_tokens()
342
+ if not tokens:
343
+ return None
344
+ if not is_token_expired(tokens):
345
+ return tokens.get("access_token")
346
+ refresh = tokens.get("refresh_token")
347
+ if not refresh:
348
+ return None
349
+ try:
350
+ new_tokens = refresh_access_token(refresh)
351
+ except Exception:
352
+ return None
353
+ save_tokens(new_tokens)
354
+ return new_tokens.get("access_token")
355
+
356
+
357
+ def auth_status() -> dict[str, Any]:
358
+ tokens = load_tokens()
359
+ client_configured = bool(_client_id())
360
+ if not tokens:
361
+ return {
362
+ "authenticated": False,
363
+ "client_configured": client_configured,
364
+ "client_secret_configured": bool(_client_secret()),
365
+ }
366
+ expires_at = int(tokens.get("expires_at", 0))
367
+ return {
368
+ "authenticated": True,
369
+ "client_configured": client_configured,
370
+ "client_secret_configured": bool(_client_secret()),
371
+ "expires_at": expires_at,
372
+ "expires_in": max(0, expires_at - int(time.time())),
373
+ "scope": tokens.get("scope", ""),
374
+ "has_refresh_token": bool(tokens.get("refresh_token")),
375
+ "token_type": tokens.get("token_type", "Bearer"),
376
+ }
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Loopback capture
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ def port_is_free(port: int = GOOGLE_REDIRECT_PORT) -> bool:
385
+ sock = socket.socket()
386
+ sock.settimeout(0.2)
387
+ try:
388
+ return sock.connect_ex((GOOGLE_REDIRECT_HOST, port)) != 0
389
+ except OSError:
390
+ return False
391
+ finally:
392
+ sock.close()
393
+
394
+
395
+ def select_redirect_port() -> int | None:
396
+ for port in GOOGLE_REDIRECT_PORT_RANGE:
397
+ if port_is_free(port):
398
+ return port
399
+ return None
400
+
401
+
402
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
403
+ """Single-request handler that captures the OAuth callback query string."""
404
+
405
+ query_params: dict[str, str] = {}
406
+ done = False
407
+
408
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature
409
+ return # quiet
410
+
411
+ def do_GET(self) -> None: # noqa: N802 - http.server signature
412
+ parsed = urllib.parse.urlparse(self.path)
413
+ if parsed.path != "/callback":
414
+ self.send_response(404)
415
+ self.end_headers()
416
+ self.wfile.write(b"Not found")
417
+ return
418
+ params = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
419
+ type(_CallbackHandler).query_params = params
420
+ type(_CallbackHandler).done = True
421
+ body = (
422
+ b"<!doctype html><html><body><h2>Algo CLI: Google Workspace login complete.</h2>"
423
+ b"<p>You can close this tab and return to Algo CLI.</p></body></html>"
424
+ )
425
+ self.send_response(200)
426
+ self.send_header("Content-Type", "text/html; charset=utf-8")
427
+ self.send_header("Content-Length", str(len(body)))
428
+ self.end_headers()
429
+ self.wfile.write(body)
430
+
431
+
432
+ def run_loopback_capture(*, redirect_port: int, timeout: float = _CALLBACK_TIMEOUT_SECONDS) -> dict[str, str]:
433
+ """Block until a callback arrives, then return the query parameters."""
434
+ type(_CallbackHandler).query_params = {}
435
+ type(_CallbackHandler).done = False
436
+ server = http.server.HTTPServer(
437
+ (GOOGLE_REDIRECT_HOST, redirect_port), _CallbackHandler
438
+ )
439
+ server.timeout = 1.0
440
+ deadline = time.time() + max(1.0, float(timeout))
441
+ try:
442
+ while time.time() < deadline and not _CallbackHandler.done:
443
+ server.handle_request()
444
+ finally:
445
+ server.server_close()
446
+ if not _CallbackHandler.done:
447
+ raise RuntimeError(
448
+ f"Timed out after {int(timeout)}s waiting for Google OAuth callback."
449
+ )
450
+ return dict(_CallbackHandler.query_params)
451
+
452
+
453
+ def wait_for_callback(*, redirect_port: int, timeout: float = _CALLBACK_TIMEOUT_SECONDS) -> dict[str, str]:
454
+ """Compatibility wrapper for the Google login flow's loopback callback wait."""
455
+ return run_loopback_capture(redirect_port=redirect_port, timeout=timeout)
456
+
457
+
458
+ # ---------------------------------------------------------------------------
459
+ # Login orchestration
460
+ # ---------------------------------------------------------------------------
461
+
462
+
463
+ def begin_login(*, no_browser: bool = False, redirect_port: int = GOOGLE_REDIRECT_PORT) -> dict[str, str]:
464
+ state = secrets.token_urlsafe(32)
465
+ verifier, challenge = generate_pkce_pair()
466
+ redirect_uri = redirect_uri_for_port(redirect_port)
467
+ url = build_authorize_url(state=state, code_challenge=challenge, redirect_uri=redirect_uri)
468
+ if not no_browser:
469
+ opened = webbrowser.open(url)
470
+ if not opened:
471
+ no_browser = True
472
+ prep = {
473
+ "state": state,
474
+ "code_verifier": verifier,
475
+ "auth_url": url,
476
+ "redirect_uri": redirect_uri,
477
+ "redirect_port": str(redirect_port),
478
+ "ssh_tunnel_cmd": (
479
+ f"ssh -N -L {redirect_port}:{GOOGLE_REDIRECT_HOST}:{redirect_port} you@remote-host"
480
+ ),
481
+ "browser_opened": not no_browser,
482
+ }
483
+ save_pending_login(prep)
484
+ return prep
485
+
486
+
487
+ def parse_callback_value(value: str) -> dict[str, str]:
488
+ """Parse a full callback URL, malformed URL, or raw query string."""
489
+ text = (value or "").strip()
490
+ if not text:
491
+ return {}
492
+ parsed = urllib.parse.urlparse(text)
493
+ query = parsed.query
494
+ if not query:
495
+ candidate = text[1:] if text.startswith("?") else text
496
+ if "=" in candidate:
497
+ query = candidate
498
+ if not query:
499
+ return {}
500
+ params = urllib.parse.parse_qs(query, keep_blank_values=True)
501
+ return {key: values[0] for key, values in params.items() if values}
502
+
503
+
504
+ def complete_login(
505
+ code_verifier: str,
506
+ state: str,
507
+ callback: dict[str, str] | None,
508
+ ) -> dict[str, Any]:
509
+ if not callback:
510
+ raise RuntimeError("Timed out waiting for OAuth callback.")
511
+ if "error" in callback:
512
+ raise RuntimeError(
513
+ f"OAuth error: {callback.get('error_description', callback['error'])}"
514
+ )
515
+ if callback.get("state") != state:
516
+ raise RuntimeError("OAuth state mismatch - possible CSRF attack.")
517
+ code = callback.get("code")
518
+ if not code:
519
+ raise RuntimeError("No authorization code in callback.")
520
+ tokens = exchange_code(code, code_verifier, redirect_uri=callback.get("redirect_uri", ""))
521
+ save_tokens(tokens)
522
+ clear_pending_login()
523
+ return tokens