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,71 @@
1
+ """Claude Code calls this before it starts a sub-agent, and it may refuse.
2
+
3
+ Claude Code runs it as a PreToolUse hook for its Agent tool, with the run's
4
+ limit and a counter file as arguments. It allows only the server-owned worker,
5
+ only from the run's own agent, in the foreground, up to the limit. Any error
6
+ exits with 2, which makes Claude Code refuse the call. Other exit codes would
7
+ let it through. The agent reads the event stream too, and stops the CLI if a
8
+ sub-agent runs that this should have refused.
9
+
10
+ Claude Code runs this file by path, so it uses only the standard library.
11
+ """
12
+
13
+ import fcntl
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ # The one sub-agent type a run may start. Its tools are the broker's only.
20
+ WORKER = "secondshift-worker"
21
+ # Claude Code's name for the tool in --tools, and in tool calls.
22
+ AGENT_TOOLS = ("Task", "Agent")
23
+
24
+
25
+ def refusal(event: dict[str, Any], limit: int, counter: Path) -> str:
26
+ """Why this start is refused, or an empty string to allow it."""
27
+ if event.get("agent_id"):
28
+ return "A sub-agent cannot start sub-agents. Do this yourself."
29
+ task = event.get("tool_input") or {}
30
+ if task.get("subagent_type") != WORKER:
31
+ return f"Start sub-agents with subagent_type {WORKER}."
32
+ # Claude Code runs a sub-agent in the background unless the call says not
33
+ # to, so only an explicit false keeps it in the foreground.
34
+ if task.get("run_in_background") is not False:
35
+ return (
36
+ "Start sub-agents with run_in_background set to false, and wait "
37
+ "for their report."
38
+ )
39
+ with counter.open("a+") as stream:
40
+ fcntl.flock(stream, fcntl.LOCK_EX)
41
+ stream.seek(0)
42
+ started = int(stream.read() or 0)
43
+ if started >= limit:
44
+ return (
45
+ f"This run has started the most sub-agents it may, {limit}. "
46
+ "Do the rest yourself."
47
+ )
48
+ stream.seek(0)
49
+ stream.truncate()
50
+ stream.write(str(started + 1))
51
+ return ""
52
+
53
+
54
+ def main() -> None:
55
+ try:
56
+ limit, counter = int(sys.argv[1]), Path(sys.argv[2])
57
+ reason = refusal(json.load(sys.stdin), limit, counter)
58
+ except Exception:
59
+ print("The sub-agent check failed.", file=sys.stderr)
60
+ sys.exit(2)
61
+ if reason:
62
+ decision = {
63
+ "hookEventName": "PreToolUse",
64
+ "permissionDecision": "deny",
65
+ "permissionDecisionReason": reason,
66
+ }
67
+ print(json.dumps({"hookSpecificOutput": decision}))
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,41 @@
1
+ """The control plane sends only fixed agent choices, never Docker options."""
2
+
3
+ from typing import Literal
4
+
5
+ from ninja import Schema
6
+ from pydantic import ConfigDict, Field, SecretStr
7
+
8
+ from secondshift.runner.agent_image.outcomes import AgentOutcome
9
+
10
+
11
+ class AgentInput(Schema):
12
+ model_config = ConfigDict(extra="forbid")
13
+ backend: Literal["claude_code", "codex"]
14
+ credential_key: str = Field(pattern=r"^[0-9a-f]{32}$")
15
+ auth: SecretStr = Field(max_length=64000, exclude=True)
16
+ broker_token: SecretStr = Field(max_length=200, exclude=True)
17
+ prompt: str = Field(max_length=250000)
18
+ max_turns: int = Field(ge=1, le=10000)
19
+ token_budget: int = Field(ge=1)
20
+ timeout_seconds: int = Field(ge=1, le=86400)
21
+ # The most sub-agents the agent may start. Only Claude Code can so far.
22
+ subagents: int = Field(default=0, ge=0, le=100)
23
+
24
+
25
+ class AgentCheckInput(Schema):
26
+ """A login check. The runner owns the prompt and every limit."""
27
+
28
+ model_config = ConfigDict(extra="forbid")
29
+ backend: Literal["claude_code", "codex"]
30
+ credential_key: str = Field(pattern=r"^[0-9a-f]{32}$")
31
+ auth: SecretStr = Field(max_length=64000, exclude=True)
32
+
33
+
34
+ class AgentOutput(Schema):
35
+ report: str = ""
36
+ succeeded: bool = False
37
+ reason: str = AgentOutcome.AGENT_FAILED
38
+ usage: dict[str, int] = Field(default_factory=dict)
39
+ # Each sub-agent's model requests and tool calls, in the order they started.
40
+ subagents: list[dict[str, int]] = Field(default_factory=list, max_length=100)
41
+ versions: dict[str, str] = Field(default_factory=dict)
@@ -0,0 +1,254 @@
1
+ """Server-owned agent containers. Repository command sessions stay offline."""
2
+
3
+ import fcntl
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+
14
+ from django.conf import settings
15
+
16
+ from secondshift.config.constants import (
17
+ HOST_ALIAS,
18
+ AgentBackend,
19
+ )
20
+ from secondshift.runner.agent_credentials import decrypt_agent_auth
21
+ from secondshift.runner.agent_image.outcomes import (
22
+ AUTH_FILES,
23
+ AgentConfig,
24
+ AgentOutcome,
25
+ )
26
+ from secondshift.runner.agent_protocol import AgentCheckInput, AgentInput, AgentOutput
27
+ from secondshift.runner.dao import is_session_active
28
+ from secondshift.runner.images import resolve_image_id
29
+ from secondshift.runner.protocol import CommandRequest, RunnerProtocolError
30
+ from secondshift.runner.services import DockerRunnerService, agent_name, sandbox_flags
31
+
32
+ # A login check asks for one short answer, with no tools.
33
+ CHECK_PROMPT = "Reply with the single word OK."
34
+ CHECK_TOKEN_BUDGET = 50_000
35
+ CHECK_TIMEOUT_SECONDS = 20
36
+
37
+
38
+ def require_agent_configuration() -> str:
39
+ """The exact image to run. Only a local runner may resolve a tag."""
40
+ if not settings.AGENT_IMAGE or not settings.AGENT_BROKER_URL:
41
+ raise RunnerProtocolError(
42
+ "CLI agent execution is not configured on the runner."
43
+ )
44
+ image = settings.AGENT_IMAGE
45
+ if not re.fullmatch(r"(?:[^@\s]+@)?sha256:[0-9a-f]{64}", image):
46
+ if not settings.AGENT_IMAGE_TAGS_ALLOWED:
47
+ raise RunnerProtocolError("The agent image must be pinned by digest.")
48
+ try:
49
+ image = resolve_image_id(image)
50
+ except (subprocess.SubprocessError, OSError, ValueError) as error:
51
+ raise RunnerProtocolError(
52
+ "The agent image isn't built. Restart SecondShift to build it."
53
+ ) from error
54
+ DockerRunnerService().verify_runtime()
55
+ return image
56
+
57
+
58
+ def agent_command(session_id: str, auth_path: Path, image: str) -> list[str]:
59
+ command = [
60
+ "docker",
61
+ "run",
62
+ "--rm",
63
+ "--interactive",
64
+ "--name",
65
+ agent_name(session_id),
66
+ *sandbox_flags(),
67
+ ]
68
+ if settings.AGENT_RESOLV_CONF:
69
+ resolver = Path(settings.AGENT_RESOLV_CONF)
70
+ if (
71
+ not resolver.is_absolute()
72
+ or not resolver.is_file()
73
+ or resolver.is_symlink()
74
+ ):
75
+ raise RunnerProtocolError(
76
+ "The agent resolver must be an existing absolute file."
77
+ )
78
+ command += [f"--mount=type=bind,src={resolver},dst=/etc/resolv.conf,readonly"]
79
+ uid, gid = settings.RUNNER_SANDBOX_UID, settings.RUNNER_SANDBOX_GID
80
+ return [
81
+ *command,
82
+ f"--network={settings.AGENT_NETWORK}",
83
+ # Docker Desktop defines this alias. Native Linux Docker needs it added.
84
+ f"--add-host={HOST_ALIAS}:host-gateway",
85
+ f"--tmpfs=/tmp:rw,nosuid,size=256m,uid={uid},gid={gid}",
86
+ f"--mount=type=bind,src={auth_path},dst=/auth",
87
+ "--workdir=/tmp",
88
+ "--env=HOME=/tmp",
89
+ "--env=CODEX_HOME=/auth",
90
+ "--env=CLAUDE_CONFIG_DIR=/tmp/claude",
91
+ "--env=DISABLE_AUTOUPDATER=1",
92
+ f"--label=secondshift.agent-credential={auth_path.name}",
93
+ "--log-driver=none",
94
+ "--entrypoint=python3",
95
+ image,
96
+ "-m",
97
+ "secondshift.runner.agent_image.agent",
98
+ ]
99
+
100
+
101
+ def provision_auth(request: AgentInput | AgentCheckInput) -> Path:
102
+ root = settings.AGENT_AUTH_ROOT.resolve()
103
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
104
+ path = root / request.credential_key
105
+ # Keys are random, server-issued, immutable per reconnect. No paths from repos.
106
+ if path.exists():
107
+ if path.is_symlink() or not path.is_dir():
108
+ raise RunnerProtocolError("Invalid agent credential directory.")
109
+ return path
110
+ auth = decrypt_agent_auth(request.auth.get_secret_value())
111
+ path.mkdir(mode=0o700)
112
+ target = path / AUTH_FILES[request.backend]
113
+ target.write_text(auth)
114
+ target.chmod(0o600)
115
+ uid, gid = settings.RUNNER_SANDBOX_UID, settings.RUNNER_SANDBOX_GID
116
+ if os.geteuid() == 0:
117
+ os.chown(target, uid, gid)
118
+ os.chown(path, uid, gid)
119
+ return path
120
+
121
+
122
+ @contextmanager
123
+ def credential_lock(key: str) -> Iterator[None]:
124
+ root = settings.AGENT_AUTH_ROOT.resolve()
125
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
126
+ with (root / f"{key}.lock").open("a") as stream:
127
+ try:
128
+ fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
129
+ except BlockingIOError:
130
+ raise RunnerProtocolError(
131
+ "This agent login is already running another job."
132
+ ) from None
133
+ try:
134
+ yield
135
+ finally:
136
+ fcntl.flock(stream, fcntl.LOCK_UN)
137
+
138
+
139
+ def execute_agent(session_id: str, request: AgentInput) -> AgentOutput:
140
+ with credential_lock(request.credential_key):
141
+ return execute_locked_agent(session_id, request)
142
+
143
+
144
+ def execute_locked_agent(session_id: str, request: AgentInput) -> AgentOutput:
145
+ image = require_agent_configuration()
146
+ if not is_session_active(session_id):
147
+ raise RunnerProtocolError("Unknown or inactive runner session.")
148
+ # Reserve once, before starting the process: an ambiguous outcome is not retried.
149
+ reservation = CommandRequest(
150
+ call_id="agent-execution", argv=(request.backend, request.credential_key)
151
+ )
152
+ service = DockerRunnerService()
153
+ if service.reserve_command(session_id, reservation) is not None:
154
+ raise RunnerProtocolError("This agent execution cannot be repeated.")
155
+ auth_path = provision_auth(request)
156
+ configuration: AgentConfig = {
157
+ "backend": request.backend,
158
+ "broker_url": settings.AGENT_BROKER_URL,
159
+ "broker_token": request.broker_token.get_secret_value(),
160
+ "prompt": request.prompt,
161
+ "max_turns": request.max_turns,
162
+ "token_budget": request.token_budget,
163
+ "subagents": request.subagents
164
+ if AgentBackend(request.backend).starts_subagents
165
+ else 0,
166
+ }
167
+ return run_agent_container(
168
+ agent_name(session_id),
169
+ agent_command(session_id, auth_path, image),
170
+ configuration,
171
+ request.timeout_seconds,
172
+ )
173
+
174
+
175
+ def check_agent(request: AgentCheckInput) -> AgentOutput:
176
+ """Run the login's CLI once with a fixed prompt, no tools and no repository."""
177
+ with credential_lock(request.credential_key):
178
+ image = require_agent_configuration()
179
+ auth_path = provision_auth(request)
180
+ name = f"check-{request.credential_key}"
181
+ configuration: AgentConfig = {
182
+ "backend": request.backend,
183
+ "prompt": CHECK_PROMPT,
184
+ "max_turns": 1,
185
+ "token_budget": CHECK_TOKEN_BUDGET,
186
+ }
187
+ return run_agent_container(
188
+ agent_name(name),
189
+ agent_command(name, auth_path, image),
190
+ configuration,
191
+ CHECK_TIMEOUT_SECONDS,
192
+ )
193
+
194
+
195
+ def run_agent_container(
196
+ name: str, command: list[str], configuration: AgentConfig, timeout: int
197
+ ) -> AgentOutput:
198
+ # Auth and the broker token never appear in argv, Docker env, logs, or DBOS.
199
+ with tempfile.TemporaryFile() as output:
200
+ try:
201
+ completed = subprocess.run(
202
+ command,
203
+ input=json.dumps(configuration).encode(),
204
+ stdout=output,
205
+ stderr=subprocess.DEVNULL,
206
+ timeout=timeout,
207
+ check=False,
208
+ )
209
+ output.seek(0)
210
+ data = output.read(settings.RUNNER_COMMAND_OUTPUT_MAX_BYTES + 1)
211
+ if completed.returncode:
212
+ return AgentOutput(reason=AgentOutcome.AGENT_STOPPED)
213
+ if len(data) > settings.RUNNER_COMMAND_OUTPUT_MAX_BYTES:
214
+ return AgentOutput(reason=AgentOutcome.AGENT_FAILED)
215
+ try:
216
+ return AgentOutput.model_validate_json(data)
217
+ except ValueError:
218
+ return AgentOutput(reason=AgentOutcome.INVALID_AGENT_OUTPUT)
219
+ except subprocess.TimeoutExpired:
220
+ return AgentOutput(reason=AgentOutcome.TIMED_OUT)
221
+ finally:
222
+ DockerRunnerService().remove_container(name)
223
+
224
+
225
+ def remove_agent_credentials(key: str) -> None:
226
+ """Stop consumers before removing a disconnected login's CLI cache."""
227
+ if not re.fullmatch(r"[0-9a-f]{32}", key):
228
+ raise RunnerProtocolError("Invalid credential key.")
229
+ service = DockerRunnerService()
230
+ listed = service.run_command(
231
+ [
232
+ "docker",
233
+ "ps",
234
+ "--all",
235
+ "--quiet",
236
+ "--filter",
237
+ f"label=secondshift.agent-credential={key}",
238
+ ],
239
+ timeout=15,
240
+ )
241
+ if listed.returncode:
242
+ raise RunnerProtocolError("The runner could not revoke agent credentials.")
243
+ for container in listed.stdout.splitlines():
244
+ if not re.fullmatch(r"[0-9a-f]{12,64}", container):
245
+ raise RunnerProtocolError(
246
+ "Docker returned an invalid container identifier."
247
+ )
248
+ service.remove_container(container)
249
+ with credential_lock(key):
250
+ path = settings.AGENT_AUTH_ROOT.resolve() / key
251
+ if path.is_symlink():
252
+ raise RunnerProtocolError("Invalid agent credential directory.")
253
+ if path.exists():
254
+ shutil.rmtree(path)
@@ -0,0 +1,123 @@
1
+ import hmac
2
+
3
+ from django.conf import settings
4
+ from django.http import FileResponse, HttpRequest
5
+ from ninja import NinjaAPI, Status
6
+ from ninja.security import HttpBearer
7
+
8
+ from secondshift.runner.agent_credentials import provisioning_public_key
9
+ from secondshift.runner.agent_protocol import AgentCheckInput, AgentInput, AgentOutput
10
+ from secondshift.runner.agents import (
11
+ check_agent,
12
+ execute_agent,
13
+ remove_agent_credentials,
14
+ )
15
+ from secondshift.runner.dao import has_sessions
16
+ from secondshift.runner.protocol import (
17
+ CommandRequest,
18
+ RunnerProtocolError,
19
+ RunnerSessionSpec,
20
+ )
21
+ from secondshift.runner.schemas import (
22
+ CommandInput,
23
+ CommandOutput,
24
+ SessionInput,
25
+ SessionOutput,
26
+ )
27
+ from secondshift.runner.services import get_runner_service
28
+ from secondshift.runner.sources import has_source, receive_source
29
+
30
+
31
+ class RunnerBearer(HttpBearer):
32
+ def authenticate(self, request: HttpRequest, token: str) -> str | None:
33
+ if hmac.compare_digest(token, settings.RUNNER_SERVICE_TOKEN):
34
+ return token
35
+ return None
36
+
37
+
38
+ api = NinjaAPI(
39
+ title="SecondShift Runner API",
40
+ version="1.0",
41
+ auth=RunnerBearer(),
42
+ urls_namespace="runner",
43
+ docs_url=None,
44
+ openapi_url=None,
45
+ )
46
+
47
+
48
+ @api.exception_handler(RunnerProtocolError)
49
+ def protocol_error(request: HttpRequest, error: RunnerProtocolError):
50
+ return api.create_response(request, {"error": str(error)}, status=409)
51
+
52
+
53
+ @api.exception_handler(ValueError)
54
+ def invalid_protocol_input(request: HttpRequest, error: ValueError):
55
+ return api.create_response(request, {"error": str(error)}, status=422)
56
+
57
+
58
+ @api.get("/-/health/", auth=None)
59
+ def health(request: HttpRequest) -> dict[str, bool]:
60
+ has_sessions()
61
+ return {"ok": True}
62
+
63
+
64
+ @api.post("/v1/sessions", response={201: SessionOutput})
65
+ def start_session(request: HttpRequest, payload: SessionInput):
66
+ spec = RunnerSessionSpec(**payload.model_dump())
67
+ session_id = get_runner_service().start(spec)
68
+ return Status(201, {"session_id": session_id})
69
+
70
+
71
+ @api.post("/v1/sessions/{session_id}/commands", response=CommandOutput)
72
+ def execute_command(request: HttpRequest, session_id: str, payload: CommandInput):
73
+ command = CommandRequest(**{**payload.model_dump(), "argv": tuple(payload.argv)})
74
+ return get_runner_service().execute(session_id, command)
75
+
76
+
77
+ @api.post("/v1/sessions/{session_id}/collection")
78
+ def collect_session(request: HttpRequest, session_id: str) -> FileResponse:
79
+ """Stop the sandbox and return its changes and saved files as one archive."""
80
+ archive = get_runner_service().collect(session_id)
81
+ return FileResponse(archive.open("rb"), content_type="application/gzip")
82
+
83
+
84
+ @api.delete("/v1/sessions/{session_id}", response={204: None})
85
+ def stop_session(request: HttpRequest, session_id: str):
86
+ get_runner_service().stop(session_id)
87
+ return Status(204, None)
88
+
89
+
90
+ @api.api_operation(["HEAD"], "/v1/sources/{sha256}", response={204: None, 404: None})
91
+ def source_exists(request: HttpRequest, sha256: str) -> Status[None]:
92
+ return Status(204 if has_source(sha256) else 404, None)
93
+
94
+
95
+ @api.put("/v1/sources/{sha256}", response={204: None})
96
+ def upload_source(request: HttpRequest, sha256: str) -> Status[None]:
97
+ # Read the body as a stream, so a snapshot never sits in memory whole.
98
+ length = request.headers.get("Content-Length")
99
+ receive_source(sha256, request, int(length) if length else None)
100
+ return Status(204, None)
101
+
102
+
103
+ @api.post("/v1/sessions/{session_id}/agent", response=AgentOutput)
104
+ def run_agent(
105
+ request: HttpRequest, session_id: str, payload: AgentInput
106
+ ) -> AgentOutput:
107
+ return execute_agent(session_id, payload)
108
+
109
+
110
+ @api.post("/v1/agent-checks", response=AgentOutput)
111
+ def run_agent_check(request: HttpRequest, payload: AgentCheckInput) -> AgentOutput:
112
+ return check_agent(payload)
113
+
114
+
115
+ @api.delete("/v1/agent-credentials/{key}", response={204: None})
116
+ def revoke_agent_credentials(request: HttpRequest, key: str) -> Status[None]:
117
+ remove_agent_credentials(key)
118
+ return Status(204, None)
119
+
120
+
121
+ @api.get("/v1/agent-provisioning-key", response=dict[str, str])
122
+ def agent_provisioning_key(request: HttpRequest) -> dict[str, str]:
123
+ return {"public_key": provisioning_public_key()}
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class RunnerConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "secondshift.runner"
@@ -0,0 +1,168 @@
1
+ from dataclasses import asdict
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from django.conf import settings
7
+
8
+ from secondshift.runner.agent_credentials import encrypt_agent_auth
9
+ from secondshift.runner.agent_protocol import AgentCheckInput, AgentInput, AgentOutput
10
+ from secondshift.runner.protocol import (
11
+ CommandRequest,
12
+ CommandResult,
13
+ RunnerProtocolError,
14
+ RunnerSessionSpec,
15
+ )
16
+
17
+
18
+ class HttpRunnerClient:
19
+ """Trusted control-plane client for a separately deployed runner."""
20
+
21
+ def __init__(
22
+ self,
23
+ base_url: str,
24
+ auth_token: str,
25
+ timeout: int = 30,
26
+ transport: httpx.BaseTransport | None = None,
27
+ ) -> None:
28
+ if not base_url.strip() or not auth_token:
29
+ raise ValueError("Runner URL and authentication token are required.")
30
+ self.base_url = base_url.rstrip("/")
31
+ self.timeout = timeout
32
+ self.session = httpx.Client(
33
+ base_url=self.base_url,
34
+ trust_env=False,
35
+ transport=transport,
36
+ headers={
37
+ "Authorization": f"Bearer {auth_token}",
38
+ "Accept": "application/json",
39
+ },
40
+ )
41
+
42
+ def send(
43
+ self, method: str, path: str, timeout: float | None = None, **kwargs: Any
44
+ ) -> httpx.Response:
45
+ """One call to the runner. Transport errors become RunnerProtocolError."""
46
+ try:
47
+ return self.session.request(
48
+ method, path, timeout=timeout or self.timeout, **kwargs
49
+ )
50
+ except httpx.TimeoutException as error:
51
+ raise RunnerProtocolError("The runner did not answer in time.") from error
52
+ except httpx.HTTPError as error:
53
+ raise RunnerProtocolError(
54
+ "The runner service could not be reached."
55
+ ) from error
56
+
57
+ def upload_source(self, sha256: str, path: Path) -> None:
58
+ """Send a run's snapshot as a raw stream, unless the runner has it."""
59
+ if self.send("HEAD", f"/v1/sources/{sha256}").status_code == 204:
60
+ return
61
+ with path.open("rb") as stream:
62
+ response = self.send(
63
+ "PUT",
64
+ f"/v1/sources/{sha256}",
65
+ timeout=180,
66
+ content=stream,
67
+ # Django reads a body only up to its Content-Length.
68
+ headers={
69
+ "Content-Type": "application/gzip",
70
+ "Content-Length": str(path.stat().st_size),
71
+ },
72
+ )
73
+ if response.status_code != 204:
74
+ self.response_payload(response)
75
+
76
+ def start(self, spec: RunnerSessionSpec) -> str:
77
+ response = self.send("POST", "/v1/sessions", json=asdict(spec))
78
+ payload = self.response_payload(response)
79
+ return str(payload["session_id"])
80
+
81
+ def execute(self, session_id: str, request: CommandRequest) -> CommandResult:
82
+ response = self.send(
83
+ "POST",
84
+ f"/v1/sessions/{session_id}/commands",
85
+ timeout=max(self.timeout, request.timeout_seconds + 5),
86
+ json=asdict(request),
87
+ )
88
+ return CommandResult(**self.response_payload(response))
89
+
90
+ def collect(self, session_id: str, destination: Path) -> None:
91
+ """Stop the sandbox and save its archive of changes and files."""
92
+ try:
93
+ with self.session.stream(
94
+ "POST", f"/v1/sessions/{session_id}/collection", timeout=360
95
+ ) as response:
96
+ if response.status_code != 200:
97
+ response.read()
98
+ self.response_payload(response)
99
+ raise RunnerProtocolError("Runner returned an invalid response.")
100
+ with destination.open("wb") as output:
101
+ for chunk in response.iter_bytes():
102
+ output.write(chunk)
103
+ except httpx.TimeoutException as error:
104
+ raise RunnerProtocolError("The runner did not answer in time.") from error
105
+ except httpx.HTTPError as error:
106
+ raise RunnerProtocolError(
107
+ "The runner service could not be reached."
108
+ ) from error
109
+
110
+ def encrypted_auth(self, auth: str) -> str:
111
+ """An agent login, encrypted so that only the runner can read it."""
112
+ key = self.response_payload(self.send("GET", "/v1/agent-provisioning-key"))
113
+ return encrypt_agent_auth(str(key["public_key"]), auth)
114
+
115
+ def run_agent(self, session_id: str, request: AgentInput) -> AgentOutput:
116
+ response = self.send(
117
+ "POST",
118
+ f"/v1/sessions/{session_id}/agent",
119
+ timeout=request.timeout_seconds + 30,
120
+ json=request.model_dump(mode="json")
121
+ | {
122
+ "auth": request.auth.get_secret_value(),
123
+ "broker_token": request.broker_token.get_secret_value(),
124
+ },
125
+ )
126
+ return AgentOutput.model_validate(self.response_payload(response))
127
+
128
+ def check_agent(self, request: AgentCheckInput) -> AgentOutput:
129
+ response = self.send(
130
+ "POST",
131
+ "/v1/agent-checks",
132
+ # The runner stops the CLI after its own limit. This covers startup.
133
+ timeout=28,
134
+ json=request.model_dump(mode="json")
135
+ | {"auth": request.auth.get_secret_value()},
136
+ )
137
+ return AgentOutput.model_validate(self.response_payload(response))
138
+
139
+ def stop(self, session_id: str) -> None:
140
+ response = self.send("DELETE", f"/v1/sessions/{session_id}")
141
+ if response.status_code != 204:
142
+ self.response_payload(response)
143
+
144
+ @staticmethod
145
+ def response_payload(response: httpx.Response) -> dict[str, object]:
146
+ try:
147
+ payload = response.json()
148
+ except ValueError as error:
149
+ raise RunnerProtocolError(
150
+ f"Runner answered HTTP {response.status_code}."
151
+ ) from error
152
+ if response.is_error:
153
+ message = payload.get("error") if isinstance(payload, dict) else None
154
+ raise RunnerProtocolError(
155
+ str(message)
156
+ if message
157
+ else f"Runner answered HTTP {response.status_code}."
158
+ )
159
+ if not isinstance(payload, dict):
160
+ raise RunnerProtocolError("Runner returned an invalid response.")
161
+ return payload
162
+
163
+
164
+ def get_runner_client() -> HttpRunnerClient:
165
+ return HttpRunnerClient(
166
+ base_url=settings.RUNNER_SERVICE_URL,
167
+ auth_token=settings.RUNNER_SERVICE_TOKEN,
168
+ )