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,206 @@
1
+ """Browser sign-in for MCP connections: start, then finish on the callback."""
2
+
3
+ import logging
4
+ import re
5
+ import secrets
6
+ from urllib.parse import urlencode
7
+
8
+ from asgiref.sync import async_to_sync
9
+ from django.contrib.auth.models import AbstractBaseUser
10
+ from django.db import IntegrityError, transaction
11
+ from django.utils import timezone
12
+
13
+ from secondshift.connections.dao import (
14
+ binding_taken,
15
+ create_authorization,
16
+ delete_stale_authorizations,
17
+ lock_unused_authorization,
18
+ )
19
+ from secondshift.connections.mcp.base import MCPError
20
+ from secondshift.connections.mcp.client import (
21
+ connection_target,
22
+ discover_tools,
23
+ select_tools,
24
+ )
25
+ from secondshift.connections.mcp.oauth import (
26
+ AUTHORIZATION_TTL,
27
+ MAX_SCOPES,
28
+ OAUTH,
29
+ SCOPE_PATTERN,
30
+ apply_tokens,
31
+ discover,
32
+ granted_scopes,
33
+ pkce_pair,
34
+ redirect_uri,
35
+ register_client,
36
+ token_request,
37
+ )
38
+ from secondshift.connections.mcp.providers import mcp_provider
39
+ from secondshift.connections.models import Connection, OAuthAuthorization
40
+ from secondshift.connections.services import ConnectionConflict
41
+ from secondshift.workspaces.access import Action, role_allows
42
+ from secondshift.workspaces.dao import find_membership
43
+ from secondshift.workspaces.models import Workspace
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class SignInError(MCPError):
49
+ """A sign-in that cannot finish. `code` is safe to put in a redirect URL."""
50
+
51
+ def __init__(self, code: str, message: str) -> None:
52
+ super().__init__(message)
53
+ self.code = code
54
+
55
+
56
+ def start_authorization(
57
+ workspace: Workspace,
58
+ user: AbstractBaseUser,
59
+ provider_key: str,
60
+ binding_key: str,
61
+ display_name: str,
62
+ values: dict[str, str],
63
+ scopes: list[str],
64
+ connection: Connection | None = None,
65
+ require_approval: bool = False,
66
+ ) -> str:
67
+ """Record a pending sign-in and return the address to send the browser to."""
68
+ provider = mcp_provider(provider_key)
69
+ if OAUTH not in provider.auth_methods:
70
+ raise MCPError(f"{provider.label} does not support sign-in.")
71
+ if connection is None and binding_taken(workspace.pk, binding_key):
72
+ raise ConnectionConflict("This binding already has a connection.")
73
+ cleaned = provider.clean_settings(values)
74
+ server = async_to_sync(discover)(provider.endpoint(cleaned))
75
+ if server is None:
76
+ raise MCPError("This server does not offer sign-in. Use a token instead.")
77
+ scopes = sorted(set(scopes))
78
+ if len(scopes) > MAX_SCOPES or any(
79
+ not re.fullmatch(SCOPE_PATTERN, scope)
80
+ or (server.scopes_supported and scope not in server.scopes_supported)
81
+ for scope in scopes
82
+ ):
83
+ raise MCPError("Choose scopes from the server's list.")
84
+ client_id = async_to_sync(register_client)(server)
85
+ verifier, challenge = pkce_pair()
86
+ state = secrets.token_urlsafe(32)
87
+ delete_stale_authorizations(user, timezone.now() - AUTHORIZATION_TTL)
88
+ create_authorization(
89
+ workspace=workspace,
90
+ user=user,
91
+ connection=connection,
92
+ state=state,
93
+ code_verifier=verifier,
94
+ provider=provider.key,
95
+ binding_key=connection.binding_key if connection else binding_key,
96
+ display_name=display_name,
97
+ connection_settings=cleaned,
98
+ scopes=scopes,
99
+ require_approval=require_approval,
100
+ oauth={
101
+ "issuer": server.issuer,
102
+ "token_endpoint": server.token_endpoint,
103
+ "client_id": client_id,
104
+ "resource": server.resource,
105
+ },
106
+ )
107
+ query = {
108
+ "response_type": "code",
109
+ "client_id": client_id,
110
+ "redirect_uri": redirect_uri(),
111
+ "state": state,
112
+ "code_challenge": challenge,
113
+ "code_challenge_method": "S256",
114
+ "resource": server.resource,
115
+ }
116
+ if scopes:
117
+ query["scope"] = " ".join(scopes)
118
+ separator = "&" if "?" in server.authorization_endpoint else "?"
119
+ return f"{server.authorization_endpoint}{separator}{urlencode(query)}"
120
+
121
+
122
+ def claim_authorization(user: AbstractBaseUser, state: str) -> OAuthAuthorization:
123
+ """Single use: a state value works once, for the person who started it."""
124
+ with transaction.atomic():
125
+ authorization = lock_unused_authorization(state, user)
126
+ if authorization is None:
127
+ raise SignInError("invalid", "This sign-in is not valid. Start again.")
128
+ authorization.used_at = timezone.now()
129
+ authorization.save(update_fields=["used_at"])
130
+ if authorization.created_at < timezone.now() - AUTHORIZATION_TTL:
131
+ raise SignInError("expired", "This sign-in expired. Start again.")
132
+ # The person may have lost their role since they started.
133
+ membership = find_membership(authorization.workspace_id, user.pk)
134
+ if membership is None or not role_allows(
135
+ membership.role, Action.ADD_MCP_CONNECTIONS
136
+ ):
137
+ raise SignInError("invalid", "This sign-in is not valid. Start again.")
138
+ return authorization
139
+
140
+
141
+ def complete_authorization(
142
+ user: AbstractBaseUser, state: str, code: str, error: str
143
+ ) -> Connection:
144
+ authorization = claim_authorization(user, state)
145
+ if error or not code:
146
+ raise SignInError("refused", "Sign-in was cancelled or refused.")
147
+ provider = mcp_provider(authorization.provider)
148
+ oauth = authorization.oauth
149
+ tokens = async_to_sync(token_request)(
150
+ oauth["token_endpoint"],
151
+ {
152
+ "grant_type": "authorization_code",
153
+ "code": code,
154
+ "redirect_uri": redirect_uri(),
155
+ "client_id": oauth["client_id"],
156
+ "code_verifier": authorization.code_verifier,
157
+ "resource": oauth["resource"],
158
+ },
159
+ )
160
+ granted = granted_scopes(tokens, authorization.scopes)
161
+ missing = set(authorization.scopes) - set(granted)
162
+ if missing:
163
+ logger.warning("MCP sign-in missing required scopes: %s", sorted(missing))
164
+ raise SignInError(
165
+ "scope", "The server did not grant the required access. Nothing was saved."
166
+ )
167
+ oauth = {**oauth, "requested_scopes": list(authorization.scopes)}
168
+ connection = authorization.connection or Connection(
169
+ workspace=authorization.workspace,
170
+ provider=provider.key,
171
+ kind=Connection.Kind.MCP,
172
+ binding_key=authorization.binding_key,
173
+ require_approval=authorization.require_approval,
174
+ )
175
+ if connection.workspace_id != authorization.workspace_id:
176
+ raise SignInError("invalid", "This sign-in is not valid. Start again.")
177
+ cleaned = authorization.connection_settings
178
+ connection.display_name = (
179
+ authorization.display_name.strip() or connection.display_name or provider.label
180
+ )
181
+ connection.endpoint_url = provider.endpoint(cleaned)
182
+ connection.auth_strategy = Connection.AuthStrategy.OAUTH
183
+ connection.status = Connection.Status.CONNECTED
184
+ connection.scopes = granted
185
+ connection.refresh_token = ""
186
+ apply_tokens(connection, tokens)
187
+ connection.metadata = {
188
+ **connection.metadata,
189
+ "target": connection_target(provider, cleaned),
190
+ "settings": cleaned,
191
+ "oauth": oauth,
192
+ }
193
+ tools = discover_tools(connection)
194
+ select_tools(
195
+ connection,
196
+ provider,
197
+ tools,
198
+ connection.available_tools if authorization.connection else None,
199
+ )
200
+ connection.full_clean()
201
+ try:
202
+ with transaction.atomic():
203
+ connection.save()
204
+ except IntegrityError:
205
+ raise ConnectionConflict("This binding already has a connection.") from None
206
+ return connection
@@ -0,0 +1,28 @@
1
+ """Versioned data operations used by generated connections migrations.
2
+
3
+ Keep these functions independent of current models.
4
+ """
5
+
6
+ from django.apps.registry import Apps
7
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
8
+
9
+
10
+ def record_requested_scopes_v1(
11
+ apps: Apps, schema_editor: BaseDatabaseSchemaEditor
12
+ ) -> None:
13
+ """Keep OAuth requirements apart from grants on connections saved before b10."""
14
+ connection_model = apps.get_model("connections", "Connection")
15
+ alias = schema_editor.connection.alias
16
+ changed = []
17
+ for connection in connection_model.objects.using(alias).filter(
18
+ auth_strategy="oauth"
19
+ ):
20
+ oauth = connection.metadata.get("oauth", {})
21
+ if "requested_scopes" in oauth:
22
+ continue
23
+ connection.metadata = {
24
+ **connection.metadata,
25
+ "oauth": {**oauth, "requested_scopes": list(connection.scopes)},
26
+ }
27
+ changed.append(connection)
28
+ connection_model.objects.using(alias).bulk_update(changed, ["metadata"])
@@ -0,0 +1,101 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ import encrypted_model_fields.fields
5
+ from django.conf import settings
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ('workspaces', '0001_initial'),
15
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name='Connection',
21
+ fields=[
22
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
+ ('provider', models.CharField(choices=[('github', 'GitHub'), ('sentry', 'Sentry'), ('chatgpt', 'ChatGPT'), ('slack', 'Slack'), ('mcp', 'MCP server'), ('api', 'HTTP API'), ('webhook', 'Webhook'), ('anthropic', 'Anthropic'), ('openai', 'OpenAI'), ('openrouter', 'OpenRouter'), ('openai_compatible', 'OpenAI-compatible')], max_length=24)),
24
+ ('kind', models.CharField(choices=[('identity', 'Identity'), ('mcp', 'MCP'), ('api', 'API'), ('notification', 'Notification'), ('model', 'Model provider')], default='identity', max_length=24)),
25
+ ('binding_key', models.SlugField(blank=True, help_text='Logical name used by repository manifests, for example observability.', max_length=120)),
26
+ ('display_name', models.CharField(max_length=200)),
27
+ ('endpoint_url', models.URLField(blank=True)),
28
+ ('external_account_id', models.CharField(blank=True, max_length=255)),
29
+ ('status', models.CharField(choices=[('connected', 'Connected'), ('expired', 'Expired'), ('revoked', 'Revoked')], default='connected', max_length=16)),
30
+ ('scopes', models.JSONField(blank=True, default=list)),
31
+ ('available_tools', models.JSONField(blank=True, default=list)),
32
+ ('allowed_models', models.JSONField(blank=True, default=list)),
33
+ ('default_model', models.CharField(blank=True, max_length=200)),
34
+ ('is_default', models.BooleanField(default=False, help_text="The workspace's default model provider for runs.")),
35
+ ('auth_strategy', models.CharField(choices=[('oauth', 'OAuth'), ('secret_reference', 'Secret reference'), ('api_key', 'Encrypted API key'), ('managed', 'Managed by service'), ('none', 'None')], default='oauth', max_length=24)),
36
+ ('credential_reference', models.CharField(blank=True, help_text='Opaque Vault/KMS/provider reference. Never a credential value.', max_length=255)),
37
+ ('delivery_url', encrypted_model_fields.fields.EncryptedTextField(blank=True, default='')),
38
+ ('access_token', encrypted_model_fields.fields.EncryptedTextField(blank=True, default='')),
39
+ ('refresh_token', encrypted_model_fields.fields.EncryptedTextField(blank=True, default='')),
40
+ ('expires_at', models.DateTimeField(blank=True, null=True)),
41
+ ('metadata', models.JSONField(blank=True, default=dict)),
42
+ ('created_at', models.DateTimeField(auto_now_add=True)),
43
+ ('updated_at', models.DateTimeField(auto_now=True)),
44
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connections', to='workspaces.workspace')),
45
+ ],
46
+ options={
47
+ 'ordering': ['provider', 'display_name'],
48
+ },
49
+ ),
50
+ migrations.CreateModel(
51
+ name='OAuthAuthorization',
52
+ fields=[
53
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
54
+ ('state', models.CharField(max_length=64, unique=True)),
55
+ ('code_verifier', encrypted_model_fields.fields.EncryptedTextField()),
56
+ ('provider', models.CharField(choices=[('github', 'GitHub'), ('sentry', 'Sentry'), ('chatgpt', 'ChatGPT'), ('slack', 'Slack'), ('mcp', 'MCP server'), ('api', 'HTTP API'), ('webhook', 'Webhook'), ('anthropic', 'Anthropic'), ('openai', 'OpenAI'), ('openrouter', 'OpenRouter'), ('openai_compatible', 'OpenAI-compatible')], max_length=24)),
57
+ ('binding_key', models.SlugField(blank=True, max_length=120)),
58
+ ('display_name', models.CharField(blank=True, max_length=200)),
59
+ ('connection_settings', models.JSONField(blank=True, default=dict)),
60
+ ('scopes', models.JSONField(blank=True, default=list)),
61
+ ('oauth', models.JSONField(blank=True, default=dict)),
62
+ ('created_at', models.DateTimeField(auto_now_add=True)),
63
+ ('used_at', models.DateTimeField(blank=True, null=True)),
64
+ ('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth_authorizations', to='connections.connection')),
65
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth_authorizations', to=settings.AUTH_USER_MODEL)),
66
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth_authorizations', to='workspaces.workspace')),
67
+ ],
68
+ ),
69
+ migrations.CreateModel(
70
+ name='Secret',
71
+ fields=[
72
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
73
+ ('name', models.CharField(max_length=128)),
74
+ ('description', models.CharField(blank=True, max_length=255)),
75
+ ('value', encrypted_model_fields.fields.EncryptedTextField()),
76
+ ('last_used_at', models.DateTimeField(blank=True, null=True)),
77
+ ('created_at', models.DateTimeField(auto_now_add=True)),
78
+ ('updated_at', models.DateTimeField(auto_now=True)),
79
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='secrets', to='workspaces.workspace')),
80
+ ],
81
+ options={
82
+ 'ordering': ['name'],
83
+ },
84
+ ),
85
+ migrations.AddConstraint(
86
+ model_name='connection',
87
+ constraint=models.UniqueConstraint(condition=models.Q(('external_account_id', ''), _negated=True), fields=('workspace', 'provider', 'external_account_id'), name='unique_workspace_provider_account'),
88
+ ),
89
+ migrations.AddConstraint(
90
+ model_name='connection',
91
+ constraint=models.UniqueConstraint(condition=models.Q(('binding_key', ''), _negated=True), fields=('workspace', 'binding_key'), name='unique_workspace_connection_binding'),
92
+ ),
93
+ migrations.AddConstraint(
94
+ model_name='connection',
95
+ constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('workspace',), name='unique_workspace_default_model'),
96
+ ),
97
+ migrations.AddConstraint(
98
+ model_name='secret',
99
+ constraint=models.UniqueConstraint(fields=('workspace', 'name'), name='unique_workspace_secret_name'),
100
+ ),
101
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:43
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='connection',
15
+ name='kind',
16
+ field=models.CharField(choices=[('identity', 'Identity'), ('source', 'Repository source'), ('mcp', 'MCP'), ('api', 'API'), ('notification', 'Notification'), ('model', 'Model provider')], default='identity', max_length=24),
17
+ ),
18
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 10:44
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0002_alter_connection_kind'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='connection',
15
+ name='require_approval',
16
+ field=models.BooleanField(default=True),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='oauthauthorization',
20
+ name='require_approval',
21
+ field=models.BooleanField(default=False),
22
+ ),
23
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 10:44
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0003_require_approval'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='connection',
15
+ name='require_approval',
16
+ field=models.BooleanField(default=False),
17
+ ),
18
+ ]
@@ -0,0 +1,36 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 13:33
2
+
3
+ import django.core.validators
4
+ import django.db.models.deletion
5
+ import encrypted_model_fields.fields
6
+ import uuid
7
+ from django.conf import settings
8
+ from django.db import migrations, models
9
+
10
+
11
+ class Migration(migrations.Migration):
12
+
13
+ dependencies = [
14
+ ('connections', '0004_new_connections_skip_approval'),
15
+ ('workspaces', '0002_api_token'),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name='AgentConnection',
22
+ fields=[
23
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+ ('backend', models.CharField(max_length=24)),
25
+ ('display_name', models.CharField(max_length=200)),
26
+ ('auth_data', encrypted_model_fields.fields.EncryptedTextField()),
27
+ ('credential_key', models.UUIDField(default=uuid.uuid4, editable=False)),
28
+ ('connected', models.BooleanField(default=True)),
29
+ ('max_runs_per_day', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)])),
30
+ ('created_at', models.DateTimeField(auto_now_add=True)),
31
+ ('updated_at', models.DateTimeField(auto_now=True)),
32
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
33
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workspaces.workspace')),
34
+ ],
35
+ ),
36
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 14:14
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0005_agentconnection'),
10
+ # The workspace keeps the default model key from here on.
11
+ ('workspaces', '0004_copy_default_model_key'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.RemoveConstraint(
16
+ model_name='connection',
17
+ name='unique_workspace_default_model',
18
+ ),
19
+ migrations.RemoveField(
20
+ model_name='connection',
21
+ name='is_default',
22
+ ),
23
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 15:07
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0006_remove_is_default'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='agentconnection',
15
+ name='last_check',
16
+ field=models.JSONField(blank=True, null=True),
17
+ ),
18
+ ]
@@ -0,0 +1,25 @@
1
+ # Generated by Django 6.0.9 on 2026-11-07 09:51
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('connections', '0007_agent_last_check'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='AgentCredentialCleanup',
16
+ fields=[
17
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+ ('credential_key', models.UUIDField(unique=True)),
19
+ ('attempts', models.PositiveIntegerField(default=0)),
20
+ ('created_at', models.DateTimeField(auto_now_add=True)),
21
+ ('updated_at', models.DateTimeField(auto_now=True)),
22
+ ('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='credential_cleanups', to='connections.agentconnection')),
23
+ ],
24
+ ),
25
+ ]
@@ -0,0 +1,52 @@
1
+ # Generated by Django 6.0.9 on 2026-11-09 09:15
2
+
3
+ import django.db.migrations.operations.special
4
+ import secondshift.connections.migration_helpers
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ replaces = [('connections', '0009_remove_secrets_and_identity'), ('connections', '0010_record_requested_scopes')]
11
+
12
+ dependencies = [
13
+ ('connections', '0008_agentcredentialcleanup'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.RemoveConstraint(
18
+ model_name='secret',
19
+ name='unique_workspace_secret_name',
20
+ ),
21
+ migrations.RemoveField(
22
+ model_name='connection',
23
+ name='credential_reference',
24
+ ),
25
+ migrations.AlterField(
26
+ model_name='connection',
27
+ name='auth_strategy',
28
+ field=models.CharField(choices=[('oauth', 'OAuth'), ('api_key', 'Encrypted API key'), ('managed', 'Managed by service'), ('none', 'None')], default='oauth', max_length=24),
29
+ ),
30
+ migrations.AlterField(
31
+ model_name='connection',
32
+ name='kind',
33
+ field=models.CharField(choices=[('source', 'Repository source'), ('mcp', 'MCP'), ('api', 'API'), ('notification', 'Notification'), ('model', 'Model provider')], max_length=24),
34
+ ),
35
+ migrations.AlterField(
36
+ model_name='connection',
37
+ name='provider',
38
+ field=models.CharField(choices=[('github', 'GitHub'), ('sentry', 'Sentry'), ('mcp', 'MCP server'), ('api', 'HTTP API'), ('webhook', 'Webhook'), ('anthropic', 'Anthropic'), ('openai', 'OpenAI'), ('openrouter', 'OpenRouter'), ('openai_compatible', 'OpenAI-compatible')], max_length=24),
39
+ ),
40
+ migrations.AlterField(
41
+ model_name='oauthauthorization',
42
+ name='provider',
43
+ field=models.CharField(choices=[('github', 'GitHub'), ('sentry', 'Sentry'), ('mcp', 'MCP server'), ('api', 'HTTP API'), ('webhook', 'Webhook'), ('anthropic', 'Anthropic'), ('openai', 'OpenAI'), ('openrouter', 'OpenRouter'), ('openai_compatible', 'OpenAI-compatible')], max_length=24),
44
+ ),
45
+ migrations.DeleteModel(
46
+ name='Secret',
47
+ ),
48
+ migrations.RunPython(
49
+ code=secondshift.connections.migration_helpers.record_requested_scopes_v1,
50
+ reverse_code=django.db.migrations.operations.special.RunPython.noop,
51
+ ),
52
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-13 11:00
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('connections', '0009_release_0_1_0b11'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='connection',
15
+ name='attribution',
16
+ field=models.BooleanField(blank=True, null=True),
17
+ ),
18
+ ]
File without changes