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,140 @@
1
+ """Settings for the separately deployed command runner.
2
+
3
+ This module deliberately does not import the control-plane settings. The runner
4
+ has its own apps, database credentials, authentication token, and network policy.
5
+ """
6
+
7
+ import os
8
+ import secrets
9
+ from pathlib import Path
10
+
11
+ from django.core.exceptions import ImproperlyConfigured
12
+ from environ import Env
13
+
14
+ from secondshift.config.constants import (
15
+ DEFAULT_SANDBOX_IMAGE,
16
+ ENVIRONMENT_VAR,
17
+ LOCAL_AGENT_TAG,
18
+ LOCAL_GATEWAY_URL,
19
+ AppEnv,
20
+ Environment,
21
+ SandboxRuntime,
22
+ Service,
23
+ local_agent_broker_url,
24
+ )
25
+ from secondshift.config.logging import logging_config
26
+
27
+ env = Env()
28
+ # On a single host the runner shares the data folder, but reads only runner.env
29
+ # (its token), never the control plane's .env with the encryption key.
30
+ DATA_DIR = Path(env.str(AppEnv.DATA_DIR, default="~/.secondshift")).expanduser()
31
+ Env.read_env(DATA_DIR / "runner.env")
32
+
33
+ # Nothing in the runner signs data (no sessions, no auth cookies), so a
34
+ # per-process key is enough and there is nothing to configure.
35
+ SECRET_KEY = secrets.token_urlsafe(50)
36
+ DEBUG = False
37
+ ALLOWED_HOSTS = env.list(
38
+ AppEnv.RUNNER_ALLOWED_HOSTS, default=["127.0.0.1", "localhost"]
39
+ )
40
+
41
+ INSTALLED_APPS = ["django.contrib.contenttypes", "secondshift.runner"]
42
+ MIDDLEWARE = ["django.middleware.security.SecurityMiddleware"]
43
+ ROOT_URLCONF = "secondshift.runner.urls"
44
+
45
+ USE_TZ = True
46
+ TIME_ZONE = "UTC"
47
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
48
+ DATA_UPLOAD_MAX_MEMORY_SIZE = 64 * 1024
49
+
50
+ RUNNER_WORK_ROOT = Path(
51
+ env.str(AppEnv.RUNNER_WORK_ROOT, default=str(DATA_DIR / "runner"))
52
+ ).resolve()
53
+ RUNNER_WORK_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
54
+ RUNNER_DATABASE_URL = env.str(
55
+ AppEnv.RUNNER_DATABASE_URL,
56
+ default=f"sqlite:///{RUNNER_WORK_ROOT / 'runner.sqlite3'}",
57
+ )
58
+ DATABASES = {
59
+ "default": {
60
+ **env.db_url_config(RUNNER_DATABASE_URL),
61
+ "CONN_MAX_AGE": env.int(AppEnv.RUNNER_DB_CONN_MAX_AGE_SECONDS, default=60),
62
+ "CONN_HEALTH_CHECKS": True,
63
+ }
64
+ }
65
+
66
+ RUNNER_SERVICE_TOKEN = env.str(AppEnv.RUNNER_SERVICE_TOKEN, default="")
67
+ RUNNER_IMAGE = env.str(AppEnv.RUNNER_IMAGE, default=DEFAULT_SANDBOX_IMAGE)
68
+ RUNNER_RUNTIME = env.str(AppEnv.RUNNER_RUNTIME, default=SandboxRuntime.GVISOR)
69
+ RUNNER_COMMAND_TIMEOUT_SECONDS = env.int(
70
+ "SECONDSHIFT_RUNNER_COMMAND_TIMEOUT_SECONDS", default=120
71
+ )
72
+ RUNNER_COMMAND_OUTPUT_MAX_BYTES = env.int(
73
+ "SECONDSHIFT_RUNNER_COMMAND_OUTPUT_MAX_BYTES", default=1024 * 1024
74
+ )
75
+ RUNNER_MEMORY = env.str("SECONDSHIFT_RUNNER_MEMORY", default="2g")
76
+ RUNNER_CPUS = env.str("SECONDSHIFT_RUNNER_CPUS", default="2")
77
+ RUNNER_PIDS_LIMIT = env.int("SECONDSHIFT_RUNNER_PIDS_LIMIT", default=256)
78
+ # A runner started as root still runs sandboxes as an unprivileged user.
79
+ UNPRIVILEGED_ID = 10001
80
+ RUNNER_SANDBOX_UID = env.int(
81
+ "SECONDSHIFT_RUNNER_SANDBOX_UID", default=os.getuid() or UNPRIVILEGED_ID
82
+ )
83
+ RUNNER_SANDBOX_GID = env.int(
84
+ "SECONDSHIFT_RUNNER_SANDBOX_GID", default=os.getgid() or UNPRIVILEGED_ID
85
+ )
86
+
87
+ if len(RUNNER_SERVICE_TOKEN) < 32:
88
+ raise ImproperlyConfigured(
89
+ "SECONDSHIFT_RUNNER_SERVICE_TOKEN must contain at least 32 characters. "
90
+ "On a single host, start SecondShift once to generate it in runner.env."
91
+ )
92
+ if RUNNER_RUNTIME not in set(SandboxRuntime):
93
+ raise ImproperlyConfigured(
94
+ f"SECONDSHIFT_RUNNER_RUNTIME must be one of {', '.join(SandboxRuntime)}"
95
+ )
96
+ if RUNNER_COMMAND_TIMEOUT_SECONDS < 1:
97
+ raise ImproperlyConfigured("Runner command timeout must be positive")
98
+ if RUNNER_COMMAND_OUTPUT_MAX_BYTES < 1024:
99
+ raise ImproperlyConfigured("Runner output limit must be at least 1024 bytes")
100
+ if RUNNER_PIDS_LIMIT < 1:
101
+ raise ImproperlyConfigured("Runner PID limit must be positive")
102
+ if RUNNER_SANDBOX_UID < 0 or RUNNER_SANDBOX_GID < 0:
103
+ raise ImproperlyConfigured("Runner sandbox UID and GID cannot be negative")
104
+ # Each workspace is chowned to the sandbox user. Without root that only works
105
+ # for the runner's own user and groups, so fail at startup, not on the first run.
106
+ if os.geteuid() != 0 and (
107
+ os.geteuid() != RUNNER_SANDBOX_UID
108
+ or RUNNER_SANDBOX_GID not in {os.getegid(), *os.getgroups()}
109
+ ):
110
+ raise ImproperlyConfigured(
111
+ "SECONDSHIFT_RUNNER_SANDBOX_UID/GID name another user, which needs a "
112
+ "runner running as root. Unset them to use the runner's own user."
113
+ )
114
+
115
+ LOGGING = logging_config(
116
+ env.str(AppEnv.LOG_LEVEL, default="INFO").upper(), Service.RUNNER
117
+ )
118
+
119
+
120
+ # CLI agent execution. Servers turn it on by setting an image pinned by digest.
121
+ # `secondshift up` builds the local tag on one person's machine. Only there may
122
+ # the runner resolve a tag, once per run. Servers must pin a digest.
123
+ LOCAL = os.environ.get(ENVIRONMENT_VAR) == Environment.LOCAL
124
+ AGENT_IMAGE = env.str(AppEnv.AGENT_IMAGE, default=LOCAL_AGENT_TAG if LOCAL else "")
125
+ AGENT_IMAGE_TAGS_ALLOWED = LOCAL
126
+ AGENT_RESOLV_CONF = env.str(AppEnv.AGENT_RESOLV_CONF, default="")
127
+ AGENT_NETWORK = env.str(AppEnv.AGENT_NETWORK, default="bridge")
128
+ AGENT_BROKER_URL = env.str(
129
+ AppEnv.AGENT_BROKER_URL,
130
+ default=local_agent_broker_url(
131
+ env.str(AppEnv.MODEL_GATEWAY_URL, default=LOCAL_GATEWAY_URL)
132
+ )
133
+ if LOCAL
134
+ else "",
135
+ )
136
+
137
+ AGENT_AUTH_ROOT = RUNNER_WORK_ROOT / "agent-auth"
138
+
139
+ if AGENT_IMAGE:
140
+ DATA_UPLOAD_MAX_MEMORY_SIZE = 512 * 1024
@@ -0,0 +1,47 @@
1
+ # ruff: noqa: F403, F405
2
+ import os
3
+ import tempfile
4
+
5
+ from secondshift.config.constants import ENVIRONMENT_VAR, Environment
6
+
7
+ # Tests use production defaults, whatever SECONDSHIFT_ENV the shell exports.
8
+ os.environ[ENVIRONMENT_VAR] = Environment.TEST
9
+ # Files go to a throwaway folder, and ~/.secondshift/.env is never read.
10
+ os.environ["SECONDSHIFT_DATA_DIR"] = tempfile.mkdtemp(prefix="secondshift-test-")
11
+
12
+ from secondshift.config.settings.base import *
13
+
14
+ INSTALLED_APPS = [*INSTALLED_APPS, "secondshift.runner"]
15
+ SECRET_KEY = "test-secret-key"
16
+ FIELD_ENCRYPTION_KEY = "x2Ce3x8qg2ZDiZ0T3W2WXZkCw5rN0p8D2kMlcV1Wq3E="
17
+ DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
18
+ DBOS_SYSTEM_DATABASE_URL = "sqlite:///:memory:"
19
+ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
20
+ SIGNUP_ENABLED = False
21
+ # Tests run without collectstatic, so they cannot use the manifest storage.
22
+ # An empty folder keeps WhiteNoise from warning that STATIC_ROOT is missing,
23
+ # and keeps a local collectstatic build out of the tests.
24
+ STATIC_ROOT = tempfile.mkdtemp(prefix="secondshift-static-")
25
+ STORAGES = {
26
+ **STORAGES,
27
+ "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
28
+ }
29
+
30
+ # Tests install every first-party app in one Django process. Deployment settings
31
+ # remain separate and are checked independently.
32
+ RUNNER_SERVICE_TOKEN = "test-runner-service-token-value"
33
+ RUNNER_WORK_ROOT = BASE_DIR / ".secondshift-runner-test"
34
+ RUNNER_COMMAND_TIMEOUT_SECONDS = 120
35
+ RUNNER_COMMAND_OUTPUT_MAX_BYTES = 1024 * 1024
36
+ RUNNER_MEMORY = "2g"
37
+ RUNNER_CPUS = "2"
38
+ RUNNER_PIDS_LIMIT = 256
39
+ RUNNER_SANDBOX_UID = os.getuid()
40
+ RUNNER_SANDBOX_GID = os.getgid()
41
+ AGENT_IMAGE_TAGS_ALLOWED = False
42
+
43
+ # Internal services in tests have explicit addresses, independent of the shell.
44
+ RUNNER_SERVICE_URL = "http://127.0.0.1:8002"
45
+ MODEL_GATEWAY_URL = "http://127.0.0.1:8001"
46
+
47
+ AGENT_RESOLV_CONF = ""
@@ -0,0 +1,45 @@
1
+ """Preserve Vite's asset names; Django hashes its own admin/account assets."""
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+ from typing import Any
6
+
7
+ from django.core.files.storage import Storage
8
+ from whitenoise.storage import CompressedManifestStaticFilesStorage
9
+
10
+ VITE_ASSET_PREFIX = "assets/"
11
+
12
+
13
+ class StaticFilesStorage(CompressedManifestStaticFilesStorage):
14
+ def post_process(
15
+ self,
16
+ paths: dict[str, tuple[Storage, str]],
17
+ **options: Any,
18
+ ) -> Iterator[tuple[str, str, bool | Exception]]:
19
+ frontend = {
20
+ name: source
21
+ for name, source in paths.items()
22
+ if name.startswith(VITE_ASSET_PREFIX)
23
+ }
24
+ django = {
25
+ name: source for name, source in paths.items() if name not in frontend
26
+ }
27
+ yield from super().post_process(django, **options)
28
+ if options.get("dry_run"):
29
+ return
30
+ # Vite already hashes filenames and rewrites URLs in its HTML/CSS/JS.
31
+ # Register identity mappings without rewriting or duplicating those files.
32
+ self.hashed_files.update({name: name for name in frontend})
33
+ self.save_manifest()
34
+ for name in frontend:
35
+ yield name, name, False
36
+ for name, compressed in self.compress_files(frontend):
37
+ yield name, compressed, True
38
+
39
+
40
+ def immutable_static_file(path: str, url: str) -> bool:
41
+ """Recognize Django's hashes and Vite's dash-separated content hashes."""
42
+ return bool(
43
+ re.search(r"\.[a-f0-9]{12}\.[^/]+$", url)
44
+ or re.search(r"/assets/[^/]+-[A-Za-z0-9_-]{8,}\.[^/]+$", url)
45
+ )
@@ -0,0 +1,42 @@
1
+ from django.conf import settings
2
+ from django.contrib import admin
3
+ from django.http import FileResponse, Http404, HttpRequest
4
+ from django.urls import include, path
5
+ from health_check.views import HealthCheckView
6
+
7
+ from secondshift.config.api import api
8
+ from secondshift.core.signin import sign_in
9
+ from secondshift.core.views import password_reset
10
+ from secondshift.mcp_server.api import api as mcp_api
11
+ from secondshift.repositories.github_views import github_callback, github_start
12
+ from secondshift.workspaces.views import open_invite
13
+
14
+
15
+ def spa(request: HttpRequest, path: str = "") -> FileResponse:
16
+ index = settings.WEB_DIR / "index.html"
17
+ if not index.exists():
18
+ raise Http404(
19
+ "Dashboard has not been built. Run npm --prefix dashboard run build."
20
+ )
21
+ return FileResponse(index.open("rb"), content_type="text/html")
22
+
23
+
24
+ urlpatterns = [
25
+ path("github/connect/", github_start),
26
+ path("github/callback/", github_callback, name="github-callback"),
27
+ path("-/admin/", admin.site.urls),
28
+ path("accounts/password/reset/", password_reset),
29
+ path("accounts/", include("allauth.urls")),
30
+ path("sign-in/<str:token>/", sign_in, name="sign-in"),
31
+ path("invite/<str:token>/", open_invite, name="invite"),
32
+ path("api/", api.urls),
33
+ path("mcp", mcp_api.urls),
34
+ path(
35
+ "-/health/",
36
+ HealthCheckView.as_view(
37
+ checks=["health_check.checks.Database", "health_check.checks.Cache"]
38
+ ),
39
+ ),
40
+ path("", spa),
41
+ path("<path:path>", spa),
42
+ ]
File without changes
@@ -0,0 +1,27 @@
1
+ """What a job could reach through an external connection."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+
7
+ class AccessReason(StrEnum):
8
+ """Why a job cannot use an external connection. Stored in run plans."""
9
+
10
+ MCP_PROVIDER_UNSUPPORTED = "mcp_provider_unsupported"
11
+ MCP_SCOPE_UNKNOWN = "mcp_scope_unknown"
12
+ MCP_SERVER_UNKNOWN = "mcp_server_unknown"
13
+ GITHUB_REPOSITORY_UNKNOWN = "github_repository_unknown"
14
+ SENTRY_ORGANIZATION_UNKNOWN = "sentry_organization_unknown"
15
+ MCP_SOURCE_MISSING = "mcp_source_missing"
16
+ API_BASE_URL_UNKNOWN = "api_base_url_unknown"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Access:
21
+ """What a job could reach through a connection, in plain words."""
22
+
23
+ ok: bool
24
+ scope: str | None
25
+ text: str
26
+ # Set when the job cannot use the connection.
27
+ reason: AccessReason | None = None
@@ -0,0 +1,25 @@
1
+ from django.contrib import admin
2
+
3
+ from secondshift.connections.models import Connection
4
+
5
+
6
+ @admin.register(Connection)
7
+ class ConnectionAdmin(admin.ModelAdmin):
8
+ list_display = (
9
+ "provider",
10
+ "display_name",
11
+ "binding_key",
12
+ "kind",
13
+ "workspace",
14
+ "status",
15
+ "expires_at",
16
+ )
17
+ list_filter = ("provider", "kind", "status", "auth_strategy")
18
+ search_fields = (
19
+ "display_name",
20
+ "binding_key",
21
+ "workspace__name",
22
+ "external_account_id",
23
+ )
24
+ exclude = ("access_token", "refresh_token", "delivery_url")
25
+ readonly_fields = ("created_at", "updated_at")
@@ -0,0 +1,224 @@
1
+ """Person-owned CLI logins; only references enter job plans."""
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+
7
+ from django.conf import settings
8
+ from django.contrib.auth.models import User
9
+ from django.db import transaction
10
+ from django.utils import timezone
11
+ from pydantic import ValidationError
12
+
13
+ from secondshift.config.constants import AgentBackend
14
+ from secondshift.connections.dao import (
15
+ count_cleanup_attempt,
16
+ pending_cleanups,
17
+ queue_cleanup,
18
+ )
19
+ from secondshift.connections.models import AgentConnection, AgentCredentialCleanup
20
+ from secondshift.runner.agent_image.outcomes import AgentOutcome
21
+ from secondshift.runner.agent_protocol import AgentCheckInput
22
+ from secondshift.runner.client import get_runner_client
23
+ from secondshift.runner.protocol import RunnerProtocolError
24
+ from secondshift.workspaces.dao import is_owner
25
+ from secondshift.workspaces.models import Workspace
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class AgentConnectionError(ValueError):
31
+ pass
32
+
33
+
34
+ def validate_agent_auth(backend: str, value: str) -> str:
35
+ if backend == AgentBackend.CLAUDE_CODE:
36
+ if not value.startswith("sk-ant-oat") or any(c.isspace() for c in value):
37
+ raise AgentConnectionError(
38
+ "Use a Claude Code subscription token from claude setup-token."
39
+ )
40
+ return value
41
+ if backend != AgentBackend.CODEX:
42
+ raise AgentConnectionError("Choose Claude Code or Codex.")
43
+ try:
44
+ auth = json.loads(value)
45
+ tokens = auth["tokens"]
46
+ if not isinstance(tokens, dict) or not all(
47
+ isinstance(tokens.get(key), str) and tokens[key]
48
+ for key in ("access_token", "refresh_token", "id_token", "account_id")
49
+ ):
50
+ raise ValueError
51
+ except (ValueError, KeyError, TypeError):
52
+ raise AgentConnectionError(
53
+ "Use the auth.json from a Codex ChatGPT login."
54
+ ) from None
55
+ # Never import configuration, endpoints, API keys, hooks, or MCP servers.
56
+ return json.dumps(
57
+ {
58
+ "tokens": {
59
+ key: tokens[key]
60
+ for key in ("access_token", "refresh_token", "id_token", "account_id")
61
+ },
62
+ "last_refresh": auth.get("last_refresh"),
63
+ }
64
+ )
65
+
66
+
67
+ def save_agent_connection(
68
+ workspace: Workspace,
69
+ owner: User,
70
+ backend: str,
71
+ display_name: str,
72
+ auth: str,
73
+ connection: AgentConnection | None = None,
74
+ ) -> AgentConnection:
75
+ if not settings.AGENT_IMAGE or not settings.AGENT_BROKER_URL:
76
+ raise AgentConnectionError("CLI execution is not configured on this server.")
77
+ cleaned = validate_agent_auth(backend, auth.strip())
78
+ if connection is None:
79
+ connection = AgentConnection(workspace=workspace, owner=owner, backend=backend)
80
+ if connection.workspace_id != workspace.pk or connection.owner_id != owner.pk:
81
+ raise AgentConnectionError("Only the login's owner can reconnect it.")
82
+ if connection.backend != backend:
83
+ raise AgentConnectionError("The agent backend cannot change during reconnect.")
84
+ connection.display_name = display_name.strip() or product_name(backend)
85
+ connection.auth_data = cleaned
86
+ connection.credential_key = uuid.uuid4()
87
+ connection.connected = True
88
+ # A check described the old login.
89
+ connection.last_check = None
90
+ connection.full_clean()
91
+ connection.save()
92
+ return connection
93
+
94
+
95
+ def reconnect_agent_connection(
96
+ connection: AgentConnection,
97
+ owner: User,
98
+ backend: str,
99
+ display_name: str,
100
+ auth: str,
101
+ ) -> AgentConnection:
102
+ validate_agent_auth(backend, auth.strip())
103
+ if backend != connection.backend:
104
+ raise AgentConnectionError("The agent backend cannot change during reconnect.")
105
+ with transaction.atomic():
106
+ # Saving the new login gives it a new key, so runs stop using the old one.
107
+ cleanup = queue_cleanup(connection)
108
+ connection = save_agent_connection(
109
+ connection.workspace, owner, backend, display_name, auth, connection
110
+ )
111
+ remove_cached_credentials(cleanup)
112
+ return connection
113
+
114
+
115
+ def disconnect_agent_connection(connection: AgentConnection) -> bool:
116
+ """True when the runner's cached copy is gone too.
117
+
118
+ False means the runner did not answer. The worker removes the copy later.
119
+ """
120
+ with transaction.atomic():
121
+ connection.connected = False
122
+ connection.auth_data = ""
123
+ connection.save(update_fields=["connected", "auth_data", "updated_at"])
124
+ cleanup = queue_cleanup(connection)
125
+ return remove_cached_credentials(cleanup)
126
+
127
+
128
+ def remove_cached_credentials(cleanup: AgentCredentialCleanup) -> bool:
129
+ """Ask the runner to remove one cached login. True once it is gone."""
130
+ try:
131
+ response = get_runner_client().send(
132
+ "DELETE", f"/v1/agent-credentials/{cleanup.credential_key.hex}"
133
+ )
134
+ removed = response.status_code == 204
135
+ except RunnerProtocolError:
136
+ removed = False
137
+ if removed:
138
+ cleanup.delete()
139
+ return True
140
+ count_cleanup_attempt(cleanup)
141
+ logger.warning("the runner kept agent login %s; will retry", cleanup.pk)
142
+ return False
143
+
144
+
145
+ def retry_credential_cleanups() -> int:
146
+ """Remove the cached logins the runner kept earlier. Returns how many."""
147
+ return sum(remove_cached_credentials(cleanup) for cleanup in pending_cleanups(100))
148
+
149
+
150
+ # What a check's outcome means for the login. Only a finished check and a
151
+ # used-up allowance prove the sign-in works.
152
+ CHECK_RESULTS: dict[str, tuple[bool, str]] = {
153
+ AgentOutcome.COMPLETED: (True, "Signed in. {product} answered a test message."),
154
+ AgentOutcome.QUOTA_EXHAUSTED: (
155
+ True,
156
+ "Signed in, but the plan's usage allowance is used up for now.",
157
+ ),
158
+ AgentOutcome.RECONNECT_REQUIRED: (
159
+ False,
160
+ "{product} refused the sign-in. Get a new login and reconnect.",
161
+ ),
162
+ AgentOutcome.TIMED_OUT: (
163
+ False,
164
+ "{product} did not answer in time. Check again later.",
165
+ ),
166
+ }
167
+ CHECK_FAILED = (False, "{product} could not finish the check. Check again later.")
168
+
169
+
170
+ def product_name(backend: str) -> str:
171
+ return AgentBackend(backend).name.replace("_", " ").title()
172
+
173
+
174
+ def check_agent_connection(connection: AgentConnection) -> AgentConnection:
175
+ """Send the login one fixed test message through the runner and keep the result.
176
+
177
+ It uses a little of the plan's allowance. The runner returns only a reason,
178
+ never the CLI's text, so nothing from the provider is stored.
179
+ """
180
+ if not connection.connected:
181
+ raise AgentConnectionError("Reconnect this login before checking it.")
182
+ client = get_runner_client()
183
+ try:
184
+ check_input = AgentCheckInput(
185
+ backend=connection.backend,
186
+ credential_key=connection.credential_key.hex,
187
+ auth=client.encrypted_auth(connection.auth_data),
188
+ )
189
+ except ValidationError:
190
+ raise AgentConnectionError(
191
+ "This login is larger than the runner accepts. Sign in again."
192
+ ) from None
193
+ try:
194
+ result = client.check_agent(check_input)
195
+ except RunnerProtocolError:
196
+ raise AgentConnectionError(
197
+ "The runner could not check the login now. It may be running a job "
198
+ "with it. Try again in a moment."
199
+ ) from None
200
+ reason = result.reason
201
+ ok, message = CHECK_RESULTS.get(reason, CHECK_FAILED)
202
+ connection.last_check = {
203
+ "ok": ok,
204
+ "reason": reason if reason in CHECK_RESULTS else AgentOutcome.AGENT_FAILED,
205
+ "message": message.format(product=product_name(connection.backend)),
206
+ "checked_at": timezone.now().isoformat(),
207
+ }
208
+ connection.save(update_fields=["last_check", "updated_at"])
209
+ return connection
210
+
211
+
212
+ def sign_in_refused(connection: AgentConnection) -> bool:
213
+ """The last check proved the login no longer works."""
214
+ reason = (connection.last_check or {}).get("reason")
215
+ return reason == AgentOutcome.RECONNECT_REQUIRED
216
+
217
+
218
+ def usable_agent_connection(connection: AgentConnection, workspace_id: int) -> bool:
219
+ return bool(
220
+ connection.connected
221
+ and connection.workspace_id == workspace_id
222
+ and connection.backend in (AgentBackend.CLAUDE_CODE, AgentBackend.CODEX)
223
+ and is_owner(workspace_id, connection.owner_id)
224
+ )