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,97 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RunnerProtocolError(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
REPOSITORY_NAME = re.compile(r"^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)+$")
|
|
13
|
+
COMMIT_SHA = re.compile(r"^[0-9a-f]{40,64}$")
|
|
14
|
+
SESSION_KEY = re.compile(r"^[0-9a-f]{32}$")
|
|
15
|
+
SOURCE_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RunnerSessionSpec:
|
|
20
|
+
run_id: int
|
|
21
|
+
session_key: str
|
|
22
|
+
repository: str
|
|
23
|
+
commit_sha: str
|
|
24
|
+
image: str
|
|
25
|
+
# The uploaded snapshot of the commit's files the session unpacks.
|
|
26
|
+
source_sha256: str
|
|
27
|
+
# Record the unpacked files, so the agent's changes can be collected.
|
|
28
|
+
# The control plane sets it from the run's plan, never from repository input.
|
|
29
|
+
collect_changes: bool = False
|
|
30
|
+
# An uploaded archive of earlier runs' saved files, mounted read-only.
|
|
31
|
+
# Empty when the run has none.
|
|
32
|
+
history_sha256: str = ""
|
|
33
|
+
# An uploaded archive of a skill from another repository, mounted
|
|
34
|
+
# read-only. Empty when the skill is in the run's repository or there is
|
|
35
|
+
# none. The control plane builds it, so the skill's repository never
|
|
36
|
+
# chooses where it goes or how it is mounted.
|
|
37
|
+
skill_sha256: str = ""
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.run_id < 1:
|
|
41
|
+
raise ValueError("run_id must be positive")
|
|
42
|
+
if not SESSION_KEY.fullmatch(self.session_key):
|
|
43
|
+
raise ValueError("session_key must be 32 lowercase hexadecimal characters")
|
|
44
|
+
if not REPOSITORY_NAME.fullmatch(self.repository):
|
|
45
|
+
raise ValueError("repository must be a normalized owner/name path")
|
|
46
|
+
if any(part in {".", ".."} for part in self.repository.split("/")):
|
|
47
|
+
raise ValueError("repository must be a normalized owner/name path")
|
|
48
|
+
if not COMMIT_SHA.fullmatch(self.commit_sha):
|
|
49
|
+
raise ValueError("commit_sha must be a full lowercase hexadecimal SHA")
|
|
50
|
+
if not self.image.strip():
|
|
51
|
+
raise ValueError("a server-owned image is required")
|
|
52
|
+
if not SOURCE_SHA256.fullmatch(self.source_sha256):
|
|
53
|
+
raise ValueError("source_sha256 must be a lowercase SHA-256")
|
|
54
|
+
if self.history_sha256 and not SOURCE_SHA256.fullmatch(self.history_sha256):
|
|
55
|
+
raise ValueError("history_sha256 must be empty or a lowercase SHA-256")
|
|
56
|
+
if self.skill_sha256 and not SOURCE_SHA256.fullmatch(self.skill_sha256):
|
|
57
|
+
raise ValueError("skill_sha256 must be empty or a lowercase SHA-256")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def session_id(self) -> str:
|
|
61
|
+
# The key is random per run, so a reset or restored database that
|
|
62
|
+
# reuses a run ID never matches an older session.
|
|
63
|
+
return f"run-{self.run_id}-{self.session_key}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class CommandRequest:
|
|
68
|
+
call_id: str
|
|
69
|
+
argv: tuple[str, ...]
|
|
70
|
+
cwd: str = "."
|
|
71
|
+
timeout_seconds: int = 120
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not self.call_id.strip():
|
|
75
|
+
raise ValueError("call_id is required")
|
|
76
|
+
if not self.argv or any(
|
|
77
|
+
not argument or "\0" in argument for argument in self.argv
|
|
78
|
+
):
|
|
79
|
+
raise ValueError("argv must contain non-empty arguments")
|
|
80
|
+
path = PurePosixPath(self.cwd)
|
|
81
|
+
if path.is_absolute() or ".." in path.parts:
|
|
82
|
+
raise ValueError("cwd must stay inside the workspace")
|
|
83
|
+
if not 1 <= self.timeout_seconds <= 900:
|
|
84
|
+
raise ValueError("timeout_seconds must be between 1 and 900")
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def fingerprint(self) -> str:
|
|
88
|
+
encoded = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
|
|
89
|
+
return hashlib.sha256(encoded.encode()).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class CommandResult:
|
|
94
|
+
call_id: str
|
|
95
|
+
exit_code: int | None
|
|
96
|
+
output: str
|
|
97
|
+
timed_out: bool = False
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from ninja import Schema
|
|
2
|
+
from pydantic import ConfigDict, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StrictSchema(Schema):
|
|
6
|
+
model_config = ConfigDict(extra="forbid")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionInput(StrictSchema):
|
|
10
|
+
run_id: int = Field(gt=0)
|
|
11
|
+
session_key: str = Field(min_length=32, max_length=32)
|
|
12
|
+
repository: str = Field(min_length=3, max_length=300)
|
|
13
|
+
commit_sha: str = Field(min_length=40, max_length=64)
|
|
14
|
+
image: str = Field(min_length=1, max_length=300)
|
|
15
|
+
source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
16
|
+
collect_changes: bool = False
|
|
17
|
+
history_sha256: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
|
|
18
|
+
skill_sha256: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SessionOutput(Schema):
|
|
22
|
+
session_id: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommandInput(StrictSchema):
|
|
26
|
+
call_id: str = Field(min_length=1, max_length=200)
|
|
27
|
+
argv: list[str] = Field(min_length=1, max_length=256)
|
|
28
|
+
cwd: str = Field(default=".", max_length=1000)
|
|
29
|
+
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CommandOutput(Schema):
|
|
33
|
+
call_id: str
|
|
34
|
+
exit_code: int | None
|
|
35
|
+
output: str
|
|
36
|
+
timed_out: bool
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.db import IntegrityError, transaction
|
|
10
|
+
|
|
11
|
+
from secondshift.config.constants import (
|
|
12
|
+
SANDBOX_HISTORY_DIR,
|
|
13
|
+
SANDBOX_OUTPUTS_DIR,
|
|
14
|
+
SANDBOX_SKILL_DIR,
|
|
15
|
+
SandboxRuntime,
|
|
16
|
+
)
|
|
17
|
+
from secondshift.runner.collection import OUTPUTS_DIR, collect, record_baseline
|
|
18
|
+
from secondshift.runner.dao import (
|
|
19
|
+
complete_command,
|
|
20
|
+
create_command,
|
|
21
|
+
create_session,
|
|
22
|
+
get_session,
|
|
23
|
+
lock_session,
|
|
24
|
+
session_command,
|
|
25
|
+
set_session_status,
|
|
26
|
+
)
|
|
27
|
+
from secondshift.runner.models import RunnerCommand, RunnerSession
|
|
28
|
+
from secondshift.runner.protocol import (
|
|
29
|
+
CommandRequest,
|
|
30
|
+
CommandResult,
|
|
31
|
+
RunnerProtocolError,
|
|
32
|
+
RunnerSessionSpec,
|
|
33
|
+
)
|
|
34
|
+
from secondshift.runner.sources import sweep_sources, unpack_source
|
|
35
|
+
|
|
36
|
+
TRUNCATION_MARKER = b"\n[... command output truncated by SecondShift ...]\n"
|
|
37
|
+
# Beside the workspace: the files earlier runs of the job saved.
|
|
38
|
+
HISTORY_DIR = "history"
|
|
39
|
+
# Beside the workspace: a skill from another repository.
|
|
40
|
+
SKILL_DIR = "skill"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sandbox_name(session_id: str) -> str:
|
|
44
|
+
return f"secondshift-{session_id}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def agent_name(session_id: str) -> str:
|
|
48
|
+
return f"secondshift-agent-{session_id}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sandbox_flags() -> list[str]:
|
|
52
|
+
"""The runtime, hardening, limits and user every sandbox container gets.
|
|
53
|
+
|
|
54
|
+
Callers add only their own mounts, network and temporary files.
|
|
55
|
+
"""
|
|
56
|
+
flags = []
|
|
57
|
+
if settings.RUNNER_RUNTIME != SandboxRuntime.DOCKER:
|
|
58
|
+
flags += ["--runtime", settings.RUNNER_RUNTIME]
|
|
59
|
+
return [
|
|
60
|
+
*flags,
|
|
61
|
+
"--read-only",
|
|
62
|
+
"--cap-drop=ALL",
|
|
63
|
+
"--security-opt=no-new-privileges:true",
|
|
64
|
+
f"--pids-limit={settings.RUNNER_PIDS_LIMIT}",
|
|
65
|
+
f"--memory={settings.RUNNER_MEMORY}",
|
|
66
|
+
# Docker otherwise lets the sandbox swap as much again as its memory.
|
|
67
|
+
f"--memory-swap={settings.RUNNER_MEMORY}",
|
|
68
|
+
f"--cpus={settings.RUNNER_CPUS}",
|
|
69
|
+
f"--user={settings.RUNNER_SANDBOX_UID}:{settings.RUNNER_SANDBOX_GID}",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RunnerConfigurationError(RuntimeError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DockerRunnerService:
|
|
78
|
+
"""Container operations and ORM-backed idempotency for the runner service."""
|
|
79
|
+
|
|
80
|
+
def verify_configuration(self) -> None:
|
|
81
|
+
settings.RUNNER_WORK_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
82
|
+
sweep_sources()
|
|
83
|
+
self.verify_runtime()
|
|
84
|
+
self.ensure_image()
|
|
85
|
+
|
|
86
|
+
def ensure_image(self) -> None:
|
|
87
|
+
"""Pull the sandbox image at startup so the first run doesn't wait on it."""
|
|
88
|
+
image = settings.RUNNER_IMAGE
|
|
89
|
+
if not self.run_command(
|
|
90
|
+
["docker", "image", "inspect", image], timeout=15
|
|
91
|
+
).returncode:
|
|
92
|
+
return
|
|
93
|
+
pulled = self.run_command(["docker", "pull", image], timeout=600)
|
|
94
|
+
if pulled.returncode:
|
|
95
|
+
detail = (pulled.stderr or pulled.stdout).strip().splitlines()
|
|
96
|
+
reason = detail[-1] if detail else "no output"
|
|
97
|
+
raise RunnerConfigurationError(
|
|
98
|
+
f"Couldn't pull the sandbox image {image}: {reason}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def verify_runtime(self) -> None:
|
|
102
|
+
completed = self.run_command(
|
|
103
|
+
["docker", "info", "--format", "{{json .Runtimes}}"], timeout=15
|
|
104
|
+
)
|
|
105
|
+
if completed.returncode:
|
|
106
|
+
raise RunnerConfigurationError("Docker is unavailable to the runner.")
|
|
107
|
+
if settings.RUNNER_RUNTIME == SandboxRuntime.DOCKER:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
runtimes = json.loads(completed.stdout)
|
|
111
|
+
except (TypeError, ValueError) as error:
|
|
112
|
+
raise RunnerConfigurationError(
|
|
113
|
+
"Docker returned an invalid runtime list."
|
|
114
|
+
) from error
|
|
115
|
+
if settings.RUNNER_RUNTIME not in runtimes:
|
|
116
|
+
raise RunnerConfigurationError(
|
|
117
|
+
f"Docker runtime {settings.RUNNER_RUNTIME} is not registered."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def start(self, spec: RunnerSessionSpec) -> str:
|
|
121
|
+
if spec.image != settings.RUNNER_IMAGE:
|
|
122
|
+
raise RunnerProtocolError(
|
|
123
|
+
"The requested image is not enabled by this runner."
|
|
124
|
+
)
|
|
125
|
+
session_id = spec.session_id
|
|
126
|
+
container_name = sandbox_name(session_id)
|
|
127
|
+
session_root = (settings.RUNNER_WORK_ROOT / "sessions" / session_id).resolve()
|
|
128
|
+
self.require_owned_path(session_root)
|
|
129
|
+
workspace = session_root / "workspace"
|
|
130
|
+
|
|
131
|
+
with transaction.atomic():
|
|
132
|
+
session = lock_session(session_id)
|
|
133
|
+
if session is None:
|
|
134
|
+
try:
|
|
135
|
+
session = create_session(
|
|
136
|
+
session_id=session_id,
|
|
137
|
+
run_id=spec.run_id,
|
|
138
|
+
repository=spec.repository,
|
|
139
|
+
commit_sha=spec.commit_sha,
|
|
140
|
+
image=spec.image,
|
|
141
|
+
source_sha256=spec.source_sha256,
|
|
142
|
+
collect_changes=spec.collect_changes,
|
|
143
|
+
history_sha256=spec.history_sha256,
|
|
144
|
+
skill_sha256=spec.skill_sha256,
|
|
145
|
+
container_name=container_name,
|
|
146
|
+
workspace_path=str(workspace),
|
|
147
|
+
)
|
|
148
|
+
except IntegrityError as error:
|
|
149
|
+
raise RunnerProtocolError(
|
|
150
|
+
"The run already has a runner session."
|
|
151
|
+
) from error
|
|
152
|
+
else:
|
|
153
|
+
self.require_matching_spec(session, spec)
|
|
154
|
+
if session.status == RunnerSession.Status.ACTIVE:
|
|
155
|
+
if not self.container_exists(container_name):
|
|
156
|
+
raise RunnerProtocolError(
|
|
157
|
+
"The recorded runner session has lost its container."
|
|
158
|
+
)
|
|
159
|
+
return session_id
|
|
160
|
+
raise RunnerProtocolError(
|
|
161
|
+
"The previous session outcome is not reusable; it will not be repeated."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self.remove_container(container_name)
|
|
165
|
+
self.remove_workspace(session_root)
|
|
166
|
+
workspace.mkdir(parents=True, mode=0o700)
|
|
167
|
+
outputs = session_root / OUTPUTS_DIR
|
|
168
|
+
outputs.mkdir(mode=0o700)
|
|
169
|
+
(session_root / HISTORY_DIR).mkdir(mode=0o700)
|
|
170
|
+
(session_root / SKILL_DIR).mkdir(mode=0o700)
|
|
171
|
+
try:
|
|
172
|
+
self.prepare_sandbox(spec, container_name, workspace, outputs)
|
|
173
|
+
except Exception:
|
|
174
|
+
self.remove_container(container_name)
|
|
175
|
+
self.remove_workspace(session_root)
|
|
176
|
+
set_session_status(session_id, RunnerSession.Status.ERROR)
|
|
177
|
+
raise
|
|
178
|
+
set_session_status(session_id, RunnerSession.Status.ACTIVE)
|
|
179
|
+
return session_id
|
|
180
|
+
|
|
181
|
+
def prepare_sandbox(
|
|
182
|
+
self,
|
|
183
|
+
spec: RunnerSessionSpec,
|
|
184
|
+
container_name: str,
|
|
185
|
+
workspace: Path,
|
|
186
|
+
outputs: Path,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Unpack the commit, any earlier files and any skill from another
|
|
189
|
+
repository, record the commit if asked, and start the container."""
|
|
190
|
+
unpack_source(spec.source_sha256, workspace)
|
|
191
|
+
history = workspace.parent / HISTORY_DIR
|
|
192
|
+
if spec.history_sha256:
|
|
193
|
+
unpack_source(spec.history_sha256, history)
|
|
194
|
+
skill = workspace.parent / SKILL_DIR
|
|
195
|
+
if spec.skill_sha256:
|
|
196
|
+
unpack_source(spec.skill_sha256, skill)
|
|
197
|
+
if spec.collect_changes:
|
|
198
|
+
record_baseline(workspace.parent, workspace)
|
|
199
|
+
self.prepare_workspace_permissions(workspace)
|
|
200
|
+
self.prepare_workspace_permissions(outputs)
|
|
201
|
+
self.prepare_workspace_permissions(history)
|
|
202
|
+
self.prepare_workspace_permissions(skill)
|
|
203
|
+
completed = self.run_command(
|
|
204
|
+
self.create_command(spec, container_name, workspace)
|
|
205
|
+
)
|
|
206
|
+
if completed.returncode:
|
|
207
|
+
raise RunnerProtocolError("The runner could not create the sandbox.")
|
|
208
|
+
completed = self.run_command(["docker", "start", container_name])
|
|
209
|
+
if completed.returncode:
|
|
210
|
+
raise RunnerProtocolError("The runner could not start the sandbox.")
|
|
211
|
+
|
|
212
|
+
def execute(self, session_id: str, request: CommandRequest) -> CommandResult:
|
|
213
|
+
cached = self.reserve_command(session_id, request)
|
|
214
|
+
if cached is not None:
|
|
215
|
+
return cached
|
|
216
|
+
session = get_session(session_id)
|
|
217
|
+
timeout = min(request.timeout_seconds, settings.RUNNER_COMMAND_TIMEOUT_SECONDS)
|
|
218
|
+
workdir = "/workspace"
|
|
219
|
+
if request.cwd != ".":
|
|
220
|
+
workdir += f"/{request.cwd}"
|
|
221
|
+
command = [
|
|
222
|
+
"docker",
|
|
223
|
+
"exec",
|
|
224
|
+
"--workdir",
|
|
225
|
+
workdir,
|
|
226
|
+
session.container_name,
|
|
227
|
+
*request.argv,
|
|
228
|
+
]
|
|
229
|
+
output_descriptor, output_name = tempfile.mkstemp(
|
|
230
|
+
dir=settings.RUNNER_WORK_ROOT, prefix="command-output-"
|
|
231
|
+
)
|
|
232
|
+
output_path = Path(output_name)
|
|
233
|
+
timed_out = False
|
|
234
|
+
exit_code: int | None = None
|
|
235
|
+
try:
|
|
236
|
+
with os.fdopen(output_descriptor, "wb") as output:
|
|
237
|
+
try:
|
|
238
|
+
completed = subprocess.run(
|
|
239
|
+
command,
|
|
240
|
+
stdout=output,
|
|
241
|
+
stderr=subprocess.STDOUT,
|
|
242
|
+
timeout=timeout,
|
|
243
|
+
check=False,
|
|
244
|
+
)
|
|
245
|
+
exit_code = completed.returncode
|
|
246
|
+
except subprocess.TimeoutExpired:
|
|
247
|
+
timed_out = True
|
|
248
|
+
result = CommandResult(
|
|
249
|
+
call_id=request.call_id,
|
|
250
|
+
exit_code=exit_code,
|
|
251
|
+
output=self.read_output(output_path),
|
|
252
|
+
timed_out=timed_out,
|
|
253
|
+
)
|
|
254
|
+
complete_command(
|
|
255
|
+
session,
|
|
256
|
+
request.call_id,
|
|
257
|
+
result.exit_code,
|
|
258
|
+
result.output,
|
|
259
|
+
result.timed_out,
|
|
260
|
+
)
|
|
261
|
+
if timed_out:
|
|
262
|
+
self.stop(session_id)
|
|
263
|
+
elif exit_code:
|
|
264
|
+
# gVisor ends the whole sandbox when it runs out of memory, so
|
|
265
|
+
# later commands could never succeed. Docker sets OOMKilled at
|
|
266
|
+
# once but reports the container running a little longer.
|
|
267
|
+
if self.container_oom_killed(session.container_name):
|
|
268
|
+
self.stop(session_id)
|
|
269
|
+
raise RunnerProtocolError("The sandbox ran out of memory.")
|
|
270
|
+
if not self.container_running(session.container_name):
|
|
271
|
+
self.stop(session_id)
|
|
272
|
+
raise RunnerProtocolError("The sandbox stopped unexpectedly.")
|
|
273
|
+
return result
|
|
274
|
+
finally:
|
|
275
|
+
output_path.unlink(missing_ok=True)
|
|
276
|
+
|
|
277
|
+
def collect(self, session_id: str) -> Path:
|
|
278
|
+
"""Stop the sandbox and archive its changes and saved files.
|
|
279
|
+
|
|
280
|
+
The sandbox stops first, so nothing changes the files while the runner
|
|
281
|
+
reads them. Asking again returns the same archive.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
session = get_session(session_id)
|
|
285
|
+
except RunnerSession.DoesNotExist as error:
|
|
286
|
+
raise RunnerProtocolError("Unknown runner session.") from error
|
|
287
|
+
if session.status != RunnerSession.Status.ACTIVE:
|
|
288
|
+
raise RunnerProtocolError("The session has ended, so its files are gone.")
|
|
289
|
+
workspace = Path(session.workspace_path)
|
|
290
|
+
session_root = workspace.parent.resolve()
|
|
291
|
+
self.require_owned_path(session_root)
|
|
292
|
+
self.remove_container(agent_name(session_id))
|
|
293
|
+
self.run_command(["docker", "kill", session.container_name])
|
|
294
|
+
if self.container_running(session.container_name):
|
|
295
|
+
raise RunnerProtocolError("The runner could not stop the sandbox.")
|
|
296
|
+
return collect(session_root, workspace)
|
|
297
|
+
|
|
298
|
+
def stop(self, session_id: str) -> None:
|
|
299
|
+
with transaction.atomic():
|
|
300
|
+
session = lock_session(session_id)
|
|
301
|
+
if session is None:
|
|
302
|
+
return
|
|
303
|
+
session.status = RunnerSession.Status.STOPPED
|
|
304
|
+
session.save(update_fields=("status", "updated_at"))
|
|
305
|
+
container_name = session.container_name
|
|
306
|
+
workspace = Path(session.workspace_path)
|
|
307
|
+
self.remove_container(agent_name(session_id))
|
|
308
|
+
self.remove_container(container_name)
|
|
309
|
+
self.remove_workspace(workspace.parent)
|
|
310
|
+
|
|
311
|
+
def reserve_command(
|
|
312
|
+
self, session_id: str, request: CommandRequest
|
|
313
|
+
) -> CommandResult | None:
|
|
314
|
+
with transaction.atomic():
|
|
315
|
+
session = lock_session(session_id, active_only=True)
|
|
316
|
+
if session is None:
|
|
317
|
+
raise RunnerProtocolError("Unknown or inactive runner session.")
|
|
318
|
+
command = session_command(session, request.call_id)
|
|
319
|
+
if command is None:
|
|
320
|
+
create_command(session, request.call_id, request.fingerprint)
|
|
321
|
+
return None
|
|
322
|
+
if command.fingerprint != request.fingerprint:
|
|
323
|
+
raise RunnerProtocolError(
|
|
324
|
+
"A tool call ID was reused with different arguments."
|
|
325
|
+
)
|
|
326
|
+
if command.status != RunnerCommand.Status.COMPLETED:
|
|
327
|
+
raise RunnerProtocolError(
|
|
328
|
+
"The previous execution outcome is unknown; it will not be repeated."
|
|
329
|
+
)
|
|
330
|
+
return CommandResult(
|
|
331
|
+
call_id=command.call_id,
|
|
332
|
+
exit_code=command.exit_code,
|
|
333
|
+
output=command.output,
|
|
334
|
+
timed_out=command.timed_out,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def require_matching_spec(session: RunnerSession, spec: RunnerSessionSpec) -> None:
|
|
339
|
+
recorded = (
|
|
340
|
+
session.run_id,
|
|
341
|
+
session.repository,
|
|
342
|
+
session.commit_sha,
|
|
343
|
+
session.image,
|
|
344
|
+
session.source_sha256,
|
|
345
|
+
session.collect_changes,
|
|
346
|
+
session.history_sha256,
|
|
347
|
+
session.skill_sha256,
|
|
348
|
+
)
|
|
349
|
+
requested = (
|
|
350
|
+
spec.run_id,
|
|
351
|
+
spec.repository,
|
|
352
|
+
spec.commit_sha,
|
|
353
|
+
spec.image,
|
|
354
|
+
spec.source_sha256,
|
|
355
|
+
spec.collect_changes,
|
|
356
|
+
spec.history_sha256,
|
|
357
|
+
spec.skill_sha256,
|
|
358
|
+
)
|
|
359
|
+
if recorded != requested:
|
|
360
|
+
raise RunnerProtocolError("The run already has a different session spec.")
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def create_command(
|
|
364
|
+
spec: RunnerSessionSpec, container_name: str, workspace: Path
|
|
365
|
+
) -> list[str]:
|
|
366
|
+
return [
|
|
367
|
+
"docker",
|
|
368
|
+
"create",
|
|
369
|
+
"--name",
|
|
370
|
+
container_name,
|
|
371
|
+
*sandbox_flags(),
|
|
372
|
+
"--network=none",
|
|
373
|
+
"--tmpfs=/tmp:rw,noexec,nosuid,size=256m",
|
|
374
|
+
# Bind mounts are writable by default. Docker rejects a bare "rw" field.
|
|
375
|
+
f"--mount=type=bind,src={workspace},dst=/workspace",
|
|
376
|
+
# Files the agent hands back. The runner collects them at the end.
|
|
377
|
+
f"--mount=type=bind,src={workspace.parent / OUTPUTS_DIR},"
|
|
378
|
+
f"dst={SANDBOX_OUTPUTS_DIR}",
|
|
379
|
+
# Files earlier runs of the job saved. The sandbox can only read them.
|
|
380
|
+
f"--mount=type=bind,src={workspace.parent / HISTORY_DIR},"
|
|
381
|
+
f"dst={SANDBOX_HISTORY_DIR},readonly",
|
|
382
|
+
# A skill from another repository. The sandbox can only read it,
|
|
383
|
+
# and it is never part of the repository's changes.
|
|
384
|
+
f"--mount=type=bind,src={workspace.parent / SKILL_DIR},"
|
|
385
|
+
f"dst={SANDBOX_SKILL_DIR},readonly",
|
|
386
|
+
"--workdir=/workspace",
|
|
387
|
+
"--env=HOME=/tmp",
|
|
388
|
+
# Skills that run Python must not leave bytecode in the repository.
|
|
389
|
+
"--env=PYTHONDONTWRITEBYTECODE=1",
|
|
390
|
+
spec.image,
|
|
391
|
+
"sleep",
|
|
392
|
+
"infinity",
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def prepare_workspace_permissions(workspace: Path) -> None:
|
|
397
|
+
uid = settings.RUNNER_SANDBOX_UID
|
|
398
|
+
gid = settings.RUNNER_SANDBOX_GID
|
|
399
|
+
for root, directories, files in os.walk(workspace):
|
|
400
|
+
for name in [*directories, *files]:
|
|
401
|
+
os.chown(Path(root) / name, uid, gid, follow_symlinks=False)
|
|
402
|
+
os.chown(workspace, uid, gid, follow_symlinks=False)
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def read_output(path: Path) -> str:
|
|
406
|
+
limit = settings.RUNNER_COMMAND_OUTPUT_MAX_BYTES
|
|
407
|
+
size = path.stat().st_size
|
|
408
|
+
with path.open("rb") as stream:
|
|
409
|
+
if size <= limit:
|
|
410
|
+
content = stream.read()
|
|
411
|
+
else:
|
|
412
|
+
remaining = max(limit - len(TRUNCATION_MARKER), 0)
|
|
413
|
+
head_size = remaining // 2
|
|
414
|
+
tail_size = remaining - head_size
|
|
415
|
+
head = stream.read(head_size)
|
|
416
|
+
stream.seek(size - tail_size)
|
|
417
|
+
content = head + TRUNCATION_MARKER + stream.read(tail_size)
|
|
418
|
+
return content[:limit].decode(errors="replace")
|
|
419
|
+
|
|
420
|
+
def container_exists(self, name: str) -> bool:
|
|
421
|
+
return self.run_command(["docker", "inspect", name], timeout=10).returncode == 0
|
|
422
|
+
|
|
423
|
+
def container_state(self, name: str, field: str) -> bool:
|
|
424
|
+
completed = self.run_command(
|
|
425
|
+
["docker", "inspect", "--format", f"{{{{.State.{field}}}}}", name],
|
|
426
|
+
timeout=10,
|
|
427
|
+
)
|
|
428
|
+
return completed.returncode == 0 and completed.stdout.strip() == "true"
|
|
429
|
+
|
|
430
|
+
def container_running(self, name: str) -> bool:
|
|
431
|
+
return self.container_state(name, "Running")
|
|
432
|
+
|
|
433
|
+
def container_oom_killed(self, name: str) -> bool:
|
|
434
|
+
return self.container_state(name, "OOMKilled")
|
|
435
|
+
|
|
436
|
+
def remove_container(self, name: str) -> None:
|
|
437
|
+
self.run_command(["docker", "rm", "--force", name], timeout=30)
|
|
438
|
+
|
|
439
|
+
def remove_workspace(self, path: Path) -> None:
|
|
440
|
+
path = path.resolve()
|
|
441
|
+
self.require_owned_path(path)
|
|
442
|
+
if path.exists():
|
|
443
|
+
shutil.rmtree(path)
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def require_owned_path(path: Path) -> None:
|
|
447
|
+
sessions_root = (settings.RUNNER_WORK_ROOT / "sessions").resolve()
|
|
448
|
+
if path == sessions_root or not path.is_relative_to(sessions_root):
|
|
449
|
+
raise RunnerProtocolError("Runner workspace escaped its owned root.")
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def run_command(
|
|
453
|
+
command: list[str], timeout: int = 30
|
|
454
|
+
) -> subprocess.CompletedProcess[str]:
|
|
455
|
+
return subprocess.run(
|
|
456
|
+
command,
|
|
457
|
+
capture_output=True,
|
|
458
|
+
text=True,
|
|
459
|
+
timeout=timeout,
|
|
460
|
+
check=False,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def get_runner_service() -> DockerRunnerService:
|
|
465
|
+
return DockerRunnerService()
|