secondshift 0.1.0b12__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 (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,161 @@
1
+ """Call one approved operation. The model supplies values, never the request shape."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+ from urllib.parse import quote, urlencode
8
+
9
+ import anyio
10
+ from django.conf import settings
11
+
12
+ from secondshift.connections.http_api.spec import (
13
+ BODY,
14
+ APIArgumentError,
15
+ APIError,
16
+ APIRequestError,
17
+ )
18
+ from secondshift.connections.models import Connection
19
+ from secondshift.connections.outbound import RESPONSE_MAX_BYTES, outbound_client
20
+
21
+ BEARER = "bearer"
22
+ HEADER = "header"
23
+ AUTH_TYPES = (BEARER, HEADER)
24
+ HEADER_NAME_PATTERN = r"[A-Za-z0-9-]{1,64}"
25
+ # Headers an owner may not use for the credential: they change how the request
26
+ # is routed or framed.
27
+ RESERVED_HEADERS = {
28
+ "host",
29
+ "content-length",
30
+ "content-type",
31
+ "transfer-encoding",
32
+ "connection",
33
+ "cookie",
34
+ "accept",
35
+ "accept-encoding",
36
+ }
37
+ SCALAR_TYPES = (str, int, float, bool)
38
+ TEXT_MAX_CHARS = 64 * 1024
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class APIRequest:
43
+ method: str
44
+ url: str
45
+ body: Any = None
46
+
47
+
48
+ def auth_header(settings_: dict[str, str], token: str) -> dict[str, str]:
49
+ if settings_.get("auth") == HEADER:
50
+ name = settings_.get("header", "")
51
+ if (
52
+ not re.fullmatch(HEADER_NAME_PATTERN, name)
53
+ or name.lower() in RESERVED_HEADERS
54
+ ):
55
+ raise APIError("The credential header name is not allowed.")
56
+ return {name: token}
57
+ return {"Authorization": f"Bearer {token}"}
58
+
59
+
60
+ def path_value(value: Any) -> str:
61
+ """One path segment. Slashes, dots-only segments, and URLs are refused."""
62
+ if isinstance(value, bool) or not isinstance(value, SCALAR_TYPES):
63
+ raise APIArgumentError("Path values must be text or numbers.")
64
+ text = str(value)
65
+ if not text or text in {".", ".."} or "/" in text or "\\" in text or "://" in text:
66
+ raise APIArgumentError("A path value is not allowed.")
67
+ return quote(text, safe="")
68
+
69
+
70
+ def query_values(value: Any) -> list[str]:
71
+ values = value if isinstance(value, list) else [value]
72
+ if any(item is None or not isinstance(item, SCALAR_TYPES) for item in values):
73
+ raise APIArgumentError("Query values must be text, numbers, or lists of them.")
74
+ return [
75
+ str(item).lower() if isinstance(item, bool) else str(item) for item in values
76
+ ]
77
+
78
+
79
+ def build_request(
80
+ base_url: str, operation: dict[str, Any], arguments: dict[str, Any]
81
+ ) -> APIRequest:
82
+ declared = {item["name"]: item for item in operation["parameters"]}
83
+ unknown = set(arguments) - set(declared) - ({BODY} if operation["body"] else set())
84
+ if unknown:
85
+ raise APIArgumentError("The call has arguments the operation does not declare.")
86
+ path = operation["path"]
87
+ for name, item in declared.items():
88
+ if item["in"] != "path":
89
+ continue
90
+ if name not in arguments:
91
+ raise APIArgumentError("A required path value is missing.")
92
+ path = path.replace("{" + name + "}", path_value(arguments[name]))
93
+ query: list[tuple[str, str]] = []
94
+ for name, item in declared.items():
95
+ if item["in"] == "query" and arguments.get(name) is not None:
96
+ query += [(name, value) for value in query_values(arguments[name])]
97
+ url = base_url.rstrip("/") + path
98
+ if query:
99
+ url += "?" + urlencode(query)
100
+ return APIRequest(
101
+ method=operation["method"].upper(),
102
+ url=url,
103
+ body=arguments.get(BODY) if operation["body"] else None,
104
+ )
105
+
106
+
107
+ def validate_api_connection(connection: Connection) -> None:
108
+ if (
109
+ connection.kind != Connection.Kind.API
110
+ or connection.provider != Connection.Provider.API
111
+ or connection.status != Connection.Status.CONNECTED
112
+ or not connection.access_token
113
+ or not connection.endpoint_url
114
+ or connection.token_expired
115
+ ):
116
+ raise APIError("This API connection is unavailable.")
117
+
118
+
119
+ async def send(connection: Connection, request: APIRequest) -> dict[str, Any]:
120
+ """Status and body. No token, exception text, or redirect target leaks."""
121
+ headers = {
122
+ **auth_header(connection.settings, connection.access_token),
123
+ "Accept": "application/json, text/plain;q=0.5",
124
+ }
125
+ try:
126
+ with anyio.fail_after(settings.OUTBOUND_TIMEOUT_SECONDS):
127
+ async with outbound_client(headers) as client:
128
+ response = await client.request(
129
+ request.method,
130
+ request.url,
131
+ json=request.body if request.body is not None else None,
132
+ )
133
+ raw = await response.aread()
134
+ except Exception:
135
+ raise APIRequestError(
136
+ "The API request failed. Check the service and token."
137
+ ) from None
138
+ if 300 <= response.status_code < 400:
139
+ return {
140
+ "status": response.status_code,
141
+ "ok": False,
142
+ "body": "The API redirected. SecondShift does not follow redirects.",
143
+ }
144
+ content_type = response.headers.get("content-type", "").split(";")[0].strip()
145
+ text = raw.decode("utf-8", errors="replace").replace(
146
+ connection.access_token, "[redacted]"
147
+ )
148
+ body: Any
149
+ if content_type == "application/json" or content_type.endswith("+json"):
150
+ try:
151
+ body = json.loads(text)
152
+ except ValueError:
153
+ body = text[:TEXT_MAX_CHARS]
154
+ elif content_type.startswith("text/") or not content_type:
155
+ body = text[:TEXT_MAX_CHARS]
156
+ else:
157
+ body = f"Unsupported content type: {content_type[:80]}"
158
+ result = {"status": response.status_code, "ok": response.is_success, "body": body}
159
+ if len(json.dumps(result).encode()) > RESPONSE_MAX_BYTES:
160
+ raise APIError("The API response exceeded the size limit.")
161
+ return result
@@ -0,0 +1,187 @@
1
+ """Create, edit, and re-check HTTP API connections."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from django.db import IntegrityError, transaction
8
+
9
+ from secondshift.connections.dao import binding_taken
10
+ from secondshift.connections.http_api.client import (
11
+ AUTH_TYPES,
12
+ BEARER,
13
+ HEADER,
14
+ HEADER_NAME_PATTERN,
15
+ RESERVED_HEADERS,
16
+ )
17
+ from secondshift.connections.http_api.spec import APIError, Spec, load_spec
18
+ from secondshift.connections.models import Connection
19
+ from secondshift.connections.outbound import (
20
+ OutboundUrlError,
21
+ host_label,
22
+ validate_outbound_url,
23
+ )
24
+ from secondshift.connections.services import ConnectionConflict
25
+ from secondshift.connections.tools import UnknownTools, store_tools
26
+ from secondshift.workspaces.models import Workspace
27
+
28
+ TOKEN_PATTERN = r"\S{1,2000}"
29
+ # Connection.endpoint_url is a URLField with Django's default limit.
30
+ BASE_URL_MAX_LENGTH = 200
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SpecSummary:
35
+ title: str
36
+ servers: tuple[str, ...]
37
+ operations: int
38
+ read_only: int
39
+
40
+
41
+ def summarize(spec_url: str, spec_text: str) -> SpecSummary:
42
+ spec = load_spec(spec_url, spec_text)
43
+ return SpecSummary(
44
+ title=spec.title,
45
+ servers=spec.servers,
46
+ operations=len(spec.operations),
47
+ read_only=sum(1 for item in spec.operations.values() if item.read_only),
48
+ )
49
+
50
+
51
+ def clean_base_url(value: str) -> str:
52
+ """Where requests and the credential go. Checked with DNS, like webhooks."""
53
+ value = value.strip().rstrip("/")
54
+ if not value or len(value) > BASE_URL_MAX_LENGTH:
55
+ raise APIError("Enter the API base URL.")
56
+ try:
57
+ value = validate_outbound_url(value, "base URL")
58
+ except OutboundUrlError as error:
59
+ raise APIError(str(error)) from None
60
+ if "?" in value or "#" in value:
61
+ raise APIError("The base URL cannot have a query or fragment.")
62
+ return value
63
+
64
+
65
+ def clean_auth(auth: str, header: str) -> dict[str, str]:
66
+ if auth not in AUTH_TYPES:
67
+ raise APIError("Choose how to send the credential.")
68
+ if auth == BEARER:
69
+ return {"auth": BEARER}
70
+ if (
71
+ not re.fullmatch(HEADER_NAME_PATTERN, header)
72
+ or header.lower() in RESERVED_HEADERS
73
+ ):
74
+ raise APIError("Enter a header name such as X-API-Key.")
75
+ return {"auth": HEADER, "header": header}
76
+
77
+
78
+ def offered(spec: Spec) -> dict[str, Any]:
79
+ return {name: operation.definition() for name, operation in spec.operations.items()}
80
+
81
+
82
+ def select_operations(
83
+ connection: Connection, operations: dict[str, Any], selected: list[str] | None
84
+ ) -> None:
85
+ """GET operations start selected. Anything else waits for the owner."""
86
+ try:
87
+ store_tools(connection, operations, selected)
88
+ except UnknownTools as error:
89
+ raise APIError("Choose operations from the spec.") from error
90
+
91
+
92
+ def create_api_connection(
93
+ workspace: Workspace,
94
+ binding_key: str,
95
+ display_name: str,
96
+ spec_url: str,
97
+ spec_text: str,
98
+ base_url: str,
99
+ auth: str,
100
+ header: str,
101
+ token: str,
102
+ ) -> Connection:
103
+ if not re.fullmatch(TOKEN_PATTERN, token):
104
+ raise APIError("Enter the credential without spaces.")
105
+ if binding_taken(workspace.pk, binding_key):
106
+ raise ConnectionConflict("This binding already has a connection.")
107
+ # Cheap checks first, before downloading the spec.
108
+ auth_settings = clean_auth(auth, header)
109
+ base_url = clean_base_url(base_url)
110
+ spec = load_spec(spec_url, spec_text)
111
+ connection = Connection(
112
+ workspace=workspace,
113
+ provider=Connection.Provider.API,
114
+ kind=Connection.Kind.API,
115
+ binding_key=binding_key,
116
+ display_name=display_name.strip() or spec.title or host_label(base_url),
117
+ endpoint_url=base_url,
118
+ auth_strategy=Connection.AuthStrategy.API_KEY,
119
+ access_token=token,
120
+ metadata={
121
+ "target": host_label(base_url),
122
+ "spec_url": spec_url,
123
+ "settings": auth_settings,
124
+ },
125
+ )
126
+ select_operations(connection, offered(spec), None)
127
+ connection.full_clean()
128
+ try:
129
+ with transaction.atomic():
130
+ connection.save()
131
+ except IntegrityError:
132
+ raise ConnectionConflict("This binding already has a connection.") from None
133
+ return connection
134
+
135
+
136
+ def update_api_connection(
137
+ connection: Connection,
138
+ display_name: str | None,
139
+ token: str | None,
140
+ base_url: str | None,
141
+ operations: list[str] | None,
142
+ ) -> Connection:
143
+ """A blank token keeps the current one. A new base URL is an explicit choice."""
144
+ if connection.kind != Connection.Kind.API:
145
+ raise ConnectionConflict("Only API connections can be edited here.")
146
+ if token:
147
+ if not re.fullmatch(TOKEN_PATTERN, token):
148
+ raise APIError("Enter the credential without spaces.")
149
+ connection.access_token = token
150
+ elif (
151
+ connection.status != Connection.Status.CONNECTED or not connection.access_token
152
+ ):
153
+ raise APIError("Enter a new credential to reconnect.")
154
+ if display_name is not None:
155
+ connection.display_name = display_name.strip() or connection.binding_key
156
+ if base_url is not None:
157
+ cleaned = clean_base_url(base_url)
158
+ connection.endpoint_url = cleaned
159
+ connection.metadata = {
160
+ **connection.metadata,
161
+ "target": host_label(cleaned),
162
+ }
163
+ if operations is not None:
164
+ select_operations(connection, connection.offered_tools, operations)
165
+ connection.status = Connection.Status.CONNECTED
166
+ connection.full_clean()
167
+ connection.save()
168
+ return connection
169
+
170
+
171
+ def recheck_api_connection(connection: Connection) -> str:
172
+ """Load the spec again. The saved base URL never changes here."""
173
+ spec_url = connection.metadata.get("spec_url", "")
174
+ if not spec_url:
175
+ return (
176
+ "This connection uses a pasted spec, so there is nothing to download again."
177
+ )
178
+ spec = load_spec(spec_url, "")
179
+ operations = offered(spec)
180
+ kept = [name for name in connection.available_tools if name in operations]
181
+ select_operations(connection, operations, kept)
182
+ connection.save(update_fields=["available_tools", "metadata", "updated_at"])
183
+ message = f"Found {len(operations)} operations. {len(kept)} enabled."
184
+ base_url = connection.endpoint_url
185
+ if spec.servers and base_url not in spec.servers:
186
+ message += f" The spec now lists a different server. Requests still go to {host_label(base_url)}."
187
+ return message