hyperagents 1.2.3rc5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,442 @@
1
+ """Slack sign-in for a team install: OAuth v2 with PKCE, user token: reads, and posts only on the owner's Send.
2
+
3
+ - The Slack app (``app_id``, ``client_id``) comes from the Hyper-signed team
4
+ profile; no client secret is used (PKCE, S256).
5
+ - The redirect is ``http://localhost:<port><path>`` (team profile; port 8766
6
+ by default), caught by a one-shot loopback listener. The person signs in in
7
+ their own browser.
8
+ - Exactly the app's user scopes are requested (``SLACK_USER_SCOPES``: read
9
+ mentions, DMs and channels, plus ``chat:write`` to post on tap; every post
10
+ still waits for the owner's Send). A grant that differs in any way is
11
+ refused and nothing is stored.
12
+ - The token bundle (access token, refresh token, expiries) is stored in the
13
+ OS secret store (hyperclaw.teamsetup.secrets, name ``slack_user_token``).
14
+ - **Refresh** (the app: PKCE on, token rotation off; the main service checks
15
+ hourly, docs/TEAM-ONBOARDING.md):
16
+ - a grant with no ``expires_in`` and no refresh token is a long-lived user
17
+ token: nothing to refresh; a new sign-in is needed only if it is revoked;
18
+ - a grant with a refresh token: the access token is refreshed 10 minutes
19
+ before it expires. Slack's PKCE refresh tokens expire 30 days after they
20
+ are issued. A refresh that returns a new refresh token restarts the 30
21
+ days; one that doesn't leaves the original date. Three days before that
22
+ date the owner is told once on Telegram (``SIGN_IN_SOON``); a refused
23
+ refresh is told once (``SIGN_IN_AGAIN``).
24
+ - A connection is proven with one authenticated call, ``auth.test``.
25
+ - **One workspace only** (fail-closed). The signed team profile names the
26
+ Slack workspace (``slack_team_id``; and ``slack_enterprise_id`` only for an
27
+ Enterprise Grid org). After the OAuth exchange, on every refresh and on
28
+ every connection check, the token's team id (and its enterprise id, when
29
+ Slack reports one) must match exactly. On a mismatch the token is
30
+ discarded (revoked with Slack, best effort, and nothing is stored or the
31
+ stored bundle is removed) and the person is told plainly
32
+ (``TEXT["wrong_workspace"]``). A profile with no ``slack_team_id`` refuses
33
+ Slack sign-in, and stored tokens are not handed out, with the fix shown.
34
+
35
+ Nothing here prints, logs or raises with a token, a code or a verifier.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import base64
40
+ import hashlib
41
+ import json
42
+ import logging
43
+ import os
44
+ import secrets as _secrets
45
+ import time
46
+ from dataclasses import dataclass
47
+ from typing import Callable, Optional
48
+ from urllib.parse import urlencode, urlsplit
49
+
50
+ import httpx
51
+
52
+ from hyperclaw.teamsetup import secrets
53
+ from hyperclaw.teamsetup.item import SLACK_USER_SCOPES
54
+ from hyperclaw.teamsetup.oauth_loopback import Catcher, PortBusy
55
+
56
+ log = logging.getLogger("hyperclaw.teamsetup.slack")
57
+
58
+ AUTH_URL = "https://slack.com/oauth/v2/authorize"
59
+ API = "https://slack.com/api"
60
+ API_ENV = "HYPERCLAW_TEAM_SLACK_API" # tests only: must be loopback
61
+ SECRET = "slack_user_token"
62
+ TIMEOUT = 20.0
63
+ CONSENT_WAIT = 300.0
64
+ REFRESH_LIMIT = 30 * 86400 # a PKCE refresh token's life from when it is issued
65
+ WARN_BEFORE = 3 * 86400 # tell the owner this long before it runs out
66
+ EARLY = 600 # refresh an access token 10 min before expiry
67
+
68
+ SIGN_IN_AGAIN = ("Slack needs you to sign in again (your Slack sign-in has expired or was "
69
+ "removed). Run: hyperclaw onboard --team --step slack")
70
+ SIGN_IN_SOON = ("Slack will sign HyperAgents out on {date}. To stay connected, sign in again "
71
+ "before then: hyperclaw onboard --team --step slack")
72
+
73
+ TEXT = {
74
+ "no_app": "the team profile has no Slack app, so Slack sign-in isn't set up for you",
75
+ "port_busy": ("another program on this computer is using port {port}, which Slack sign-in "
76
+ "needs. Close it and try again"),
77
+ "denied": "Slack sign-in was cancelled or didn't finish",
78
+ "timeout": "Slack sign-in didn't finish within 5 minutes",
79
+ "scopes": ("Slack granted different access than HyperAgents asks for, so nothing was "
80
+ "saved"),
81
+ "rejected": "Slack didn't accept the sign-in",
82
+ "unreachable": "Slack couldn't be reached",
83
+ "expired": "your Slack sign-in has expired or was removed",
84
+ "not_connected": "Slack isn't connected",
85
+ "wrong_workspace": ("This Slack sign-in is for a different workspace than the "
86
+ "workspace your team profile names, so it was discarded and nothing was "
87
+ "saved. Sign in again and choose your team's workspace"),
88
+ "no_workspace": ("your team profile doesn't name the Slack workspace (slack_team_id), so "
89
+ "Slack sign-in is refused"),
90
+ }
91
+
92
+ RERUN = "hyperclaw onboard --team --step slack"
93
+ FIX = {
94
+ "no_workspace": ("ask Vaughn for a new 1Password item whose signed team profile includes "
95
+ "slack_team_id, then run: hyperclaw onboard --team --step item"),
96
+ }
97
+ WRONG_WORKSPACE = TEXT["wrong_workspace"] + ": " + RERUN
98
+
99
+
100
+ class SlackAuthError(Exception):
101
+ def __init__(self, code: str, **fmt):
102
+ self.code = code
103
+ super().__init__(TEXT[code].format(**fmt))
104
+
105
+ @property
106
+ def text(self) -> str:
107
+ return str(self)
108
+
109
+ @property
110
+ def fix(self) -> str:
111
+ return FIX.get(self.code, RERUN)
112
+
113
+
114
+ def api_base() -> str:
115
+ override = os.environ.get(API_ENV, "").strip()
116
+ if override and (urlsplit(override).hostname or "") in ("127.0.0.1", "localhost", "::1"):
117
+ return override.rstrip("/")
118
+ return API
119
+
120
+
121
+ def _pkce() -> tuple:
122
+ verifier = base64.urlsafe_b64encode(_secrets.token_bytes(48)).rstrip(b"=").decode()
123
+ challenge = base64.urlsafe_b64encode(
124
+ hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
125
+ return verifier, challenge
126
+
127
+
128
+ def redirect_uri(cfg: dict) -> str:
129
+ return f"http://localhost:{int(cfg.get('redirect_port') or 8766)}" \
130
+ f"{cfg.get('redirect_path') or '/slack/callback'}"
131
+
132
+
133
+ def authorize_url(cfg: dict, state: str, challenge: str) -> str:
134
+ return AUTH_URL + "?" + urlencode({
135
+ "client_id": cfg["client_id"],
136
+ "user_scope": ",".join(SLACK_USER_SCOPES),
137
+ "redirect_uri": redirect_uri(cfg),
138
+ "state": state,
139
+ "code_challenge": challenge,
140
+ "code_challenge_method": "S256",
141
+ })
142
+
143
+
144
+ # ── token bundle ──────────────────────────────────────────────────────────
145
+
146
+ def load_bundle() -> dict:
147
+ raw = secrets.get(SECRET)
148
+ try:
149
+ data = json.loads(raw) if raw else {}
150
+ except ValueError:
151
+ data = {}
152
+ return data if isinstance(data, dict) else {}
153
+
154
+
155
+ def save_bundle(b: dict) -> str:
156
+ return secrets.put(SECRET, json.dumps(b, separators=(",", ":")))
157
+
158
+
159
+ def connected() -> bool:
160
+ return bool(load_bundle().get("access_token"))
161
+
162
+
163
+ def _bundle_from(resp: dict, now: float, old: Optional[dict] = None) -> dict:
164
+ """The stored bundle from an oauth.v2.access answer (first grant or a
165
+ refresh; Slack nests user tokens under authed_user on the first grant)."""
166
+ u = resp.get("authed_user") if isinstance(resp.get("authed_user"), dict) else {}
167
+ src = u if u.get("access_token") else resp
168
+ old = old or {}
169
+ exp = src.get("expires_in")
170
+ team = resp.get("team") if isinstance(resp.get("team"), dict) else {}
171
+ ent = resp.get("enterprise") if isinstance(resp.get("enterprise"), dict) else {}
172
+ return {
173
+ "access_token": str(src.get("access_token") or ""),
174
+ "refresh_token": str(src.get("refresh_token") or old.get("refresh_token") or ""),
175
+ # a new refresh token restarts its 30 days; otherwise the old date stands
176
+ "refresh_issued_at": now if src.get("refresh_token") else
177
+ float(old.get("refresh_issued_at") or old.get("refreshed_at") or now),
178
+ "expires_at": (now + float(exp)) if exp else 0,
179
+ "refreshed_at": now,
180
+ "scope": str(src.get("scope") or old.get("scope") or ""),
181
+ "team_id": str(team.get("id") or old.get("team_id") or ""),
182
+ "team_name": str(team.get("name") or old.get("team_name") or ""),
183
+ "enterprise_id": str(ent.get("id") or old.get("enterprise_id") or ""),
184
+ "user_id": str(u.get("id") or src.get("user_id") or old.get("user_id") or ""),
185
+ }
186
+
187
+
188
+ def _post(path: str, data: dict, http: Optional[httpx.Client], headers=None) -> dict:
189
+ c = http or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
190
+ try:
191
+ r = c.post(f"{api_base()}/{path}", data=data, headers=headers or {})
192
+ except httpx.HTTPError as e:
193
+ log.warning("slack %s: network error (%s)", path, type(e).__name__)
194
+ raise SlackAuthError("unreachable") from None
195
+ finally:
196
+ if http is None:
197
+ c.close()
198
+ if r.status_code >= 500 or r.status_code == 429:
199
+ raise SlackAuthError("unreachable")
200
+ try:
201
+ body = r.json()
202
+ except ValueError:
203
+ body = {}
204
+ return body if isinstance(body, dict) else {}
205
+
206
+
207
+ _EXPIRED = {"invalid_refresh_token", "invalid_grant", "token_revoked", "token_expired",
208
+ "invalid_auth", "not_authed", "account_inactive"}
209
+
210
+
211
+ def _check_scopes(scope: str) -> None:
212
+ got = sorted({s.strip() for s in (scope or "").split(",") if s.strip()})
213
+ if got != sorted(SLACK_USER_SCOPES):
214
+ raise SlackAuthError("scopes")
215
+
216
+
217
+ # ── the one workspace ─────────────────────────────────────────────────────
218
+
219
+ def expected_workspace(cfg: dict) -> tuple:
220
+ """(team_id, enterprise_id) the profile allows. Raises
221
+ SlackAuthError('no_workspace') when the profile names no workspace."""
222
+ team = str(cfg.get("team_id") or "").strip()
223
+ if not team:
224
+ raise SlackAuthError("no_workspace")
225
+ return team, str(cfg.get("enterprise_id") or "").strip()
226
+
227
+
228
+ def workspace_matches(cfg: dict, team_id: str, enterprise_id: str = "") -> bool:
229
+ """Exact match on the team id; an enterprise id, when either side has
230
+ one, must match too (a Grid org the profile doesn't name is refused)."""
231
+ want_team, want_ent = expected_workspace(cfg)
232
+ return bool(team_id) and team_id == want_team and (enterprise_id or "") == want_ent
233
+
234
+
235
+ def _revoke(token: str, http: Optional[httpx.Client]) -> None:
236
+ """Best effort: tell Slack to drop a token we won't keep."""
237
+ if not token:
238
+ return
239
+ try:
240
+ _post("auth.revoke", {}, http, headers={"Authorization": f"Bearer {token}"})
241
+ except SlackAuthError:
242
+ pass
243
+
244
+
245
+ def _discard(token: str, http: Optional[httpx.Client], *, stored: bool) -> None:
246
+ _revoke(token, http)
247
+ if stored and not secrets.remove(SECRET):
248
+ save_bundle({}) # the store wouldn't delete: overwrite it empty
249
+ log.warning("slack: sign-in for a different workspace discarded")
250
+
251
+
252
+ def _auth_test(token: str, http: Optional[httpx.Client]) -> dict:
253
+ body = _post("auth.test", {}, http, headers={"Authorization": f"Bearer {token}"})
254
+ if not body.get("ok"):
255
+ if str(body.get("error") or "") in _EXPIRED:
256
+ raise SlackAuthError("expired")
257
+ raise SlackAuthError("rejected")
258
+ return body
259
+
260
+
261
+ # ── sign-in ───────────────────────────────────────────────────────────────
262
+
263
+ def sign_in(cfg: dict, *, show: Callable[[str], None] = print,
264
+ open_browser: Optional[Callable[[str], object]] = None,
265
+ http: Optional[httpx.Client] = None, wait: float = CONSENT_WAIT,
266
+ on_listening: Optional[Callable[[str, str], None]] = None,
267
+ now: Callable[[], float] = time.time) -> dict:
268
+ """Run the PKCE sign-in and store the token bundle. Returns the
269
+ non-secret {"team_name", "user_id", "scope", "store"}."""
270
+ if not cfg.get("client_id"):
271
+ raise SlackAuthError("no_app")
272
+ expected_workspace(cfg) # no named workspace: refuse before any sign-in
273
+ port = int(cfg.get("redirect_port") or 8766)
274
+ verifier, challenge = _pkce()
275
+ state = _secrets.token_urlsafe(24)
276
+ try:
277
+ catcher = Catcher(port, cfg.get("redirect_path") or "/slack/callback")
278
+ except PortBusy:
279
+ raise SlackAuthError("port_busy", port=port) from None
280
+ catcher.start()
281
+ try:
282
+ url = authorize_url(cfg, state, challenge)
283
+ show("Your browser will open Slack. Sign in to the Hyper workspace and choose Allow. "
284
+ "HyperAgents asks to read your messages and channels, and to post only when "
285
+ "you press Send. If the browser didn't open, copy this address "
286
+ "into it:\n\n" + url + "\n")
287
+ if open_browser:
288
+ try:
289
+ open_browser(url)
290
+ except Exception:
291
+ pass
292
+ if on_listening:
293
+ on_listening(redirect_uri(cfg), state)
294
+ if not catcher.wait(wait):
295
+ raise SlackAuthError("timeout")
296
+ res = catcher.result
297
+ finally:
298
+ catcher.close()
299
+ if res.get("state") != state or res.get("error") or not res.get("code"):
300
+ raise SlackAuthError("denied")
301
+ body = _post("oauth.v2.access", {
302
+ "client_id": cfg["client_id"], "code": res["code"], "code_verifier": verifier,
303
+ "redirect_uri": redirect_uri(cfg), "grant_type": "authorization_code"}, http)
304
+ if not body.get("ok"):
305
+ raise SlackAuthError("rejected")
306
+ bundle = _bundle_from(body, now())
307
+ if not bundle["access_token"]:
308
+ raise SlackAuthError("rejected")
309
+ if not workspace_matches(cfg, bundle["team_id"], bundle["enterprise_id"]):
310
+ _discard(bundle["access_token"], http, stored=False)
311
+ raise SlackAuthError("wrong_workspace")
312
+ _check_scopes(bundle["scope"])
313
+ where = save_bundle(bundle)
314
+ return {"team_name": bundle["team_name"], "user_id": bundle["user_id"],
315
+ "scope": bundle["scope"], "store": where}
316
+
317
+
318
+ # ── refresh ───────────────────────────────────────────────────────────────
319
+
320
+ @dataclass
321
+ class RefreshResult:
322
+ status: str # "fresh" | "refreshed" | "sign_in_again" | "retry_later" |
323
+ # "not_connected" | "wrong_workspace" | "no_workspace"
324
+ message: str = ""
325
+
326
+
327
+ def needs_refresh(b: dict, now: float) -> bool:
328
+ """Only an access token about to expire is refreshed (a long-lived token
329
+ without a refresh token never is)."""
330
+ if not b.get("refresh_token"):
331
+ return False
332
+ exp = float(b.get("expires_at") or 0)
333
+ return bool(exp) and exp - EARLY <= now
334
+
335
+
336
+ def sign_in_by(b: dict) -> float:
337
+ """When a new sign-in is needed (the refresh token's 30 days), or 0 when
338
+ the token doesn't expire."""
339
+ if not b.get("refresh_token"):
340
+ return 0.0
341
+ return float(b.get("refresh_issued_at") or b.get("refreshed_at") or 0) + REFRESH_LIMIT
342
+
343
+
344
+ def expiry_warning(b: dict, now: float) -> str:
345
+ """SIGN_IN_SOON when the sign-in runs out within WARN_BEFORE, else ''."""
346
+ by = sign_in_by(b)
347
+ if not by or by - now > WARN_BEFORE:
348
+ return ""
349
+ import datetime as _dt
350
+ date = _dt.datetime.fromtimestamp(by).strftime("%a %d %b")
351
+ return SIGN_IN_SOON.format(date=date) if by > now else SIGN_IN_AGAIN
352
+
353
+
354
+ def refresh(cfg: dict, *, http: Optional[httpx.Client] = None, force: bool = False,
355
+ now: Callable[[], float] = time.time) -> RefreshResult:
356
+ b = load_bundle()
357
+ if not b.get("access_token"):
358
+ return RefreshResult("not_connected", TEXT["not_connected"])
359
+ try:
360
+ expected_workspace(cfg)
361
+ except SlackAuthError as e:
362
+ return RefreshResult("no_workspace", f"Slack is off: {e.text}. Fix: {e.fix}")
363
+ if b.get("team_id") and not workspace_matches(cfg, b["team_id"], b.get("enterprise_id", "")):
364
+ _discard(b["access_token"], http, stored=True)
365
+ return RefreshResult("wrong_workspace", WRONG_WORKSPACE)
366
+ t = now()
367
+ if not force and not needs_refresh(b, t):
368
+ return RefreshResult("fresh", expiry_warning(b, t))
369
+ if not b.get("refresh_token"):
370
+ return RefreshResult("fresh")
371
+ try:
372
+ body = _post("oauth.v2.access", {"client_id": cfg.get("client_id") or "",
373
+ "grant_type": "refresh_token",
374
+ "refresh_token": b["refresh_token"]}, http)
375
+ except SlackAuthError:
376
+ return RefreshResult("retry_later", expiry_warning(b, t))
377
+ if not body.get("ok"):
378
+ if str(body.get("error") or "") in _EXPIRED:
379
+ return RefreshResult("sign_in_again", SIGN_IN_AGAIN)
380
+ return RefreshResult("retry_later")
381
+ nb = _bundle_from(body, t, old=b)
382
+ if not nb["access_token"]:
383
+ return RefreshResult("retry_later")
384
+ team = body.get("team") if isinstance(body.get("team"), dict) else {}
385
+ ent = body.get("enterprise") if isinstance(body.get("enterprise"), dict) else {}
386
+ if team.get("id"):
387
+ got = (str(team["id"]), str(ent.get("id") or ""))
388
+ else: # Slack didn't say: ask with the new token
389
+ try:
390
+ who = _auth_test(nb["access_token"], http)
391
+ except SlackAuthError:
392
+ return RefreshResult("retry_later")
393
+ got = (str(who.get("team_id") or ""), str(who.get("enterprise_id") or ""))
394
+ if not workspace_matches(cfg, *got):
395
+ _revoke(nb["access_token"], http)
396
+ _discard(b["access_token"], http, stored=True)
397
+ return RefreshResult("wrong_workspace", WRONG_WORKSPACE)
398
+ nb["team_id"], nb["enterprise_id"] = got
399
+ save_bundle(nb)
400
+ return RefreshResult("refreshed", expiry_warning(nb, t))
401
+
402
+
403
+ def _current(cfg: dict, http: Optional[httpx.Client]) -> dict:
404
+ r = refresh(cfg, http=http)
405
+ if r.status in ("not_connected", "no_workspace", "wrong_workspace"):
406
+ raise SlackAuthError(r.status)
407
+ if r.status == "sign_in_again":
408
+ raise SlackAuthError("expired")
409
+ return load_bundle()
410
+
411
+
412
+ def access_token(cfg: dict, *, http: Optional[httpx.Client] = None) -> str:
413
+ """A current user token for other Slack code (refreshed when due), only
414
+ for the profile's workspace. Raises SlackAuthError('expired' /
415
+ 'not_connected' / 'no_workspace' / 'wrong_workspace')."""
416
+ b = _current(cfg, http)
417
+ if not b.get("team_id"): # never confirmed: confirm before handing it out
418
+ _confirm(cfg, b, http)
419
+ return load_bundle().get("access_token", "")
420
+
421
+
422
+ def _confirm(cfg: dict, b: dict, http: Optional[httpx.Client]) -> dict:
423
+ """auth.test with the stored token; the workspace must be the profile's.
424
+ A mismatch discards the stored token and raises 'wrong_workspace'."""
425
+ body = _auth_test(b["access_token"], http)
426
+ team, ent = str(body.get("team_id") or ""), str(body.get("enterprise_id") or "")
427
+ if not workspace_matches(cfg, team, ent):
428
+ _discard(b["access_token"], http, stored=True)
429
+ raise SlackAuthError("wrong_workspace")
430
+ if b.get("team_id") != team or b.get("enterprise_id", "") != ent:
431
+ save_bundle(dict(b, team_id=team, enterprise_id=ent))
432
+ return body
433
+
434
+
435
+ # ── verify ────────────────────────────────────────────────────────────────
436
+
437
+ def verify(cfg: dict, *, http: Optional[httpx.Client] = None) -> dict:
438
+ """One authenticated call (auth.test), which must answer for the
439
+ profile's workspace (else the token is discarded: 'wrong_workspace').
440
+ Returns {"team", "user_id"}."""
441
+ body = _confirm(cfg, _current(cfg, http), http)
442
+ return {"team": str(body.get("team") or ""), "user_id": str(body.get("user_id") or "")}
@@ -0,0 +1,62 @@
1
+ """Hidden input that takes a long paste.
2
+
3
+ ``getpass`` reads in the terminal's line mode, which on macOS cuts a line at
4
+ 1024 characters, shorter than some 1Password items. This reader turns echo
5
+ and line mode off and reads characters itself until Enter, so nothing shows
6
+ on screen and nothing is cut. Backspace works; Ctrl-C raises
7
+ KeyboardInterrupt. Without a terminal it falls back to ``getpass``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import os
13
+ import sys
14
+
15
+ MAX_CHARS = 16384
16
+
17
+
18
+ def read_hidden(prompt: str) -> str:
19
+ try:
20
+ import termios
21
+ fd = sys.stdin.fileno()
22
+ if not os.isatty(fd):
23
+ raise OSError
24
+ old = termios.tcgetattr(fd)
25
+ except (ImportError, OSError, ValueError, AttributeError):
26
+ return getpass.getpass(prompt)
27
+ sys.stdout.write(prompt)
28
+ sys.stdout.flush()
29
+ new = termios.tcgetattr(fd)
30
+ new[3] &= ~(termios.ECHO | termios.ICANON) # lflags: no echo, no line mode
31
+ new[6][termios.VMIN] = 1
32
+ new[6][termios.VTIME] = 0
33
+ chars = []
34
+ try:
35
+ termios.tcsetattr(fd, termios.TCSAFLUSH, new)
36
+ while True:
37
+ b = os.read(fd, 4096)
38
+ if not b:
39
+ break
40
+ done = False
41
+ for ch in b.decode("utf-8", errors="ignore"):
42
+ if ch in ("\r", "\n"):
43
+ done = True
44
+ break
45
+ if ch == "\x03":
46
+ raise KeyboardInterrupt
47
+ if ch == "\x04" and not chars:
48
+ done = True
49
+ break
50
+ if ch in ("\x7f", "\x08"):
51
+ if chars:
52
+ chars.pop()
53
+ continue
54
+ if len(chars) < MAX_CHARS:
55
+ chars.append(ch)
56
+ if done:
57
+ break
58
+ finally:
59
+ termios.tcsetattr(fd, termios.TCSAFLUSH, old)
60
+ sys.stdout.write("\n")
61
+ sys.stdout.flush()
62
+ return "".join(chars)