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