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/x_account.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""X account integration through the official xurl CLI.
|
|
2
|
+
|
|
3
|
+
This module is intentionally separate from xai_auth/xai_client. xAI OAuth
|
|
4
|
+
tokens are for Grok on api.x.ai; account actions use X API OAuth through xurl
|
|
5
|
+
on api.x.com.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
FORBIDDEN_XURL_FLAGS = {
|
|
20
|
+
"--bearer-token",
|
|
21
|
+
"--consumer-key",
|
|
22
|
+
"--consumer-secret",
|
|
23
|
+
"--access-token",
|
|
24
|
+
"--token-secret",
|
|
25
|
+
"--client-id",
|
|
26
|
+
"--client-secret",
|
|
27
|
+
}
|
|
28
|
+
CONFIRMED_POST_ACTIONS = {
|
|
29
|
+
"delete",
|
|
30
|
+
"like",
|
|
31
|
+
"unlike",
|
|
32
|
+
"repost",
|
|
33
|
+
"unrepost",
|
|
34
|
+
"bookmark",
|
|
35
|
+
"unbookmark",
|
|
36
|
+
}
|
|
37
|
+
POST_ID_RE = re.compile(r"(?:status|statuses)/(\d{1,20})(?:\D|$)|^(\d{1,20})$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class XAccountResult:
|
|
42
|
+
ok: bool
|
|
43
|
+
action: str
|
|
44
|
+
message: str
|
|
45
|
+
data: dict[str, Any] | None = None
|
|
46
|
+
|
|
47
|
+
def to_json(self) -> str:
|
|
48
|
+
payload: dict[str, Any] = {
|
|
49
|
+
"ok": self.ok,
|
|
50
|
+
"action": self.action,
|
|
51
|
+
"message": self.message,
|
|
52
|
+
}
|
|
53
|
+
if self.data:
|
|
54
|
+
payload["data"] = self.data
|
|
55
|
+
return json.dumps(payload, indent=2)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def xurl_path() -> str | None:
|
|
59
|
+
"""Return the xurl executable path when it is available."""
|
|
60
|
+
return shutil.which("xurl")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_post_id(value: str) -> str:
|
|
64
|
+
"""Extract an X post id from a numeric id or x.com status URL."""
|
|
65
|
+
raw = (value or "").strip()
|
|
66
|
+
match = POST_ID_RE.search(raw)
|
|
67
|
+
if not match:
|
|
68
|
+
raise ValueError("expected a post id or x.com status URL")
|
|
69
|
+
return match.group(1) or match.group(2) or ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compose_post_url(text: str) -> str:
|
|
73
|
+
return "https://x.com/compose/post?text=" + quote(text or "", safe="")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def reply_url(post: str) -> str:
|
|
77
|
+
post_id = normalize_post_id(post)
|
|
78
|
+
return f"https://x.com/intent/tweet?in_reply_to={post_id}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_xurl_args(args: list[str]) -> None:
|
|
82
|
+
for arg in args:
|
|
83
|
+
if arg.split("=", 1)[0] in FORBIDDEN_XURL_FLAGS:
|
|
84
|
+
raise ValueError(f"refusing inline credential flag: {arg.split('=', 1)[0]}")
|
|
85
|
+
if arg in {"-v", "--verbose"}:
|
|
86
|
+
raise ValueError("refusing verbose xurl mode because it can expose auth headers")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run_xurl(args: list[str], *, timeout: int = 30) -> XAccountResult:
|
|
90
|
+
_validate_xurl_args(args)
|
|
91
|
+
exe = xurl_path()
|
|
92
|
+
if not exe:
|
|
93
|
+
return XAccountResult(
|
|
94
|
+
False,
|
|
95
|
+
"xurl",
|
|
96
|
+
"xurl is not installed or not on PATH. Install xdevplatform/xurl and run `xurl auth status`.",
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run(
|
|
100
|
+
[exe, *args],
|
|
101
|
+
text=True,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
except subprocess.TimeoutExpired:
|
|
107
|
+
return XAccountResult(False, "xurl", f"xurl timed out after {timeout}s.")
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
return XAccountResult(False, "xurl", f"could not run xurl: {exc}")
|
|
110
|
+
|
|
111
|
+
output = (proc.stdout or proc.stderr or "").strip()
|
|
112
|
+
if len(output) > 8000:
|
|
113
|
+
output = output[:8000] + "\n...[truncated]"
|
|
114
|
+
return XAccountResult(
|
|
115
|
+
proc.returncode == 0,
|
|
116
|
+
"xurl",
|
|
117
|
+
output or f"xurl exited with code {proc.returncode}.",
|
|
118
|
+
{"exit_code": proc.returncode},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def status() -> XAccountResult:
|
|
123
|
+
"""Check xurl auth state without reading token files."""
|
|
124
|
+
return _run_xurl(["auth", "status"], timeout=15)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def draft_post(text: str) -> XAccountResult:
|
|
128
|
+
text = (text or "").strip()
|
|
129
|
+
if not text:
|
|
130
|
+
return XAccountResult(False, "draft_post", "post text is empty")
|
|
131
|
+
return XAccountResult(
|
|
132
|
+
True,
|
|
133
|
+
"draft_post",
|
|
134
|
+
"Draft URL generated. Review in browser before posting.",
|
|
135
|
+
{"url": compose_post_url(text), "text": text},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def draft_reply(post: str, text: str) -> XAccountResult:
|
|
140
|
+
text = (text or "").strip()
|
|
141
|
+
if not text:
|
|
142
|
+
return XAccountResult(False, "draft_reply", "reply text is empty")
|
|
143
|
+
try:
|
|
144
|
+
post_id = normalize_post_id(post)
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
return XAccountResult(False, "draft_reply", str(exc))
|
|
147
|
+
return XAccountResult(
|
|
148
|
+
True,
|
|
149
|
+
"draft_reply",
|
|
150
|
+
"Reply draft URL generated. Review in browser before posting.",
|
|
151
|
+
{"url": reply_url(post_id), "text": text, "post_id": post_id},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def post(text: str, *, confirm: bool = False) -> XAccountResult:
|
|
156
|
+
text = (text or "").strip()
|
|
157
|
+
if not text:
|
|
158
|
+
return XAccountResult(False, "post", "post text is empty")
|
|
159
|
+
if not confirm:
|
|
160
|
+
draft = draft_post(text)
|
|
161
|
+
return XAccountResult(
|
|
162
|
+
False,
|
|
163
|
+
"post",
|
|
164
|
+
"Blocked write: pass confirm=True only after the user explicitly approves this exact post.",
|
|
165
|
+
draft.data,
|
|
166
|
+
)
|
|
167
|
+
return _run_xurl(["post", text], timeout=45)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def reply(post_id_or_url: str, text: str, *, confirm: bool = False) -> XAccountResult:
|
|
171
|
+
text = (text or "").strip()
|
|
172
|
+
if not text:
|
|
173
|
+
return XAccountResult(False, "reply", "reply text is empty")
|
|
174
|
+
try:
|
|
175
|
+
post_id = normalize_post_id(post_id_or_url)
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
return XAccountResult(False, "reply", str(exc))
|
|
178
|
+
if not confirm:
|
|
179
|
+
draft = draft_reply(post_id, text)
|
|
180
|
+
return XAccountResult(
|
|
181
|
+
False,
|
|
182
|
+
"reply",
|
|
183
|
+
"Blocked write: pass confirm=True only after the user explicitly approves this exact reply.",
|
|
184
|
+
draft.data,
|
|
185
|
+
)
|
|
186
|
+
return _run_xurl(["reply", post_id, text], timeout=45)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def post_action(action: str, post_id_or_url: str, *, confirm: bool = False) -> XAccountResult:
|
|
190
|
+
action = (action or "").strip().lower()
|
|
191
|
+
if action not in CONFIRMED_POST_ACTIONS:
|
|
192
|
+
return XAccountResult(
|
|
193
|
+
False,
|
|
194
|
+
"post_action",
|
|
195
|
+
f"unsupported action: {action or '(empty)'}",
|
|
196
|
+
{"supported_actions": sorted(CONFIRMED_POST_ACTIONS)},
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
post_id = normalize_post_id(post_id_or_url)
|
|
200
|
+
except ValueError as exc:
|
|
201
|
+
return XAccountResult(False, action, str(exc))
|
|
202
|
+
if not confirm:
|
|
203
|
+
return XAccountResult(
|
|
204
|
+
False,
|
|
205
|
+
action,
|
|
206
|
+
f"Blocked write: pass confirm=True only after the user explicitly approves {action} for this post.",
|
|
207
|
+
{"post_id": post_id},
|
|
208
|
+
)
|
|
209
|
+
return _run_xurl([action, post_id], timeout=45)
|
algo_cli/xai_auth.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Optional xAI OAuth 2.0 + PKCE authentication.
|
|
2
|
+
|
|
3
|
+
Public client, Authorization Code + PKCE (S256), loopback redirect. Tokens
|
|
4
|
+
persist to CONFIG_DIR/xai_auth.json with POSIX 0600 permissions. This is the
|
|
5
|
+
subscription OAuth path.
|
|
6
|
+
|
|
7
|
+
API-key fallback is intentionally disabled elsewhere.
|
|
8
|
+
|
|
9
|
+
Algo CLI deliberately does not bundle an OAuth client identity. Users who
|
|
10
|
+
enable this optional provider must supply a client id they are authorized to
|
|
11
|
+
use through ``XAI_CLIENT_ID``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import http.server
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
import webbrowser
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .config import CONFIG_DIR, _atomic_write_text
|
|
29
|
+
|
|
30
|
+
XAI_CLIENT_ID_ENV = "XAI_CLIENT_ID"
|
|
31
|
+
XAI_AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
|
|
32
|
+
XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
|
33
|
+
XAI_REDIRECT_HOST = "127.0.0.1"
|
|
34
|
+
XAI_REDIRECT_PORT = 56121
|
|
35
|
+
# We'll try a range of ports if the default is in use
|
|
36
|
+
XAI_REDIRECT_PORT_RANGE = range(56121, 56151)
|
|
37
|
+
XAI_REDIRECT_URI = f"http://{XAI_REDIRECT_HOST}:{XAI_REDIRECT_PORT}/callback"
|
|
38
|
+
XAI_DEFAULT_SCOPE = "openid offline_access api:access"
|
|
39
|
+
XAI_API_BASE = "https://api.x.ai/v1"
|
|
40
|
+
|
|
41
|
+
AUTH_FILE = CONFIG_DIR / "xai_auth.json"
|
|
42
|
+
_REFRESH_WINDOW_SECONDS = 60
|
|
43
|
+
_CALLBACK_TIMEOUT_SECONDS = 300.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_client_id() -> str:
|
|
47
|
+
"""Return the user-supplied xAI OAuth client id, or an empty string."""
|
|
48
|
+
return os.environ.get(XAI_CLIENT_ID_ENV, "").strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def client_id_configured() -> bool:
|
|
52
|
+
"""Whether optional xAI OAuth has a user-provided client identity."""
|
|
53
|
+
return bool(resolve_client_id())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def require_client_id() -> str:
|
|
57
|
+
"""Resolve the xAI client id or fail before starting an OAuth flow."""
|
|
58
|
+
client_id = resolve_client_id()
|
|
59
|
+
if not client_id:
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
"xAI subscription OAuth is optional and is not configured. "
|
|
62
|
+
"Set XAI_CLIENT_ID to an OAuth client id you are authorized to use, "
|
|
63
|
+
"then retry /xai-login. Algo CLI does not bundle a client id."
|
|
64
|
+
)
|
|
65
|
+
return client_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def safe_error_message(error: BaseException | str) -> str:
|
|
69
|
+
"""Format an OAuth error without echoing the configured client identity."""
|
|
70
|
+
message = str(error)
|
|
71
|
+
client_id = resolve_client_id()
|
|
72
|
+
if client_id:
|
|
73
|
+
message = message.replace(client_id, "[redacted-client-id]")
|
|
74
|
+
encoded = urllib.parse.quote_plus(client_id)
|
|
75
|
+
if encoded != client_id:
|
|
76
|
+
message = message.replace(encoded, "[redacted-client-id]")
|
|
77
|
+
return message
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _b64url(data: bytes) -> str:
|
|
81
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
85
|
+
"""Return (code_verifier, code_challenge) per RFC 7636 S256."""
|
|
86
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
87
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
88
|
+
return verifier, challenge
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redirect_uri_for_port(port: int = XAI_REDIRECT_PORT) -> str:
|
|
92
|
+
return f"http://{XAI_REDIRECT_HOST}:{port}/callback"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_authorize_url(
|
|
96
|
+
*,
|
|
97
|
+
state: str,
|
|
98
|
+
code_challenge: str,
|
|
99
|
+
scope: str = XAI_DEFAULT_SCOPE,
|
|
100
|
+
redirect_uri: str | None = None,
|
|
101
|
+
) -> str:
|
|
102
|
+
client_id = require_client_id()
|
|
103
|
+
params = {
|
|
104
|
+
"response_type": "code",
|
|
105
|
+
"client_id": client_id,
|
|
106
|
+
"redirect_uri": redirect_uri or XAI_REDIRECT_URI,
|
|
107
|
+
"scope": scope,
|
|
108
|
+
"state": state,
|
|
109
|
+
"code_challenge": code_challenge,
|
|
110
|
+
"code_challenge_method": "S256",
|
|
111
|
+
}
|
|
112
|
+
return f"{XAI_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
116
|
+
received: dict[str, str] = {}
|
|
117
|
+
|
|
118
|
+
def do_GET(self) -> None: # noqa: N802
|
|
119
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
120
|
+
if parsed.path != "/callback":
|
|
121
|
+
self.send_response(404)
|
|
122
|
+
self.end_headers()
|
|
123
|
+
return
|
|
124
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
125
|
+
type(self).received = {k: v[0] for k, v in qs.items() if v}
|
|
126
|
+
self.send_response(200)
|
|
127
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
128
|
+
self.end_headers()
|
|
129
|
+
ok = "code" in type(self).received and "error" not in type(self).received
|
|
130
|
+
body = (
|
|
131
|
+
"<html><body style='font-family:sans-serif;padding:2em'>"
|
|
132
|
+
"<h2>xAI login complete.</h2>"
|
|
133
|
+
"<p>You can close this tab and return to algo-cli.</p></body></html>"
|
|
134
|
+
if ok
|
|
135
|
+
else "<html><body style='font-family:sans-serif;padding:2em'>"
|
|
136
|
+
"<h2>xAI login failed.</h2>"
|
|
137
|
+
"<p>Return to algo-cli for details.</p></body></html>"
|
|
138
|
+
)
|
|
139
|
+
self.wfile.write(body.encode("utf-8"))
|
|
140
|
+
|
|
141
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_loopback_capture(
|
|
146
|
+
*,
|
|
147
|
+
timeout: float = _CALLBACK_TIMEOUT_SECONDS,
|
|
148
|
+
redirect_port: int = XAI_REDIRECT_PORT,
|
|
149
|
+
) -> dict[str, str]:
|
|
150
|
+
"""Run a single-shot HTTP listener on the selected loopback redirect port.
|
|
151
|
+
|
|
152
|
+
Returns the parsed query string from the first /callback request, or an
|
|
153
|
+
empty dict on timeout/bind failure. Port selection happens before the auth
|
|
154
|
+
URL is built so the browser redirect URI and token exchange stay aligned.
|
|
155
|
+
"""
|
|
156
|
+
received: dict[str, str] = {}
|
|
157
|
+
Handler = type("_LoopbackCallbackHandler", (_CallbackHandler,), {"received": received})
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
server = http.server.HTTPServer((XAI_REDIRECT_HOST, redirect_port), Handler)
|
|
161
|
+
except OSError:
|
|
162
|
+
return {}
|
|
163
|
+
server.timeout = 1.0
|
|
164
|
+
deadline = time.time() + timeout
|
|
165
|
+
try:
|
|
166
|
+
while not Handler.received and time.time() < deadline:
|
|
167
|
+
server.handle_request()
|
|
168
|
+
finally:
|
|
169
|
+
server.server_close()
|
|
170
|
+
return dict(Handler.received)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _post_token_endpoint(form: dict[str, str]) -> dict[str, Any]:
|
|
174
|
+
data = urllib.parse.urlencode(form).encode("ascii")
|
|
175
|
+
req = urllib.request.Request(
|
|
176
|
+
XAI_TOKEN_URL,
|
|
177
|
+
data=data,
|
|
178
|
+
headers={
|
|
179
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
180
|
+
"Accept": "application/json",
|
|
181
|
+
},
|
|
182
|
+
method="POST",
|
|
183
|
+
)
|
|
184
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
185
|
+
body = resp.read().decode("utf-8")
|
|
186
|
+
return json.loads(body)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def exchange_code(code: str, code_verifier: str, *, redirect_uri: str | None = None) -> dict[str, Any]:
|
|
190
|
+
client_id = require_client_id()
|
|
191
|
+
payload = _post_token_endpoint(
|
|
192
|
+
{
|
|
193
|
+
"grant_type": "authorization_code",
|
|
194
|
+
"client_id": client_id,
|
|
195
|
+
"code": code,
|
|
196
|
+
"redirect_uri": redirect_uri or XAI_REDIRECT_URI,
|
|
197
|
+
"code_verifier": code_verifier,
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
return _normalize_token_response(payload)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def refresh_access_token(refresh_token: str, *, scope: str = XAI_DEFAULT_SCOPE) -> dict[str, Any]:
|
|
204
|
+
client_id = require_client_id()
|
|
205
|
+
payload = _post_token_endpoint(
|
|
206
|
+
{
|
|
207
|
+
"grant_type": "refresh_token",
|
|
208
|
+
"client_id": client_id,
|
|
209
|
+
"refresh_token": refresh_token,
|
|
210
|
+
"scope": scope,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
return _normalize_token_response(payload, fallback_refresh=refresh_token)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _normalize_token_response(
|
|
217
|
+
payload: dict[str, Any], *, fallback_refresh: str | None = None
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
if "access_token" not in payload:
|
|
220
|
+
# Token responses may include provider diagnostics or credentials. Do
|
|
221
|
+
# not reflect the payload into terminal output or logs.
|
|
222
|
+
raise RuntimeError("xAI OAuth token response did not include an access token.")
|
|
223
|
+
now = int(time.time())
|
|
224
|
+
try:
|
|
225
|
+
expires_in = int(payload.get("expires_in", 3600))
|
|
226
|
+
except (TypeError, ValueError):
|
|
227
|
+
expires_in = 3600
|
|
228
|
+
return {
|
|
229
|
+
"access_token": str(payload["access_token"]),
|
|
230
|
+
"refresh_token": payload.get("refresh_token") or fallback_refresh,
|
|
231
|
+
"token_type": payload.get("token_type", "Bearer"),
|
|
232
|
+
"expires_at": now + expires_in,
|
|
233
|
+
"scope": payload.get("scope", ""),
|
|
234
|
+
"obtained_at": now,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def save_tokens(tokens: dict[str, Any]) -> None:
|
|
239
|
+
AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
_atomic_write_text(AUTH_FILE, json.dumps(tokens, indent=2))
|
|
241
|
+
try:
|
|
242
|
+
os.chmod(AUTH_FILE, 0o600)
|
|
243
|
+
except (OSError, NotImplementedError):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_tokens() -> dict[str, Any] | None:
|
|
248
|
+
if not AUTH_FILE.exists():
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
return json.loads(AUTH_FILE.read_text(encoding="utf-8"))
|
|
252
|
+
except (OSError, json.JSONDecodeError):
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def clear_tokens() -> bool:
|
|
257
|
+
if not AUTH_FILE.exists():
|
|
258
|
+
return False
|
|
259
|
+
try:
|
|
260
|
+
AUTH_FILE.unlink()
|
|
261
|
+
return True
|
|
262
|
+
except OSError:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_token_expired(tokens: dict[str, Any], *, window: int = _REFRESH_WINDOW_SECONDS) -> bool:
|
|
267
|
+
return int(tokens.get("expires_at", 0)) - window <= int(time.time())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_valid_token() -> str | None:
|
|
271
|
+
"""Return a fresh access token, refreshing silently if expiry is near.
|
|
272
|
+
|
|
273
|
+
Returns None when the optional client identity is not configured, when no
|
|
274
|
+
tokens are stored, when refresh is required but no refresh_token exists,
|
|
275
|
+
or when the refresh call fails.
|
|
276
|
+
"""
|
|
277
|
+
if not client_id_configured():
|
|
278
|
+
return None
|
|
279
|
+
tokens = load_tokens()
|
|
280
|
+
if not tokens:
|
|
281
|
+
return None
|
|
282
|
+
if not is_token_expired(tokens):
|
|
283
|
+
return tokens.get("access_token")
|
|
284
|
+
refresh = tokens.get("refresh_token")
|
|
285
|
+
if not refresh:
|
|
286
|
+
return None
|
|
287
|
+
try:
|
|
288
|
+
new_tokens = refresh_access_token(refresh)
|
|
289
|
+
except Exception:
|
|
290
|
+
return None
|
|
291
|
+
save_tokens(new_tokens)
|
|
292
|
+
return new_tokens.get("access_token")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def auth_status() -> dict[str, Any]:
|
|
296
|
+
tokens = load_tokens()
|
|
297
|
+
client_configured = client_id_configured()
|
|
298
|
+
if not tokens:
|
|
299
|
+
return {
|
|
300
|
+
"authenticated": False,
|
|
301
|
+
"client_configured": client_configured,
|
|
302
|
+
"token_present": False,
|
|
303
|
+
}
|
|
304
|
+
expires_at = int(tokens.get("expires_at", 0))
|
|
305
|
+
return {
|
|
306
|
+
"authenticated": client_configured,
|
|
307
|
+
"client_configured": client_configured,
|
|
308
|
+
"token_present": True,
|
|
309
|
+
"expires_at": expires_at,
|
|
310
|
+
"expires_in": max(0, expires_at - int(time.time())),
|
|
311
|
+
"scope": tokens.get("scope", ""),
|
|
312
|
+
"has_refresh_token": bool(tokens.get("refresh_token")),
|
|
313
|
+
"token_type": tokens.get("token_type", "Bearer"),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def port_is_free(port: int = XAI_REDIRECT_PORT) -> bool:
|
|
318
|
+
sock = socket.socket()
|
|
319
|
+
sock.settimeout(0.2)
|
|
320
|
+
try:
|
|
321
|
+
result = sock.connect_ex((XAI_REDIRECT_HOST, port))
|
|
322
|
+
return result != 0
|
|
323
|
+
except OSError:
|
|
324
|
+
return False
|
|
325
|
+
finally:
|
|
326
|
+
sock.close()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def select_redirect_port() -> int | None:
|
|
330
|
+
for port in XAI_REDIRECT_PORT_RANGE:
|
|
331
|
+
if port_is_free(port):
|
|
332
|
+
return port
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def begin_login(*, no_browser: bool = False, redirect_port: int = XAI_REDIRECT_PORT) -> dict[str, str]:
|
|
337
|
+
# Resolve before generating state, opening a browser, or starting a
|
|
338
|
+
# callback listener so an unconfigured install fails closed and cleanly.
|
|
339
|
+
require_client_id()
|
|
340
|
+
state = secrets.token_urlsafe(32)
|
|
341
|
+
verifier, challenge = generate_pkce_pair()
|
|
342
|
+
redirect_uri = redirect_uri_for_port(redirect_port)
|
|
343
|
+
|
|
344
|
+
url = build_authorize_url(state=state, code_challenge=challenge, redirect_uri=redirect_uri)
|
|
345
|
+
if not no_browser:
|
|
346
|
+
opened = webbrowser.open(url)
|
|
347
|
+
if not opened:
|
|
348
|
+
no_browser = True
|
|
349
|
+
ssh_tunnel_cmd = f"ssh -N -L {redirect_port}:{XAI_REDIRECT_HOST}:{redirect_port} you@remote-host"
|
|
350
|
+
return {
|
|
351
|
+
"state": state,
|
|
352
|
+
"code_verifier": verifier,
|
|
353
|
+
"auth_url": url,
|
|
354
|
+
"redirect_uri": redirect_uri,
|
|
355
|
+
"redirect_port": str(redirect_port),
|
|
356
|
+
"ssh_tunnel_cmd": ssh_tunnel_cmd,
|
|
357
|
+
"browser_opened": not no_browser,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def complete_login(code_verifier: str, state: str, callback: dict[str, str] | None) -> dict[str, Any]:
|
|
362
|
+
if not callback:
|
|
363
|
+
raise RuntimeError("Timed out waiting for OAuth callback.")
|
|
364
|
+
if "error" in callback:
|
|
365
|
+
detail = callback.get("error_description", callback["error"])
|
|
366
|
+
raise RuntimeError(f"OAuth error: {safe_error_message(detail)}")
|
|
367
|
+
if callback.get("state") != state:
|
|
368
|
+
raise RuntimeError("OAuth state mismatch - possible CSRF attack.")
|
|
369
|
+
code = callback.get("code")
|
|
370
|
+
if not code:
|
|
371
|
+
raise RuntimeError("No authorization code in callback.")
|
|
372
|
+
tokens = exchange_code(code, code_verifier, redirect_uri=callback.get("redirect_uri"))
|
|
373
|
+
save_tokens(tokens)
|
|
374
|
+
return tokens
|