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,317 @@
1
+ import ipaddress
2
+ import socket
3
+ from collections.abc import AsyncIterator, Iterable
4
+ from urllib.parse import urlsplit
5
+
6
+ import anyio
7
+ import httpcore
8
+ import httpx
9
+ from django.conf import settings
10
+
11
+
12
+ class OutboundUrlError(ValueError):
13
+ pass
14
+
15
+
16
+ def parse_address(address: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
17
+ parsed = ipaddress.ip_address(address.split("%", 1)[0])
18
+ if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped:
19
+ return parsed.ipv4_mapped
20
+ return parsed
21
+
22
+
23
+ def public_address(address: str) -> bool:
24
+ return parse_address(address).is_global
25
+
26
+
27
+ def link_local_address(address: str) -> bool:
28
+ """Link-local hosts include cloud metadata services such as 169.254.169.254.
29
+
30
+ These stay blocked even when local addresses are allowed, because they can
31
+ hand out the host machine's own credentials.
32
+ """
33
+ return parse_address(address).is_link_local
34
+
35
+
36
+ def literal_address(host: str) -> str | None:
37
+ """The IP address a host names directly, including the short numeric
38
+ forms that resolvers accept, such as 2852039166 or 0xa9fea9fe."""
39
+ host = host.strip("[]").split("%", 1)[0]
40
+ try:
41
+ return str(ipaddress.ip_address(host))
42
+ except ValueError:
43
+ pass
44
+ if not host or not all(char in "0123456789abcdefx." for char in host.lower()):
45
+ return None
46
+ try:
47
+ return socket.inet_ntoa(socket.inet_aton(host))
48
+ except OSError:
49
+ return None
50
+
51
+
52
+ def resolves_to_link_local(host: str, port: int) -> bool:
53
+ try:
54
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
55
+ except (socket.gaierror, UnicodeError):
56
+ return False
57
+ return any(link_local_address(str(info[4][0])) for info in infos)
58
+
59
+
60
+ def validate_outbound_url(url: str, noun: str = "URL", resolve: bool = True) -> str:
61
+ """Reject URLs the server must not call. Messages never echo the URL."""
62
+ url = url.strip()
63
+ # "Base URLs", not the "Base urls" that str.capitalize would give.
64
+ plural = f"{noun[:1].upper()}{noun[1:]}s"
65
+ try:
66
+ parts = urlsplit(url)
67
+ port = parts.port
68
+ except ValueError as error:
69
+ raise OutboundUrlError(f"Enter a valid {noun}.") from error
70
+ allow_local = settings.OUTBOUND_ALLOW_LOCAL
71
+ allowed_schemes = {"https", "http"} if allow_local else {"https"}
72
+ if parts.scheme not in allowed_schemes:
73
+ raise OutboundUrlError(
74
+ f"{plural} must use HTTPS."
75
+ if not allow_local
76
+ else f"{plural} must use HTTP or HTTPS."
77
+ )
78
+ if not parts.hostname:
79
+ raise OutboundUrlError(f"Enter a valid {noun}.")
80
+ if parts.username or parts.password:
81
+ raise OutboundUrlError(f"{plural} cannot contain a username or password.")
82
+ literal = literal_address(parts.hostname)
83
+ link_local = literal is not None and link_local_address(literal)
84
+ if not link_local and allow_local and resolve:
85
+ # Local mode skips the public-address check below, but names that
86
+ # resolve to a metadata service are still refused.
87
+ link_local = resolves_to_link_local(parts.hostname, port or 443)
88
+ if link_local:
89
+ raise OutboundUrlError(
90
+ f"{plural} cannot point to a link-local address, such as a cloud "
91
+ "metadata service."
92
+ )
93
+ if allow_local or not resolve:
94
+ return url
95
+ try:
96
+ addresses = {
97
+ info[4][0]
98
+ for info in socket.getaddrinfo(
99
+ parts.hostname, port or 443, proto=socket.IPPROTO_TCP
100
+ )
101
+ }
102
+ except (socket.gaierror, UnicodeError) as error:
103
+ raise OutboundUrlError(f"The {noun} host could not be resolved.") from error
104
+ if not addresses or not all(public_address(item) for item in addresses):
105
+ raise OutboundUrlError(f"{plural} must point to a public address.")
106
+ return url
107
+
108
+
109
+ def host_label(url: str) -> str:
110
+ """A redacted label safe to show: the host only, never the path or query."""
111
+ parts = urlsplit(url)
112
+ host = parts.hostname or "unknown host"
113
+ return f"{host}:{parts.port}" if parts.port else host
114
+
115
+
116
+ def vetted_addresses(infos: Iterable[tuple[object, ...]]) -> list[str]:
117
+ """The addresses from one DNS answer, if every one of them may be called."""
118
+ addresses = list(dict.fromkeys(str(info[4][0]) for info in infos)) # type: ignore[index]
119
+ if not addresses:
120
+ raise httpcore.ConnectError("The host could not be resolved.")
121
+ if any(link_local_address(address) for address in addresses):
122
+ raise httpcore.ConnectError("The host resolves to a link-local address.")
123
+ if not settings.OUTBOUND_ALLOW_LOCAL and not all(
124
+ public_address(address) for address in addresses
125
+ ):
126
+ raise httpcore.ConnectError("The host does not resolve to a public address.")
127
+ return addresses
128
+
129
+
130
+ class PinnedNetworkBackend(httpcore.AsyncNetworkBackend):
131
+ """Connects only to addresses that pass the outbound check.
132
+
133
+ The check and the connection use the same DNS answer, so a host cannot
134
+ pass validation and then rebind to a private address. TLS still verifies
135
+ the original hostname, which httpcore sends as SNI.
136
+ """
137
+
138
+ def __init__(self) -> None:
139
+ self.inner = httpcore.AnyIOBackend()
140
+
141
+ async def public_addresses(self, host: str, port: int) -> list[str]:
142
+ try:
143
+ infos = await anyio.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
144
+ except (OSError, UnicodeError) as error:
145
+ raise httpcore.ConnectError("The host could not be resolved.") from error
146
+ return vetted_addresses(infos)
147
+
148
+ async def connect_tcp(
149
+ self,
150
+ host: str,
151
+ port: int,
152
+ timeout: float | None = None,
153
+ local_address: str | None = None,
154
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
155
+ ) -> httpcore.AsyncNetworkStream:
156
+ last_error: Exception | None = None
157
+ for address in await self.public_addresses(host, port):
158
+ try:
159
+ return await self.inner.connect_tcp(
160
+ address,
161
+ port,
162
+ timeout=timeout,
163
+ local_address=local_address,
164
+ socket_options=socket_options,
165
+ )
166
+ except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
167
+ last_error = error
168
+ assert last_error is not None
169
+ raise last_error
170
+
171
+ async def connect_unix_socket(
172
+ self,
173
+ path: str,
174
+ timeout: float | None = None,
175
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
176
+ ) -> httpcore.AsyncNetworkStream:
177
+ raise httpcore.ConnectError("Unix sockets are not allowed.")
178
+
179
+ async def sleep(self, seconds: float) -> None:
180
+ await self.inner.sleep(seconds)
181
+
182
+
183
+ class PinnedSyncNetworkBackend(httpcore.NetworkBackend):
184
+ """The blocking twin of PinnedNetworkBackend, for code that is not async."""
185
+
186
+ def __init__(self) -> None:
187
+ self.inner = httpcore.SyncBackend()
188
+
189
+ def public_addresses(self, host: str, port: int) -> list[str]:
190
+ try:
191
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
192
+ except (OSError, UnicodeError) as error:
193
+ raise httpcore.ConnectError("The host could not be resolved.") from error
194
+ return vetted_addresses(infos)
195
+
196
+ def connect_tcp(
197
+ self,
198
+ host: str,
199
+ port: int,
200
+ timeout: float | None = None,
201
+ local_address: str | None = None,
202
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
203
+ ) -> httpcore.NetworkStream:
204
+ last_error: Exception | None = None
205
+ for address in self.public_addresses(host, port):
206
+ try:
207
+ return self.inner.connect_tcp(
208
+ address,
209
+ port,
210
+ timeout=timeout,
211
+ local_address=local_address,
212
+ socket_options=socket_options,
213
+ )
214
+ except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
215
+ last_error = error
216
+ assert last_error is not None
217
+ raise last_error
218
+
219
+ def connect_unix_socket(
220
+ self,
221
+ path: str,
222
+ timeout: float | None = None,
223
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
224
+ ) -> httpcore.NetworkStream:
225
+ raise httpcore.ConnectError("Unix sockets are not allowed.")
226
+
227
+ def sleep(self, seconds: float) -> None:
228
+ self.inner.sleep(seconds)
229
+
230
+
231
+ class PinnedTransport(httpx.AsyncHTTPTransport):
232
+ """An httpx transport that ignores proxy settings and pins vetted addresses."""
233
+
234
+ def __init__(self, limits: httpx.Limits = httpx.Limits()) -> None:
235
+ super().__init__(trust_env=False, limits=limits)
236
+ # httpx has no public hook for the network backend. Swap the pool it
237
+ # built for one that uses ours, with the same TLS and pool settings.
238
+ self._pool = httpcore.AsyncConnectionPool(
239
+ ssl_context=httpx.create_ssl_context(trust_env=False),
240
+ max_connections=limits.max_connections,
241
+ max_keepalive_connections=limits.max_keepalive_connections,
242
+ keepalive_expiry=limits.keepalive_expiry,
243
+ network_backend=PinnedNetworkBackend(),
244
+ )
245
+
246
+
247
+ class PinnedSyncTransport(httpx.HTTPTransport):
248
+ """The blocking twin of PinnedTransport."""
249
+
250
+ def __init__(self, limits: httpx.Limits = httpx.Limits()) -> None:
251
+ super().__init__(trust_env=False, limits=limits)
252
+ self._pool = httpcore.ConnectionPool(
253
+ ssl_context=httpx.create_ssl_context(trust_env=False),
254
+ max_connections=limits.max_connections,
255
+ max_keepalive_connections=limits.max_keepalive_connections,
256
+ keepalive_expiry=limits.keepalive_expiry,
257
+ network_backend=PinnedSyncNetworkBackend(),
258
+ )
259
+
260
+
261
+ # The most a response from a configured service may send.
262
+ RESPONSE_MAX_BYTES = 2 * 1024 * 1024
263
+
264
+
265
+ class OutboundResponseError(Exception):
266
+ """A response SecondShift will not read."""
267
+
268
+
269
+ class BoundedStream(httpx.AsyncByteStream):
270
+ def __init__(self, inner: httpx.AsyncByteStream) -> None:
271
+ self.inner = inner
272
+
273
+ async def __aiter__(self) -> AsyncIterator[bytes]:
274
+ size = 0
275
+ async for chunk in self.inner:
276
+ size += len(chunk)
277
+ if size > RESPONSE_MAX_BYTES:
278
+ raise OutboundResponseError("The response exceeded the size limit.")
279
+ yield chunk
280
+
281
+ async def aclose(self) -> None:
282
+ await self.inner.aclose()
283
+
284
+
285
+ class BoundedTransport(PinnedTransport):
286
+ """A pinned transport that refuses compressed and oversized responses."""
287
+
288
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
289
+ response = await super().handle_async_request(request)
290
+ if response.headers.get("content-encoding", "identity") != "identity":
291
+ await response.aclose()
292
+ raise OutboundResponseError("Compressed responses are not supported.")
293
+ response.stream = BoundedStream(response.stream)
294
+ return response
295
+
296
+
297
+ def outbound_client(headers: dict[str, str]) -> httpx.AsyncClient:
298
+ """A client for a service a person configured. It connects only to vetted
299
+ addresses, ignores proxy settings, follows no redirects and reads at most
300
+ RESPONSE_MAX_BYTES."""
301
+ return httpx.AsyncClient(
302
+ transport=BoundedTransport(),
303
+ headers={**headers, "Accept-Encoding": "identity"},
304
+ timeout=settings.OUTBOUND_TIMEOUT_SECONDS,
305
+ follow_redirects=False,
306
+ trust_env=False,
307
+ )
308
+
309
+
310
+ def outbound_sync_client() -> httpx.Client:
311
+ """The blocking client for a configured service, pinned the same way."""
312
+ return httpx.Client(
313
+ transport=PinnedSyncTransport(),
314
+ timeout=settings.OUTBOUND_TIMEOUT_SECONDS,
315
+ follow_redirects=False,
316
+ trust_env=False,
317
+ )
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass
2
+
3
+ from secondshift.connections.models import Connection
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class RepositoryRef:
8
+ """The repository a job works on, as external tools see it.
9
+
10
+ The repositories app builds it, so connections never read repository rows.
11
+ """
12
+
13
+ workspace_id: int
14
+ # owner/repo on GitHub. None when the repository has no GitHub identity.
15
+ github_name: str | None
16
+ # GitHub's id, only for repositories added from GitHub.
17
+ github_id: int | None = None
18
+ # The source connection a GitHub repository was added with.
19
+ source: Connection | None = None
@@ -0,0 +1,313 @@
1
+ from datetime import datetime
2
+ from typing import Any, Literal
3
+
4
+ from ninja import Schema
5
+ from pydantic import Field, SecretStr
6
+
7
+ from secondshift.connections.dao import has_pending_cleanup
8
+ from secondshift.connections.execution import recent_login_runs
9
+ from secondshift.connections.models import AgentConnection
10
+ from secondshift.workspaces.access import Action, allows
11
+
12
+
13
+ class FeatureCheckSchema(Schema):
14
+ feature: str
15
+ label: str
16
+ # None when it cannot be checked without using it.
17
+ ok: bool | None
18
+ message: str
19
+
20
+
21
+ class ConnectionCheckSchema(Schema):
22
+ ok: bool
23
+ message: str
24
+ checked_at: datetime
25
+ # Source connections: what each feature can do, and where to fix it.
26
+ features: list[FeatureCheckSchema] = Field(default_factory=list)
27
+ fix_url: str = ""
28
+
29
+
30
+ class ConnectionUsageSchema(Schema):
31
+ jobs: int
32
+ runs_7d: int
33
+ spend_7d_usd: float
34
+ spend_partial: bool
35
+ last_used_at: datetime | None
36
+
37
+
38
+ class OfferedToolSchema(Schema):
39
+ name: str
40
+ description: str
41
+ read_only: bool
42
+
43
+
44
+ class ConnectionSchema(Schema):
45
+ id: int
46
+ provider: str
47
+ kind: str
48
+ binding_key: str
49
+ display_name: str
50
+ status: str
51
+ expires_at: datetime | None
52
+ available_tools: list[str]
53
+ target: str
54
+ allowed_models: list[str]
55
+ default_model: str
56
+ is_default: bool
57
+ available_models: list[str]
58
+ last_check: ConnectionCheckSchema | None = None
59
+ usage: ConnectionUsageSchema | None = None
60
+ # MCP only: the organization or project an owner chose. Never secret.
61
+ settings: dict[str, str] = Field(default_factory=dict)
62
+ # MCP only: "oauth" for a browser sign-in, "token" for a pasted token.
63
+ auth: str = ""
64
+ scopes: list[str] = Field(default_factory=list)
65
+ requested_scopes: list[str] = Field(default_factory=list)
66
+ # Custom MCP servers: every tool the server offers, for the owner to choose.
67
+ offered_tools: list[OfferedToolSchema] = Field(default_factory=list)
68
+ # Capability connections: whether each job's request waits for an owner.
69
+ require_approval: bool = False
70
+ # GitHub sources: whether pull requests, issues and commits name SecondShift.
71
+ attribution: bool = False
72
+
73
+
74
+ class AttributionSchema(Schema):
75
+ attribution: bool
76
+
77
+
78
+ class WebhookConnectionCreateSchema(Schema):
79
+ binding_key: str = Field(
80
+ min_length=1, max_length=120, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
81
+ )
82
+ display_name: str = Field(default="", max_length=200)
83
+ url: str = Field(min_length=1, max_length=2000)
84
+ require_approval: bool = False
85
+
86
+
87
+ class WebhookConnectionUpdateSchema(Schema):
88
+ display_name: str | None = Field(default=None, max_length=200)
89
+ url: str | None = Field(default=None, max_length=2000)
90
+ require_approval: bool | None = None
91
+
92
+
93
+ class GitHubTokenConnectSchema(Schema):
94
+ token: str = Field(min_length=1, max_length=2000)
95
+ # Replace the token of this connection instead of adding one.
96
+ connection_id: int | None = None
97
+
98
+
99
+ class MCPConnectionCreateSchema(Schema):
100
+ provider: str = Field(min_length=1, max_length=40)
101
+ binding_key: str = Field(
102
+ min_length=1, max_length=120, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
103
+ )
104
+ display_name: str = Field(default="", max_length=200)
105
+ # Empty when auth is "source": the connection borrows repository sources.
106
+ token: str = Field(default="", max_length=2000)
107
+ auth: str = Field(default="token", pattern=r"^(token|source)$")
108
+ settings: dict[str, str] = Field(default_factory=dict, max_length=10)
109
+ require_approval: bool = False
110
+
111
+
112
+ class MCPConnectionUpdateSchema(Schema):
113
+ display_name: str | None = Field(default=None, max_length=200)
114
+ token: str | None = Field(default=None, max_length=2000)
115
+ settings: dict[str, str] | None = Field(default=None, max_length=10)
116
+ tools: list[str] | None = Field(default=None, max_length=200)
117
+ require_approval: bool | None = None
118
+
119
+
120
+ class APISpecSchema(Schema):
121
+ spec_url: str = Field(default="", max_length=2000)
122
+ spec_text: str = Field(default="", max_length=5 * 1024 * 1024)
123
+
124
+
125
+ class APISpecSummarySchema(Schema):
126
+ title: str
127
+ servers: list[str]
128
+ operations: int
129
+ read_only: int
130
+
131
+
132
+ class APIConnectionCreateSchema(APISpecSchema):
133
+ binding_key: str = Field(
134
+ min_length=1, max_length=120, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
135
+ )
136
+ display_name: str = Field(default="", max_length=200)
137
+ base_url: str = Field(min_length=1, max_length=200)
138
+ auth: str = Field(default="bearer", max_length=20)
139
+ header: str = Field(default="", max_length=64)
140
+ token: str = Field(min_length=1, max_length=2000)
141
+ require_approval: bool = False
142
+
143
+
144
+ class APIConnectionUpdateSchema(Schema):
145
+ display_name: str | None = Field(default=None, max_length=200)
146
+ token: str | None = Field(default=None, max_length=2000)
147
+ base_url: str | None = Field(default=None, max_length=200)
148
+ operations: list[str] | None = Field(default=None, max_length=500)
149
+ require_approval: bool | None = None
150
+
151
+
152
+ class MCPInspectSchema(Schema):
153
+ provider: str = Field(min_length=1, max_length=40)
154
+ settings: dict[str, str] = Field(default_factory=dict, max_length=10)
155
+
156
+
157
+ class MCPInspectResultSchema(Schema):
158
+ oauth: bool
159
+ scopes: list[str]
160
+
161
+
162
+ class MCPSignInSchema(Schema):
163
+ provider: str = Field(min_length=1, max_length=40)
164
+ binding_key: str = Field(
165
+ default="", max_length=120, pattern=r"^([a-z0-9]+(?:-[a-z0-9]+)*)?$"
166
+ )
167
+ display_name: str = Field(default="", max_length=200)
168
+ settings: dict[str, str] = Field(default_factory=dict, max_length=10)
169
+ scopes: list[str] = Field(default_factory=list, max_length=20)
170
+ # New connections only. A reconnect keeps the connection's setting.
171
+ require_approval: bool = False
172
+ # Reconnect this connection instead of creating one.
173
+ connection_id: int | None = None
174
+
175
+
176
+ class MCPSignInStartSchema(Schema):
177
+ authorize_url: str
178
+
179
+
180
+ class MCPSettingSchema(Schema):
181
+ key: str
182
+ label: str
183
+ pattern: str
184
+ required: bool
185
+ help: str
186
+ placeholder: str
187
+
188
+
189
+ class MCPProviderSchema(Schema):
190
+ key: str
191
+ label: str
192
+ description: str
193
+ default_binding: str
194
+ token_label: str
195
+ token_hint: str
196
+ setup_url: str
197
+ setup_help: str
198
+ settings: list[MCPSettingSchema]
199
+ tools: list[str]
200
+ auth_methods: list[str]
201
+ owner_selects_tools: bool
202
+
203
+
204
+ class ModelConnectionCreateSchema(Schema):
205
+ provider: Literal["anthropic", "openai", "openrouter", "openai_compatible"]
206
+ display_name: str = Field(default="", max_length=200)
207
+ api_key: str = Field(default="", max_length=1000)
208
+ base_url: str = Field(default="", max_length=2000)
209
+ allowed_models: list[str] = Field(default_factory=list, max_length=100)
210
+ default_model: str = Field(default="", max_length=200)
211
+ is_default: bool | None = None
212
+
213
+
214
+ class ModelConnectionUpdateSchema(Schema):
215
+ display_name: str | None = Field(default=None, max_length=200)
216
+ api_key: str | None = Field(default=None, max_length=1000)
217
+ base_url: str | None = Field(default=None, max_length=2000)
218
+ allowed_models: list[str] | None = Field(default=None, max_length=100)
219
+ default_model: str | None = Field(default=None, max_length=200)
220
+ is_default: bool | None = None
221
+
222
+
223
+ class ConnectionTestSchema(Schema):
224
+ ok: bool
225
+ status_code: int | None
226
+ message: str
227
+ models: list[str] = Field(default_factory=list)
228
+
229
+
230
+ class WebhookSampleSchema(Schema):
231
+ # A run report as a webhook receives it, built from made-up records.
232
+ payload: dict[str, Any]
233
+ # How many characters of the report the summary field can hold.
234
+ summary_max_chars: int
235
+
236
+
237
+ class AgentConnectionSchema(Schema):
238
+ id: int
239
+ backend: str
240
+ display_name: str
241
+ connected: bool
242
+ owner_id: int
243
+ updated_at: datetime
244
+ max_runs_per_day: int | None = None
245
+ # Runs in the last 24 hours, the window the cap counts over.
246
+ recent_runs: int = 0
247
+ can_manage: bool = False
248
+ last_check: dict[str, Any] | None = None
249
+ # The runner still holds a cached copy of an earlier login.
250
+ runner_cleanup_pending: bool = False
251
+
252
+ @staticmethod
253
+ def resolve_recent_runs(obj: AgentConnection) -> int:
254
+ # A list counts every login at once and attaches the counts.
255
+ counted = getattr(obj, "recent_runs", None)
256
+ if counted is None:
257
+ counted = recent_login_runs(obj.workspace_id)[obj.pk]
258
+ return counted
259
+
260
+ @staticmethod
261
+ def resolve_runner_cleanup_pending(obj: AgentConnection) -> bool:
262
+ return has_pending_cleanup(obj)
263
+
264
+ @staticmethod
265
+ def resolve_can_manage(obj: AgentConnection, context: dict[str, Any]) -> bool:
266
+ request = context["request"]
267
+ return obj.owner_id == request.user.pk and allows(
268
+ request, Action.EDIT_CONNECTIONS
269
+ )
270
+
271
+
272
+ class AgentRunLimitInput(Schema):
273
+ # Empty removes the cap.
274
+ max_runs_per_day: int | None = Field(default=None, ge=1, le=1000)
275
+
276
+
277
+ class AgentConnectionInput(Schema):
278
+ backend: str
279
+ display_name: str = Field(default="", max_length=200)
280
+ auth: SecretStr = Field(max_length=32000)
281
+
282
+
283
+ class ExecutionChoiceSchema(Schema):
284
+ key: str
285
+ label: str
286
+ disabled: bool = False
287
+ # Why this choice cannot run jobs, or empty when it can.
288
+ problem: str = ""
289
+
290
+
291
+ class ExecutionStatusSchema(Schema):
292
+ label: str
293
+ backend: str
294
+ problem: str
295
+ # False until an owner picks a model provider or a login.
296
+ chosen: bool
297
+ # Nothing to choose yet: no model provider and no login saved.
298
+ needs_setup: bool
299
+ fix_connection_id: int | None
300
+
301
+
302
+ class ExecutionConfigurationSchema(Schema):
303
+ enabled: bool
304
+ choices: list[ExecutionChoiceSchema]
305
+ selected: str
306
+ label: str
307
+ backend: str
308
+ ready: bool
309
+ problem: str
310
+
311
+
312
+ class ExecutionPreferenceSchema(Schema):
313
+ key: str = Field(max_length=100)