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,317 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import socket
|
|
3
|
+
from collections.abc import AsyncIterator, Iterable
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
import anyio
|
|
7
|
+
import httpcore
|
|
8
|
+
import httpx
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OutboundUrlError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_address(address: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
|
|
17
|
+
parsed = ipaddress.ip_address(address.split("%", 1)[0])
|
|
18
|
+
if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped:
|
|
19
|
+
return parsed.ipv4_mapped
|
|
20
|
+
return parsed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def public_address(address: str) -> bool:
|
|
24
|
+
return parse_address(address).is_global
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def link_local_address(address: str) -> bool:
|
|
28
|
+
"""Link-local hosts include cloud metadata services such as 169.254.169.254.
|
|
29
|
+
|
|
30
|
+
These stay blocked even when local addresses are allowed, because they can
|
|
31
|
+
hand out the host machine's own credentials.
|
|
32
|
+
"""
|
|
33
|
+
return parse_address(address).is_link_local
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def literal_address(host: str) -> str | None:
|
|
37
|
+
"""The IP address a host names directly, including the short numeric
|
|
38
|
+
forms that resolvers accept, such as 2852039166 or 0xa9fea9fe."""
|
|
39
|
+
host = host.strip("[]").split("%", 1)[0]
|
|
40
|
+
try:
|
|
41
|
+
return str(ipaddress.ip_address(host))
|
|
42
|
+
except ValueError:
|
|
43
|
+
pass
|
|
44
|
+
if not host or not all(char in "0123456789abcdefx." for char in host.lower()):
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return socket.inet_ntoa(socket.inet_aton(host))
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolves_to_link_local(host: str, port: int) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
55
|
+
except (socket.gaierror, UnicodeError):
|
|
56
|
+
return False
|
|
57
|
+
return any(link_local_address(str(info[4][0])) for info in infos)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_outbound_url(url: str, noun: str = "URL", resolve: bool = True) -> str:
|
|
61
|
+
"""Reject URLs the server must not call. Messages never echo the URL."""
|
|
62
|
+
url = url.strip()
|
|
63
|
+
# "Base URLs", not the "Base urls" that str.capitalize would give.
|
|
64
|
+
plural = f"{noun[:1].upper()}{noun[1:]}s"
|
|
65
|
+
try:
|
|
66
|
+
parts = urlsplit(url)
|
|
67
|
+
port = parts.port
|
|
68
|
+
except ValueError as error:
|
|
69
|
+
raise OutboundUrlError(f"Enter a valid {noun}.") from error
|
|
70
|
+
allow_local = settings.OUTBOUND_ALLOW_LOCAL
|
|
71
|
+
allowed_schemes = {"https", "http"} if allow_local else {"https"}
|
|
72
|
+
if parts.scheme not in allowed_schemes:
|
|
73
|
+
raise OutboundUrlError(
|
|
74
|
+
f"{plural} must use HTTPS."
|
|
75
|
+
if not allow_local
|
|
76
|
+
else f"{plural} must use HTTP or HTTPS."
|
|
77
|
+
)
|
|
78
|
+
if not parts.hostname:
|
|
79
|
+
raise OutboundUrlError(f"Enter a valid {noun}.")
|
|
80
|
+
if parts.username or parts.password:
|
|
81
|
+
raise OutboundUrlError(f"{plural} cannot contain a username or password.")
|
|
82
|
+
literal = literal_address(parts.hostname)
|
|
83
|
+
link_local = literal is not None and link_local_address(literal)
|
|
84
|
+
if not link_local and allow_local and resolve:
|
|
85
|
+
# Local mode skips the public-address check below, but names that
|
|
86
|
+
# resolve to a metadata service are still refused.
|
|
87
|
+
link_local = resolves_to_link_local(parts.hostname, port or 443)
|
|
88
|
+
if link_local:
|
|
89
|
+
raise OutboundUrlError(
|
|
90
|
+
f"{plural} cannot point to a link-local address, such as a cloud "
|
|
91
|
+
"metadata service."
|
|
92
|
+
)
|
|
93
|
+
if allow_local or not resolve:
|
|
94
|
+
return url
|
|
95
|
+
try:
|
|
96
|
+
addresses = {
|
|
97
|
+
info[4][0]
|
|
98
|
+
for info in socket.getaddrinfo(
|
|
99
|
+
parts.hostname, port or 443, proto=socket.IPPROTO_TCP
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
except (socket.gaierror, UnicodeError) as error:
|
|
103
|
+
raise OutboundUrlError(f"The {noun} host could not be resolved.") from error
|
|
104
|
+
if not addresses or not all(public_address(item) for item in addresses):
|
|
105
|
+
raise OutboundUrlError(f"{plural} must point to a public address.")
|
|
106
|
+
return url
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def host_label(url: str) -> str:
|
|
110
|
+
"""A redacted label safe to show: the host only, never the path or query."""
|
|
111
|
+
parts = urlsplit(url)
|
|
112
|
+
host = parts.hostname or "unknown host"
|
|
113
|
+
return f"{host}:{parts.port}" if parts.port else host
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def vetted_addresses(infos: Iterable[tuple[object, ...]]) -> list[str]:
|
|
117
|
+
"""The addresses from one DNS answer, if every one of them may be called."""
|
|
118
|
+
addresses = list(dict.fromkeys(str(info[4][0]) for info in infos)) # type: ignore[index]
|
|
119
|
+
if not addresses:
|
|
120
|
+
raise httpcore.ConnectError("The host could not be resolved.")
|
|
121
|
+
if any(link_local_address(address) for address in addresses):
|
|
122
|
+
raise httpcore.ConnectError("The host resolves to a link-local address.")
|
|
123
|
+
if not settings.OUTBOUND_ALLOW_LOCAL and not all(
|
|
124
|
+
public_address(address) for address in addresses
|
|
125
|
+
):
|
|
126
|
+
raise httpcore.ConnectError("The host does not resolve to a public address.")
|
|
127
|
+
return addresses
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PinnedNetworkBackend(httpcore.AsyncNetworkBackend):
|
|
131
|
+
"""Connects only to addresses that pass the outbound check.
|
|
132
|
+
|
|
133
|
+
The check and the connection use the same DNS answer, so a host cannot
|
|
134
|
+
pass validation and then rebind to a private address. TLS still verifies
|
|
135
|
+
the original hostname, which httpcore sends as SNI.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
self.inner = httpcore.AnyIOBackend()
|
|
140
|
+
|
|
141
|
+
async def public_addresses(self, host: str, port: int) -> list[str]:
|
|
142
|
+
try:
|
|
143
|
+
infos = await anyio.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
144
|
+
except (OSError, UnicodeError) as error:
|
|
145
|
+
raise httpcore.ConnectError("The host could not be resolved.") from error
|
|
146
|
+
return vetted_addresses(infos)
|
|
147
|
+
|
|
148
|
+
async def connect_tcp(
|
|
149
|
+
self,
|
|
150
|
+
host: str,
|
|
151
|
+
port: int,
|
|
152
|
+
timeout: float | None = None,
|
|
153
|
+
local_address: str | None = None,
|
|
154
|
+
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
155
|
+
) -> httpcore.AsyncNetworkStream:
|
|
156
|
+
last_error: Exception | None = None
|
|
157
|
+
for address in await self.public_addresses(host, port):
|
|
158
|
+
try:
|
|
159
|
+
return await self.inner.connect_tcp(
|
|
160
|
+
address,
|
|
161
|
+
port,
|
|
162
|
+
timeout=timeout,
|
|
163
|
+
local_address=local_address,
|
|
164
|
+
socket_options=socket_options,
|
|
165
|
+
)
|
|
166
|
+
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
|
|
167
|
+
last_error = error
|
|
168
|
+
assert last_error is not None
|
|
169
|
+
raise last_error
|
|
170
|
+
|
|
171
|
+
async def connect_unix_socket(
|
|
172
|
+
self,
|
|
173
|
+
path: str,
|
|
174
|
+
timeout: float | None = None,
|
|
175
|
+
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
176
|
+
) -> httpcore.AsyncNetworkStream:
|
|
177
|
+
raise httpcore.ConnectError("Unix sockets are not allowed.")
|
|
178
|
+
|
|
179
|
+
async def sleep(self, seconds: float) -> None:
|
|
180
|
+
await self.inner.sleep(seconds)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class PinnedSyncNetworkBackend(httpcore.NetworkBackend):
|
|
184
|
+
"""The blocking twin of PinnedNetworkBackend, for code that is not async."""
|
|
185
|
+
|
|
186
|
+
def __init__(self) -> None:
|
|
187
|
+
self.inner = httpcore.SyncBackend()
|
|
188
|
+
|
|
189
|
+
def public_addresses(self, host: str, port: int) -> list[str]:
|
|
190
|
+
try:
|
|
191
|
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
192
|
+
except (OSError, UnicodeError) as error:
|
|
193
|
+
raise httpcore.ConnectError("The host could not be resolved.") from error
|
|
194
|
+
return vetted_addresses(infos)
|
|
195
|
+
|
|
196
|
+
def connect_tcp(
|
|
197
|
+
self,
|
|
198
|
+
host: str,
|
|
199
|
+
port: int,
|
|
200
|
+
timeout: float | None = None,
|
|
201
|
+
local_address: str | None = None,
|
|
202
|
+
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
203
|
+
) -> httpcore.NetworkStream:
|
|
204
|
+
last_error: Exception | None = None
|
|
205
|
+
for address in self.public_addresses(host, port):
|
|
206
|
+
try:
|
|
207
|
+
return self.inner.connect_tcp(
|
|
208
|
+
address,
|
|
209
|
+
port,
|
|
210
|
+
timeout=timeout,
|
|
211
|
+
local_address=local_address,
|
|
212
|
+
socket_options=socket_options,
|
|
213
|
+
)
|
|
214
|
+
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
|
|
215
|
+
last_error = error
|
|
216
|
+
assert last_error is not None
|
|
217
|
+
raise last_error
|
|
218
|
+
|
|
219
|
+
def connect_unix_socket(
|
|
220
|
+
self,
|
|
221
|
+
path: str,
|
|
222
|
+
timeout: float | None = None,
|
|
223
|
+
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
224
|
+
) -> httpcore.NetworkStream:
|
|
225
|
+
raise httpcore.ConnectError("Unix sockets are not allowed.")
|
|
226
|
+
|
|
227
|
+
def sleep(self, seconds: float) -> None:
|
|
228
|
+
self.inner.sleep(seconds)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class PinnedTransport(httpx.AsyncHTTPTransport):
|
|
232
|
+
"""An httpx transport that ignores proxy settings and pins vetted addresses."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, limits: httpx.Limits = httpx.Limits()) -> None:
|
|
235
|
+
super().__init__(trust_env=False, limits=limits)
|
|
236
|
+
# httpx has no public hook for the network backend. Swap the pool it
|
|
237
|
+
# built for one that uses ours, with the same TLS and pool settings.
|
|
238
|
+
self._pool = httpcore.AsyncConnectionPool(
|
|
239
|
+
ssl_context=httpx.create_ssl_context(trust_env=False),
|
|
240
|
+
max_connections=limits.max_connections,
|
|
241
|
+
max_keepalive_connections=limits.max_keepalive_connections,
|
|
242
|
+
keepalive_expiry=limits.keepalive_expiry,
|
|
243
|
+
network_backend=PinnedNetworkBackend(),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class PinnedSyncTransport(httpx.HTTPTransport):
|
|
248
|
+
"""The blocking twin of PinnedTransport."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, limits: httpx.Limits = httpx.Limits()) -> None:
|
|
251
|
+
super().__init__(trust_env=False, limits=limits)
|
|
252
|
+
self._pool = httpcore.ConnectionPool(
|
|
253
|
+
ssl_context=httpx.create_ssl_context(trust_env=False),
|
|
254
|
+
max_connections=limits.max_connections,
|
|
255
|
+
max_keepalive_connections=limits.max_keepalive_connections,
|
|
256
|
+
keepalive_expiry=limits.keepalive_expiry,
|
|
257
|
+
network_backend=PinnedSyncNetworkBackend(),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# The most a response from a configured service may send.
|
|
262
|
+
RESPONSE_MAX_BYTES = 2 * 1024 * 1024
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class OutboundResponseError(Exception):
|
|
266
|
+
"""A response SecondShift will not read."""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class BoundedStream(httpx.AsyncByteStream):
|
|
270
|
+
def __init__(self, inner: httpx.AsyncByteStream) -> None:
|
|
271
|
+
self.inner = inner
|
|
272
|
+
|
|
273
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
274
|
+
size = 0
|
|
275
|
+
async for chunk in self.inner:
|
|
276
|
+
size += len(chunk)
|
|
277
|
+
if size > RESPONSE_MAX_BYTES:
|
|
278
|
+
raise OutboundResponseError("The response exceeded the size limit.")
|
|
279
|
+
yield chunk
|
|
280
|
+
|
|
281
|
+
async def aclose(self) -> None:
|
|
282
|
+
await self.inner.aclose()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class BoundedTransport(PinnedTransport):
|
|
286
|
+
"""A pinned transport that refuses compressed and oversized responses."""
|
|
287
|
+
|
|
288
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
289
|
+
response = await super().handle_async_request(request)
|
|
290
|
+
if response.headers.get("content-encoding", "identity") != "identity":
|
|
291
|
+
await response.aclose()
|
|
292
|
+
raise OutboundResponseError("Compressed responses are not supported.")
|
|
293
|
+
response.stream = BoundedStream(response.stream)
|
|
294
|
+
return response
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def outbound_client(headers: dict[str, str]) -> httpx.AsyncClient:
|
|
298
|
+
"""A client for a service a person configured. It connects only to vetted
|
|
299
|
+
addresses, ignores proxy settings, follows no redirects and reads at most
|
|
300
|
+
RESPONSE_MAX_BYTES."""
|
|
301
|
+
return httpx.AsyncClient(
|
|
302
|
+
transport=BoundedTransport(),
|
|
303
|
+
headers={**headers, "Accept-Encoding": "identity"},
|
|
304
|
+
timeout=settings.OUTBOUND_TIMEOUT_SECONDS,
|
|
305
|
+
follow_redirects=False,
|
|
306
|
+
trust_env=False,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def outbound_sync_client() -> httpx.Client:
|
|
311
|
+
"""The blocking client for a configured service, pinned the same way."""
|
|
312
|
+
return httpx.Client(
|
|
313
|
+
transport=PinnedSyncTransport(),
|
|
314
|
+
timeout=settings.OUTBOUND_TIMEOUT_SECONDS,
|
|
315
|
+
follow_redirects=False,
|
|
316
|
+
trust_env=False,
|
|
317
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from secondshift.connections.models import Connection
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class RepositoryRef:
|
|
8
|
+
"""The repository a job works on, as external tools see it.
|
|
9
|
+
|
|
10
|
+
The repositories app builds it, so connections never read repository rows.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
workspace_id: int
|
|
14
|
+
# owner/repo on GitHub. None when the repository has no GitHub identity.
|
|
15
|
+
github_name: str | None
|
|
16
|
+
# GitHub's id, only for repositories added from GitHub.
|
|
17
|
+
github_id: int | None = None
|
|
18
|
+
# The source connection a GitHub repository was added with.
|
|
19
|
+
source: Connection | None = None
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
|
|
4
|
+
from ninja import Schema
|
|
5
|
+
from pydantic import Field, SecretStr
|
|
6
|
+
|
|
7
|
+
from secondshift.connections.dao import has_pending_cleanup
|
|
8
|
+
from secondshift.connections.execution import recent_login_runs
|
|
9
|
+
from secondshift.connections.models import AgentConnection
|
|
10
|
+
from secondshift.workspaces.access import Action, allows
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FeatureCheckSchema(Schema):
|
|
14
|
+
feature: str
|
|
15
|
+
label: str
|
|
16
|
+
# None when it cannot be checked without using it.
|
|
17
|
+
ok: bool | None
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionCheckSchema(Schema):
|
|
22
|
+
ok: bool
|
|
23
|
+
message: str
|
|
24
|
+
checked_at: datetime
|
|
25
|
+
# Source connections: what each feature can do, and where to fix it.
|
|
26
|
+
features: list[FeatureCheckSchema] = Field(default_factory=list)
|
|
27
|
+
fix_url: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectionUsageSchema(Schema):
|
|
31
|
+
jobs: int
|
|
32
|
+
runs_7d: int
|
|
33
|
+
spend_7d_usd: float
|
|
34
|
+
spend_partial: bool
|
|
35
|
+
last_used_at: datetime | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OfferedToolSchema(Schema):
|
|
39
|
+
name: str
|
|
40
|
+
description: str
|
|
41
|
+
read_only: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConnectionSchema(Schema):
|
|
45
|
+
id: int
|
|
46
|
+
provider: str
|
|
47
|
+
kind: str
|
|
48
|
+
binding_key: str
|
|
49
|
+
display_name: str
|
|
50
|
+
status: str
|
|
51
|
+
expires_at: datetime | None
|
|
52
|
+
available_tools: list[str]
|
|
53
|
+
target: str
|
|
54
|
+
allowed_models: list[str]
|
|
55
|
+
default_model: str
|
|
56
|
+
is_default: bool
|
|
57
|
+
available_models: list[str]
|
|
58
|
+
last_check: ConnectionCheckSchema | None = None
|
|
59
|
+
usage: ConnectionUsageSchema | None = None
|
|
60
|
+
# MCP only: the organization or project an owner chose. Never secret.
|
|
61
|
+
settings: dict[str, str] = Field(default_factory=dict)
|
|
62
|
+
# MCP only: "oauth" for a browser sign-in, "token" for a pasted token.
|
|
63
|
+
auth: str = ""
|
|
64
|
+
scopes: list[str] = Field(default_factory=list)
|
|
65
|
+
requested_scopes: list[str] = Field(default_factory=list)
|
|
66
|
+
# Custom MCP servers: every tool the server offers, for the owner to choose.
|
|
67
|
+
offered_tools: list[OfferedToolSchema] = Field(default_factory=list)
|
|
68
|
+
# Capability connections: whether each job's request waits for an owner.
|
|
69
|
+
require_approval: bool = False
|
|
70
|
+
# GitHub sources: whether pull requests, issues and commits name SecondShift.
|
|
71
|
+
attribution: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AttributionSchema(Schema):
|
|
75
|
+
attribution: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WebhookConnectionCreateSchema(Schema):
|
|
79
|
+
binding_key: str = Field(
|
|
80
|
+
min_length=1, max_length=120, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
81
|
+
)
|
|
82
|
+
display_name: str = Field(default="", max_length=200)
|
|
83
|
+
url: str = Field(min_length=1, max_length=2000)
|
|
84
|
+
require_approval: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class WebhookConnectionUpdateSchema(Schema):
|
|
88
|
+
display_name: str | None = Field(default=None, max_length=200)
|
|
89
|
+
url: str | None = Field(default=None, max_length=2000)
|
|
90
|
+
require_approval: bool | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GitHubTokenConnectSchema(Schema):
|
|
94
|
+
token: str = Field(min_length=1, max_length=2000)
|
|
95
|
+
# Replace the token of this connection instead of adding one.
|
|
96
|
+
connection_id: int | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MCPConnectionCreateSchema(Schema):
|
|
100
|
+
provider: str = Field(min_length=1, max_length=40)
|
|
101
|
+
binding_key: str = Field(
|
|
102
|
+
min_length=1, max_length=120, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
103
|
+
)
|
|
104
|
+
display_name: str = Field(default="", max_length=200)
|
|
105
|
+
# Empty when auth is "source": the connection borrows repository sources.
|
|
106
|
+
token: str = Field(default="", max_length=2000)
|
|
107
|
+
auth: str = Field(default="token", pattern=r"^(token|source)$")
|
|
108
|
+
settings: dict[str, str] = Field(default_factory=dict, max_length=10)
|
|
109
|
+
require_approval: bool = False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MCPConnectionUpdateSchema(Schema):
|
|
113
|
+
display_name: str | None = Field(default=None, max_length=200)
|
|
114
|
+
token: str | None = Field(default=None, max_length=2000)
|
|
115
|
+
settings: dict[str, str] | None = Field(default=None, max_length=10)
|
|
116
|
+
tools: list[str] | None = Field(default=None, max_length=200)
|
|
117
|
+
require_approval: bool | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class APISpecSchema(Schema):
|
|
121
|
+
spec_url: str = Field(default="", max_length=2000)
|
|
122
|
+
spec_text: str = Field(default="", max_length=5 * 1024 * 1024)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class APISpecSummarySchema(Schema):
|
|
126
|
+
title: str
|
|
127
|
+
servers: list[str]
|
|
128
|
+
operations: int
|
|
129
|
+
read_only: int
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class APIConnectionCreateSchema(APISpecSchema):
|
|
133
|
+
binding_key: str = Field(
|
|
134
|
+
min_length=1, max_length=120, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
135
|
+
)
|
|
136
|
+
display_name: str = Field(default="", max_length=200)
|
|
137
|
+
base_url: str = Field(min_length=1, max_length=200)
|
|
138
|
+
auth: str = Field(default="bearer", max_length=20)
|
|
139
|
+
header: str = Field(default="", max_length=64)
|
|
140
|
+
token: str = Field(min_length=1, max_length=2000)
|
|
141
|
+
require_approval: bool = False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class APIConnectionUpdateSchema(Schema):
|
|
145
|
+
display_name: str | None = Field(default=None, max_length=200)
|
|
146
|
+
token: str | None = Field(default=None, max_length=2000)
|
|
147
|
+
base_url: str | None = Field(default=None, max_length=200)
|
|
148
|
+
operations: list[str] | None = Field(default=None, max_length=500)
|
|
149
|
+
require_approval: bool | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MCPInspectSchema(Schema):
|
|
153
|
+
provider: str = Field(min_length=1, max_length=40)
|
|
154
|
+
settings: dict[str, str] = Field(default_factory=dict, max_length=10)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MCPInspectResultSchema(Schema):
|
|
158
|
+
oauth: bool
|
|
159
|
+
scopes: list[str]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class MCPSignInSchema(Schema):
|
|
163
|
+
provider: str = Field(min_length=1, max_length=40)
|
|
164
|
+
binding_key: str = Field(
|
|
165
|
+
default="", max_length=120, pattern=r"^([a-z0-9]+(?:-[a-z0-9]+)*)?$"
|
|
166
|
+
)
|
|
167
|
+
display_name: str = Field(default="", max_length=200)
|
|
168
|
+
settings: dict[str, str] = Field(default_factory=dict, max_length=10)
|
|
169
|
+
scopes: list[str] = Field(default_factory=list, max_length=20)
|
|
170
|
+
# New connections only. A reconnect keeps the connection's setting.
|
|
171
|
+
require_approval: bool = False
|
|
172
|
+
# Reconnect this connection instead of creating one.
|
|
173
|
+
connection_id: int | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MCPSignInStartSchema(Schema):
|
|
177
|
+
authorize_url: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class MCPSettingSchema(Schema):
|
|
181
|
+
key: str
|
|
182
|
+
label: str
|
|
183
|
+
pattern: str
|
|
184
|
+
required: bool
|
|
185
|
+
help: str
|
|
186
|
+
placeholder: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class MCPProviderSchema(Schema):
|
|
190
|
+
key: str
|
|
191
|
+
label: str
|
|
192
|
+
description: str
|
|
193
|
+
default_binding: str
|
|
194
|
+
token_label: str
|
|
195
|
+
token_hint: str
|
|
196
|
+
setup_url: str
|
|
197
|
+
setup_help: str
|
|
198
|
+
settings: list[MCPSettingSchema]
|
|
199
|
+
tools: list[str]
|
|
200
|
+
auth_methods: list[str]
|
|
201
|
+
owner_selects_tools: bool
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ModelConnectionCreateSchema(Schema):
|
|
205
|
+
provider: Literal["anthropic", "openai", "openrouter", "openai_compatible"]
|
|
206
|
+
display_name: str = Field(default="", max_length=200)
|
|
207
|
+
api_key: str = Field(default="", max_length=1000)
|
|
208
|
+
base_url: str = Field(default="", max_length=2000)
|
|
209
|
+
allowed_models: list[str] = Field(default_factory=list, max_length=100)
|
|
210
|
+
default_model: str = Field(default="", max_length=200)
|
|
211
|
+
is_default: bool | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ModelConnectionUpdateSchema(Schema):
|
|
215
|
+
display_name: str | None = Field(default=None, max_length=200)
|
|
216
|
+
api_key: str | None = Field(default=None, max_length=1000)
|
|
217
|
+
base_url: str | None = Field(default=None, max_length=2000)
|
|
218
|
+
allowed_models: list[str] | None = Field(default=None, max_length=100)
|
|
219
|
+
default_model: str | None = Field(default=None, max_length=200)
|
|
220
|
+
is_default: bool | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ConnectionTestSchema(Schema):
|
|
224
|
+
ok: bool
|
|
225
|
+
status_code: int | None
|
|
226
|
+
message: str
|
|
227
|
+
models: list[str] = Field(default_factory=list)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class WebhookSampleSchema(Schema):
|
|
231
|
+
# A run report as a webhook receives it, built from made-up records.
|
|
232
|
+
payload: dict[str, Any]
|
|
233
|
+
# How many characters of the report the summary field can hold.
|
|
234
|
+
summary_max_chars: int
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class AgentConnectionSchema(Schema):
|
|
238
|
+
id: int
|
|
239
|
+
backend: str
|
|
240
|
+
display_name: str
|
|
241
|
+
connected: bool
|
|
242
|
+
owner_id: int
|
|
243
|
+
updated_at: datetime
|
|
244
|
+
max_runs_per_day: int | None = None
|
|
245
|
+
# Runs in the last 24 hours, the window the cap counts over.
|
|
246
|
+
recent_runs: int = 0
|
|
247
|
+
can_manage: bool = False
|
|
248
|
+
last_check: dict[str, Any] | None = None
|
|
249
|
+
# The runner still holds a cached copy of an earlier login.
|
|
250
|
+
runner_cleanup_pending: bool = False
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def resolve_recent_runs(obj: AgentConnection) -> int:
|
|
254
|
+
# A list counts every login at once and attaches the counts.
|
|
255
|
+
counted = getattr(obj, "recent_runs", None)
|
|
256
|
+
if counted is None:
|
|
257
|
+
counted = recent_login_runs(obj.workspace_id)[obj.pk]
|
|
258
|
+
return counted
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def resolve_runner_cleanup_pending(obj: AgentConnection) -> bool:
|
|
262
|
+
return has_pending_cleanup(obj)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def resolve_can_manage(obj: AgentConnection, context: dict[str, Any]) -> bool:
|
|
266
|
+
request = context["request"]
|
|
267
|
+
return obj.owner_id == request.user.pk and allows(
|
|
268
|
+
request, Action.EDIT_CONNECTIONS
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class AgentRunLimitInput(Schema):
|
|
273
|
+
# Empty removes the cap.
|
|
274
|
+
max_runs_per_day: int | None = Field(default=None, ge=1, le=1000)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class AgentConnectionInput(Schema):
|
|
278
|
+
backend: str
|
|
279
|
+
display_name: str = Field(default="", max_length=200)
|
|
280
|
+
auth: SecretStr = Field(max_length=32000)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ExecutionChoiceSchema(Schema):
|
|
284
|
+
key: str
|
|
285
|
+
label: str
|
|
286
|
+
disabled: bool = False
|
|
287
|
+
# Why this choice cannot run jobs, or empty when it can.
|
|
288
|
+
problem: str = ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class ExecutionStatusSchema(Schema):
|
|
292
|
+
label: str
|
|
293
|
+
backend: str
|
|
294
|
+
problem: str
|
|
295
|
+
# False until an owner picks a model provider or a login.
|
|
296
|
+
chosen: bool
|
|
297
|
+
# Nothing to choose yet: no model provider and no login saved.
|
|
298
|
+
needs_setup: bool
|
|
299
|
+
fix_connection_id: int | None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ExecutionConfigurationSchema(Schema):
|
|
303
|
+
enabled: bool
|
|
304
|
+
choices: list[ExecutionChoiceSchema]
|
|
305
|
+
selected: str
|
|
306
|
+
label: str
|
|
307
|
+
backend: str
|
|
308
|
+
ready: bool
|
|
309
|
+
problem: str
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class ExecutionPreferenceSchema(Schema):
|
|
313
|
+
key: str = Field(max_length=100)
|