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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- 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
|