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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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,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
|
+
)
|