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
algo_cli/chatgpt_auth.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""ChatGPT/OpenAI OAuth 2.0 + PKCE helpers.
|
|
2
|
+
|
|
3
|
+
OpenAI's public OAuth configuration can vary by application. Algo CLI defaults
|
|
4
|
+
to the Codex browser OAuth client used by the subscription runtime, while still
|
|
5
|
+
allowing provider configuration through environment variables:
|
|
6
|
+
|
|
7
|
+
- OPENAI_OAUTH_CLIENT_ID (optional override for /chatgpt-login)
|
|
8
|
+
- OPENAI_CODEX_CLIENT_ID (optional override for the bundled Codex client)
|
|
9
|
+
- OPENAI_OAUTH_AUTHORIZE_URL (optional)
|
|
10
|
+
- OPENAI_OAUTH_TOKEN_URL (optional)
|
|
11
|
+
- OPENAI_API_BASE (optional, defaults to https://api.openai.com/v1)
|
|
12
|
+
|
|
13
|
+
The runtime shape intentionally mirrors xai_auth.py so tests and user-facing
|
|
14
|
+
commands behave consistently across cloud providers.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import http.server
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import secrets
|
|
24
|
+
import shutil
|
|
25
|
+
import socket
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
import webbrowser
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .config import CONFIG_DIR, _atomic_write_text
|
|
34
|
+
|
|
35
|
+
CHATGPT_CLIENT_ID = os.environ.get("OPENAI_OAUTH_CLIENT_ID", "").strip()
|
|
36
|
+
CHATGPT_AUTHORIZE_URL = os.environ.get("OPENAI_OAUTH_AUTHORIZE_URL", "https://auth.openai.com/oauth/authorize")
|
|
37
|
+
CHATGPT_TOKEN_URL = os.environ.get("OPENAI_OAUTH_TOKEN_URL", "https://auth.openai.com/oauth/token")
|
|
38
|
+
CHATGPT_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1").rstrip("/")
|
|
39
|
+
CHATGPT_CODEX_CLIENT_ID = os.environ.get("OPENAI_CODEX_CLIENT_ID", "app_EMoamEEZ73f0CkXaXp7hrann").strip()
|
|
40
|
+
CHATGPT_REDIRECT_HOST = "localhost"
|
|
41
|
+
CHATGPT_REDIRECT_PORT = 1455
|
|
42
|
+
CHATGPT_REDIRECT_PORT_RANGE = range(1455, 1456)
|
|
43
|
+
CHATGPT_REDIRECT_PATH = "/auth/callback"
|
|
44
|
+
CHATGPT_REDIRECT_URI = f"http://{CHATGPT_REDIRECT_HOST}:{CHATGPT_REDIRECT_PORT}{CHATGPT_REDIRECT_PATH}"
|
|
45
|
+
CHATGPT_DEFAULT_SCOPE = os.environ.get("OPENAI_OAUTH_SCOPE", "openid offline_access profile email")
|
|
46
|
+
CODEX_DEVICE_VERIFY_URL = "https://auth.openai.com/codex/device"
|
|
47
|
+
CODEX_AUTH_HOME = CONFIG_DIR / "codex-chatgpt"
|
|
48
|
+
AUTH_FILE = CONFIG_DIR / "chatgpt_auth.json"
|
|
49
|
+
_REFRESH_WINDOW_SECONDS = 60
|
|
50
|
+
_CALLBACK_TIMEOUT_SECONDS = 300.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _client_id() -> str:
|
|
54
|
+
return (CHATGPT_CLIENT_ID or os.environ.get("OPENAI_OAUTH_CLIENT_ID", "") or CHATGPT_CODEX_CLIENT_ID).strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_codex_bin() -> str | None:
|
|
58
|
+
"""Find Codex even when a long-running shell has stale PATH."""
|
|
59
|
+
override = os.environ.get("CODEX_BIN", "").strip()
|
|
60
|
+
if override:
|
|
61
|
+
return override
|
|
62
|
+
for name in ("codex", "codex.cmd", "codex.exe", "codex.ps1"):
|
|
63
|
+
found = shutil.which(name)
|
|
64
|
+
if found:
|
|
65
|
+
return found
|
|
66
|
+
candidate_dirs: list[str] = []
|
|
67
|
+
appdata = os.environ.get("APPDATA", "").strip()
|
|
68
|
+
if appdata:
|
|
69
|
+
candidate_dirs.append(os.path.join(appdata, "npm"))
|
|
70
|
+
userprofile = os.environ.get("USERPROFILE", "").strip()
|
|
71
|
+
if userprofile:
|
|
72
|
+
candidate_dirs.append(os.path.join(userprofile, "AppData", "Roaming", "npm"))
|
|
73
|
+
home = os.path.expanduser("~")
|
|
74
|
+
if home and home != "~":
|
|
75
|
+
candidate_dirs.append(os.path.join(home, "AppData", "Roaming", "npm"))
|
|
76
|
+
candidate_dirs.append(os.path.join(home, ".npm-global", "bin"))
|
|
77
|
+
for directory in candidate_dirs:
|
|
78
|
+
for filename in ("codex.cmd", "codex.exe", "codex", "codex.ps1"):
|
|
79
|
+
candidate = os.path.join(directory, filename)
|
|
80
|
+
if os.path.exists(candidate):
|
|
81
|
+
return candidate
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _b64url(data: bytes) -> str:
|
|
86
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
90
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
91
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
92
|
+
return verifier, challenge
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def redirect_uri_for_port(port: int = CHATGPT_REDIRECT_PORT) -> str:
|
|
96
|
+
return f"http://{CHATGPT_REDIRECT_HOST}:{port}{CHATGPT_REDIRECT_PATH}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_authorize_url(
|
|
100
|
+
*,
|
|
101
|
+
state: str,
|
|
102
|
+
code_challenge: str,
|
|
103
|
+
scope: str = CHATGPT_DEFAULT_SCOPE,
|
|
104
|
+
redirect_uri: str | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
client_id = _client_id()
|
|
107
|
+
if not client_id:
|
|
108
|
+
raise RuntimeError("ChatGPT OAuth client id is empty. Set OPENAI_OAUTH_CLIENT_ID or OPENAI_CODEX_CLIENT_ID.")
|
|
109
|
+
params = {
|
|
110
|
+
"response_type": "code",
|
|
111
|
+
"client_id": client_id,
|
|
112
|
+
"redirect_uri": redirect_uri or CHATGPT_REDIRECT_URI,
|
|
113
|
+
"scope": scope,
|
|
114
|
+
"state": state,
|
|
115
|
+
"code_challenge": code_challenge,
|
|
116
|
+
"code_challenge_method": "S256",
|
|
117
|
+
"id_token_add_organizations": "true",
|
|
118
|
+
"codex_cli_simplified_flow": "true",
|
|
119
|
+
"originator": os.environ.get("OPENAI_CODEX_ORIGINATOR", "pi").strip() or "pi",
|
|
120
|
+
}
|
|
121
|
+
return f"{CHATGPT_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
125
|
+
received: dict[str, str] = {}
|
|
126
|
+
|
|
127
|
+
def do_GET(self) -> None: # noqa: N802
|
|
128
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
129
|
+
if parsed.path not in {"/callback", CHATGPT_REDIRECT_PATH}:
|
|
130
|
+
self.send_response(404)
|
|
131
|
+
self.end_headers()
|
|
132
|
+
return
|
|
133
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
134
|
+
type(self).received = {k: v[0] for k, v in qs.items() if v}
|
|
135
|
+
self.send_response(200)
|
|
136
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
137
|
+
self.end_headers()
|
|
138
|
+
ok = "code" in type(self).received and "error" not in type(self).received
|
|
139
|
+
title = "ChatGPT login complete." if ok else "ChatGPT login failed."
|
|
140
|
+
self.wfile.write(
|
|
141
|
+
f"<html><body style='font-family:sans-serif;padding:2em'><h2>{title}</h2>"
|
|
142
|
+
"<p>You can close this tab and return to algo-cli.</p></body></html>".encode("utf-8")
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_loopback_capture(*, timeout: float = _CALLBACK_TIMEOUT_SECONDS, redirect_port: int = CHATGPT_REDIRECT_PORT) -> dict[str, str]:
|
|
150
|
+
received: dict[str, str] = {}
|
|
151
|
+
Handler = type("_ChatGptLoopbackCallbackHandler", (_CallbackHandler,), {"received": received})
|
|
152
|
+
try:
|
|
153
|
+
server = http.server.HTTPServer((CHATGPT_REDIRECT_HOST, redirect_port), Handler)
|
|
154
|
+
except OSError:
|
|
155
|
+
return {}
|
|
156
|
+
server.timeout = 1.0
|
|
157
|
+
deadline = time.time() + timeout
|
|
158
|
+
try:
|
|
159
|
+
while not Handler.received and time.time() < deadline:
|
|
160
|
+
server.handle_request()
|
|
161
|
+
finally:
|
|
162
|
+
server.server_close()
|
|
163
|
+
return dict(Handler.received)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _post_token_endpoint(form: dict[str, str]) -> dict[str, Any]:
|
|
167
|
+
data = urllib.parse.urlencode(form).encode("ascii")
|
|
168
|
+
req = urllib.request.Request(
|
|
169
|
+
CHATGPT_TOKEN_URL,
|
|
170
|
+
data=data,
|
|
171
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
|
|
172
|
+
method="POST",
|
|
173
|
+
)
|
|
174
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
175
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _normalize_token_response(payload: dict[str, Any], *, fallback_refresh: str | None = None) -> dict[str, Any]:
|
|
179
|
+
if "access_token" not in payload:
|
|
180
|
+
raise RuntimeError(f"Token endpoint returned no access_token: {payload}")
|
|
181
|
+
now = int(time.time())
|
|
182
|
+
try:
|
|
183
|
+
expires_in = int(payload.get("expires_in", 3600))
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
expires_in = 3600
|
|
186
|
+
expires_at = payload.get("expires_at")
|
|
187
|
+
try:
|
|
188
|
+
expires_at_int = int(expires_at) if expires_at is not None else now + expires_in
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
expires_at_int = now + expires_in
|
|
191
|
+
normalized = {
|
|
192
|
+
"access_token": str(payload["access_token"]),
|
|
193
|
+
"refresh_token": payload.get("refresh_token") or fallback_refresh,
|
|
194
|
+
"token_type": payload.get("token_type", "Bearer"),
|
|
195
|
+
"expires_at": expires_at_int,
|
|
196
|
+
"scope": payload.get("scope", ""),
|
|
197
|
+
"obtained_at": int(payload.get("obtained_at", now) or now),
|
|
198
|
+
}
|
|
199
|
+
account_id = (
|
|
200
|
+
payload.get("account_id")
|
|
201
|
+
or payload.get("accountId")
|
|
202
|
+
or payload.get("chatgpt_account_id")
|
|
203
|
+
or _extract_chatgpt_account_id(str(payload["access_token"]))
|
|
204
|
+
)
|
|
205
|
+
if account_id:
|
|
206
|
+
normalized["account_id"] = account_id
|
|
207
|
+
return normalized
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
211
|
+
parts = str(token or "").split(".")
|
|
212
|
+
if len(parts) < 2:
|
|
213
|
+
return {}
|
|
214
|
+
payload = parts[1]
|
|
215
|
+
payload += "=" * (-len(payload) % 4)
|
|
216
|
+
try:
|
|
217
|
+
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
|
|
218
|
+
data = json.loads(decoded.decode("utf-8"))
|
|
219
|
+
except Exception:
|
|
220
|
+
return {}
|
|
221
|
+
return data if isinstance(data, dict) else {}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _extract_chatgpt_account_id(token: str) -> str | None:
|
|
225
|
+
claims = _decode_jwt_payload(token)
|
|
226
|
+
auth_claim = claims.get("https://api.openai.com/auth")
|
|
227
|
+
if isinstance(auth_claim, dict):
|
|
228
|
+
value = auth_claim.get("chatgpt_account_id") or auth_claim.get("account_id")
|
|
229
|
+
if value:
|
|
230
|
+
return str(value)
|
|
231
|
+
for key in ("chatgpt_account_id", "account_id"):
|
|
232
|
+
value = claims.get(key)
|
|
233
|
+
if value:
|
|
234
|
+
return str(value)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _extract_codex_tokens(payload: dict[str, Any]) -> dict[str, Any]:
|
|
239
|
+
candidates: list[dict[str, Any]] = []
|
|
240
|
+
for key in ("tokens", "chatgptAuthTokens", "chatgpt_auth_tokens"):
|
|
241
|
+
value = payload.get(key)
|
|
242
|
+
if isinstance(value, dict):
|
|
243
|
+
candidates.append(value)
|
|
244
|
+
candidates.append(payload)
|
|
245
|
+
|
|
246
|
+
for candidate in candidates:
|
|
247
|
+
access = candidate.get("access_token") or candidate.get("accessToken")
|
|
248
|
+
if not access:
|
|
249
|
+
continue
|
|
250
|
+
token_payload = {
|
|
251
|
+
"access_token": access,
|
|
252
|
+
"refresh_token": candidate.get("refresh_token") or candidate.get("refreshToken"),
|
|
253
|
+
"token_type": candidate.get("token_type") or candidate.get("tokenType") or "Bearer",
|
|
254
|
+
"scope": candidate.get("scope") or payload.get("scope") or "",
|
|
255
|
+
"obtained_at": candidate.get("obtained_at") or candidate.get("obtainedAt") or payload.get("obtained_at"),
|
|
256
|
+
}
|
|
257
|
+
if candidate.get("expires_at") is not None:
|
|
258
|
+
token_payload["expires_at"] = candidate.get("expires_at")
|
|
259
|
+
elif candidate.get("expiresAt") is not None:
|
|
260
|
+
token_payload["expires_at"] = candidate.get("expiresAt")
|
|
261
|
+
elif candidate.get("expires_in") is not None:
|
|
262
|
+
token_payload["expires_in"] = candidate.get("expires_in")
|
|
263
|
+
elif candidate.get("expiresIn") is not None:
|
|
264
|
+
token_payload["expires_in"] = candidate.get("expiresIn")
|
|
265
|
+
normalized = _normalize_token_response(token_payload)
|
|
266
|
+
normalized["provider"] = "chatgpt-codex"
|
|
267
|
+
account_id = (
|
|
268
|
+
payload.get("account_id")
|
|
269
|
+
or payload.get("accountId")
|
|
270
|
+
or payload.get("chatgpt_account_id")
|
|
271
|
+
or candidate.get("account_id")
|
|
272
|
+
or candidate.get("accountId")
|
|
273
|
+
or candidate.get("chatgpt_account_id")
|
|
274
|
+
or _extract_chatgpt_account_id(str(access))
|
|
275
|
+
)
|
|
276
|
+
if account_id:
|
|
277
|
+
normalized["account_id"] = account_id
|
|
278
|
+
return normalized
|
|
279
|
+
raise RuntimeError("Codex auth.json did not contain ChatGPT access tokens.")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def import_codex_auth_file(path: str | os.PathLike[str] | None = None) -> dict[str, Any]:
|
|
283
|
+
auth_path = os.fspath(path or (CODEX_AUTH_HOME / "auth.json"))
|
|
284
|
+
try:
|
|
285
|
+
payload = json.loads(open(auth_path, "r", encoding="utf-8").read())
|
|
286
|
+
except FileNotFoundError as exc:
|
|
287
|
+
raise RuntimeError(f"Codex auth file was not created at {auth_path}.") from exc
|
|
288
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
289
|
+
raise RuntimeError(f"Could not read Codex auth file at {auth_path}: {exc}") from exc
|
|
290
|
+
if not isinstance(payload, dict):
|
|
291
|
+
raise RuntimeError(f"Codex auth file at {auth_path} did not contain a JSON object.")
|
|
292
|
+
tokens = _extract_codex_tokens(payload)
|
|
293
|
+
save_tokens(tokens)
|
|
294
|
+
return tokens
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def run_codex_device_login(*, codex_bin: str | None = None, runner: Any = subprocess.run) -> dict[str, Any]:
|
|
298
|
+
resolved_codex_bin = codex_bin or resolve_codex_bin()
|
|
299
|
+
if not resolved_codex_bin:
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
"Codex CLI is not installed or not discoverable. Install it with: "
|
|
302
|
+
"npm install -g @openai/codex. If it is already installed, restart "
|
|
303
|
+
"your terminal or set CODEX_BIN to the full path to codex.cmd."
|
|
304
|
+
)
|
|
305
|
+
CODEX_AUTH_HOME.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
env = os.environ.copy()
|
|
307
|
+
env["CODEX_HOME"] = str(CODEX_AUTH_HOME)
|
|
308
|
+
cmd = [resolved_codex_bin, "login", "--device-auth", "-c", 'cli_auth_credentials_store="file"']
|
|
309
|
+
try:
|
|
310
|
+
result = runner(cmd, env=env, check=False)
|
|
311
|
+
except FileNotFoundError as exc:
|
|
312
|
+
raise RuntimeError(
|
|
313
|
+
"Codex CLI is not installed or not discoverable. Install it with: "
|
|
314
|
+
"npm install -g @openai/codex. If it is already installed, restart "
|
|
315
|
+
"your terminal or set CODEX_BIN to the full path to codex.cmd."
|
|
316
|
+
) from exc
|
|
317
|
+
returncode = getattr(result, "returncode", 0)
|
|
318
|
+
if returncode != 0:
|
|
319
|
+
raise RuntimeError(f"Codex device-code login failed with exit code {returncode}.")
|
|
320
|
+
return import_codex_auth_file(CODEX_AUTH_HOME / "auth.json")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def exchange_code(code: str, code_verifier: str, *, redirect_uri: str | None = None) -> dict[str, Any]:
|
|
324
|
+
payload = _post_token_endpoint(
|
|
325
|
+
{
|
|
326
|
+
"grant_type": "authorization_code",
|
|
327
|
+
"client_id": _client_id(),
|
|
328
|
+
"code": code,
|
|
329
|
+
"redirect_uri": redirect_uri or CHATGPT_REDIRECT_URI,
|
|
330
|
+
"code_verifier": code_verifier,
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
return _normalize_token_response(payload)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def refresh_access_token(
|
|
337
|
+
refresh_token: str,
|
|
338
|
+
*,
|
|
339
|
+
scope: str | None = CHATGPT_DEFAULT_SCOPE,
|
|
340
|
+
client_id: str | None = None,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
form = {
|
|
343
|
+
"grant_type": "refresh_token",
|
|
344
|
+
"client_id": client_id or _client_id(),
|
|
345
|
+
"refresh_token": refresh_token,
|
|
346
|
+
}
|
|
347
|
+
if scope is not None:
|
|
348
|
+
form["scope"] = scope
|
|
349
|
+
payload = _post_token_endpoint(form)
|
|
350
|
+
return _normalize_token_response(payload, fallback_refresh=refresh_token)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def refresh_codex_access_token(refresh_token: str) -> dict[str, Any]:
|
|
354
|
+
tokens = refresh_access_token(refresh_token, scope=None, client_id=CHATGPT_CODEX_CLIENT_ID)
|
|
355
|
+
tokens["provider"] = "chatgpt-codex"
|
|
356
|
+
return tokens
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def save_tokens(tokens: dict[str, Any]) -> None:
|
|
360
|
+
AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
_atomic_write_text(AUTH_FILE, json.dumps(tokens, indent=2))
|
|
362
|
+
try:
|
|
363
|
+
os.chmod(AUTH_FILE, 0o600)
|
|
364
|
+
except (OSError, NotImplementedError):
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def load_tokens() -> dict[str, Any] | None:
|
|
369
|
+
if not AUTH_FILE.exists():
|
|
370
|
+
return None
|
|
371
|
+
try:
|
|
372
|
+
return json.loads(AUTH_FILE.read_text(encoding="utf-8"))
|
|
373
|
+
except (OSError, json.JSONDecodeError):
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def clear_tokens() -> bool:
|
|
378
|
+
if not AUTH_FILE.exists():
|
|
379
|
+
return False
|
|
380
|
+
try:
|
|
381
|
+
AUTH_FILE.unlink()
|
|
382
|
+
return True
|
|
383
|
+
except OSError:
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def is_token_expired(tokens: dict[str, Any], *, window: int = _REFRESH_WINDOW_SECONDS) -> bool:
|
|
388
|
+
return int(tokens.get("expires_at", 0)) - window <= int(time.time())
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def get_valid_token() -> str | None:
|
|
392
|
+
tokens = load_tokens()
|
|
393
|
+
if not tokens:
|
|
394
|
+
return None
|
|
395
|
+
if not is_token_expired(tokens):
|
|
396
|
+
return tokens.get("access_token")
|
|
397
|
+
refresh = tokens.get("refresh_token")
|
|
398
|
+
if not refresh:
|
|
399
|
+
return None
|
|
400
|
+
try:
|
|
401
|
+
if tokens.get("provider") == "chatgpt-codex":
|
|
402
|
+
new_tokens = refresh_codex_access_token(refresh)
|
|
403
|
+
else:
|
|
404
|
+
new_tokens = refresh_access_token(refresh)
|
|
405
|
+
except Exception:
|
|
406
|
+
return None
|
|
407
|
+
save_tokens(new_tokens)
|
|
408
|
+
return new_tokens.get("access_token")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def force_refresh_token() -> str | None:
|
|
412
|
+
tokens = load_tokens()
|
|
413
|
+
if not tokens or not tokens.get("refresh_token"):
|
|
414
|
+
return None
|
|
415
|
+
try:
|
|
416
|
+
if tokens.get("provider") == "chatgpt-codex":
|
|
417
|
+
new_tokens = refresh_codex_access_token(str(tokens["refresh_token"]))
|
|
418
|
+
else:
|
|
419
|
+
new_tokens = refresh_access_token(str(tokens["refresh_token"]))
|
|
420
|
+
except Exception:
|
|
421
|
+
return None
|
|
422
|
+
save_tokens(new_tokens)
|
|
423
|
+
return new_tokens.get("access_token")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def get_chatgpt_account_id() -> str | None:
|
|
427
|
+
tokens = load_tokens()
|
|
428
|
+
if not tokens:
|
|
429
|
+
return None
|
|
430
|
+
account_id = tokens.get("account_id") or tokens.get("accountId") or tokens.get("chatgpt_account_id")
|
|
431
|
+
if account_id:
|
|
432
|
+
return str(account_id)
|
|
433
|
+
access_token = tokens.get("access_token")
|
|
434
|
+
if not access_token:
|
|
435
|
+
return None
|
|
436
|
+
account_id = _extract_chatgpt_account_id(str(access_token))
|
|
437
|
+
if account_id:
|
|
438
|
+
tokens["account_id"] = account_id
|
|
439
|
+
save_tokens(tokens)
|
|
440
|
+
return account_id
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def auth_status() -> dict[str, Any]:
|
|
444
|
+
tokens = load_tokens()
|
|
445
|
+
if not tokens:
|
|
446
|
+
return {"authenticated": False, "client_configured": bool(_client_id())}
|
|
447
|
+
expires_at = int(tokens.get("expires_at", 0))
|
|
448
|
+
return {
|
|
449
|
+
"authenticated": True,
|
|
450
|
+
"client_configured": bool(_client_id()),
|
|
451
|
+
"expires_at": expires_at,
|
|
452
|
+
"expires_in": max(0, expires_at - int(time.time())),
|
|
453
|
+
"scope": tokens.get("scope", ""),
|
|
454
|
+
"has_refresh_token": bool(tokens.get("refresh_token")),
|
|
455
|
+
"token_type": tokens.get("token_type", "Bearer"),
|
|
456
|
+
"has_account_id": bool(tokens.get("account_id") or tokens.get("accountId") or tokens.get("chatgpt_account_id")),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def port_is_free(port: int = CHATGPT_REDIRECT_PORT) -> bool:
|
|
461
|
+
sock = socket.socket()
|
|
462
|
+
sock.settimeout(0.2)
|
|
463
|
+
try:
|
|
464
|
+
return sock.connect_ex((CHATGPT_REDIRECT_HOST, port)) != 0
|
|
465
|
+
except OSError:
|
|
466
|
+
return False
|
|
467
|
+
finally:
|
|
468
|
+
sock.close()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def select_redirect_port() -> int | None:
|
|
472
|
+
for port in CHATGPT_REDIRECT_PORT_RANGE:
|
|
473
|
+
if port_is_free(port):
|
|
474
|
+
return port
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def begin_login(*, no_browser: bool = False, redirect_port: int = CHATGPT_REDIRECT_PORT) -> dict[str, str]:
|
|
479
|
+
state = secrets.token_urlsafe(32)
|
|
480
|
+
verifier, challenge = generate_pkce_pair()
|
|
481
|
+
redirect_uri = redirect_uri_for_port(redirect_port)
|
|
482
|
+
url = build_authorize_url(state=state, code_challenge=challenge, redirect_uri=redirect_uri)
|
|
483
|
+
if not no_browser:
|
|
484
|
+
opened = webbrowser.open(url)
|
|
485
|
+
if not opened:
|
|
486
|
+
no_browser = True
|
|
487
|
+
return {
|
|
488
|
+
"state": state,
|
|
489
|
+
"code_verifier": verifier,
|
|
490
|
+
"auth_url": url,
|
|
491
|
+
"redirect_uri": redirect_uri,
|
|
492
|
+
"redirect_port": str(redirect_port),
|
|
493
|
+
"ssh_tunnel_cmd": f"ssh -N -L {redirect_port}:{CHATGPT_REDIRECT_HOST}:{redirect_port} you@remote-host",
|
|
494
|
+
"browser_opened": not no_browser,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def complete_login(code_verifier: str, state: str, callback: dict[str, str] | None) -> dict[str, Any]:
|
|
499
|
+
if not callback:
|
|
500
|
+
raise RuntimeError("Timed out waiting for OAuth callback.")
|
|
501
|
+
if "error" in callback:
|
|
502
|
+
raise RuntimeError(f"OAuth error: {callback.get('error_description', callback['error'])}")
|
|
503
|
+
if callback.get("state") != state:
|
|
504
|
+
raise RuntimeError("OAuth state mismatch - possible CSRF attack.")
|
|
505
|
+
code = callback.get("code")
|
|
506
|
+
if not code:
|
|
507
|
+
raise RuntimeError("No authorization code in callback.")
|
|
508
|
+
tokens = exchange_code(code, code_verifier, redirect_uri=callback.get("redirect_uri"))
|
|
509
|
+
save_tokens(tokens)
|
|
510
|
+
return tokens
|