agentenv-framework 0.9.1254__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. agent_env/__init__.py +0 -0
  2. agent_env/a2a_agent/__init__.py +23 -0
  3. agent_env/a2a_agent/a2a_agent.py +600 -0
  4. agent_env/a2a_agent/conversation_store.py +356 -0
  5. agent_env/a2a_agent/object_transfer.py +609 -0
  6. agent_env/a2a_agent/protocol.py +238 -0
  7. agent_env/a2a_agent/store.py +201 -0
  8. agent_env/a2a_agent/validator.py +901 -0
  9. agent_env/artifact/__init__.py +48 -0
  10. agent_env/artifact/artifact.py +74 -0
  11. agent_env/artifact/artifacts/__init__.py +0 -0
  12. agent_env/artifact/artifacts/cli.py +83 -0
  13. agent_env/artifact/artifacts/docker_image.py +457 -0
  14. agent_env/artifact/artifacts/environment.py +89 -0
  15. agent_env/artifact/artifacts/environment_universe.py +156 -0
  16. agent_env/artifact/artifacts/file.py +214 -0
  17. agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
  18. agent_env/artifact/artifacts/skill.py +206 -0
  19. agent_env/artifact/artifacts/vm_image.py +95 -0
  20. agent_env/artifact/ref.py +15 -0
  21. agent_env/artifact/registry.py +251 -0
  22. agent_env/artifact/store.py +295 -0
  23. agent_env/artifact/universe.py +38 -0
  24. agent_env/attribution.py +41 -0
  25. agent_env/bundle/__init__.py +4 -0
  26. agent_env/bundle/_fs.py +128 -0
  27. agent_env/bundle/authoring.py +239 -0
  28. agent_env/bundle/installed.py +175 -0
  29. agent_env/bundle/ledger.py +269 -0
  30. agent_env/bundle/materialize.py +204 -0
  31. agent_env/bundle/parse.py +594 -0
  32. agent_env/bundle/plan.py +414 -0
  33. agent_env/bundle/resolve.py +404 -0
  34. agent_env/bundle/run.py +361 -0
  35. agent_env/cli/__init__.py +69 -0
  36. agent_env/cli/__main__.py +10 -0
  37. agent_env/cli/_installers.py +576 -0
  38. agent_env/cli/_plugin_changes.py +947 -0
  39. agent_env/cli/a2a_agent/__init__.py +22 -0
  40. agent_env/cli/a2a_agent/add_skill.py +97 -0
  41. agent_env/cli/a2a_agent/deploy.py +60 -0
  42. agent_env/cli/a2a_agent/get.py +15 -0
  43. agent_env/cli/a2a_agent/get_instance.py +26 -0
  44. agent_env/cli/a2a_agent/put.py +78 -0
  45. agent_env/cli/a2a_agent/validate.py +56 -0
  46. agent_env/cli/artifact/__init__.py +20 -0
  47. agent_env/cli/artifact/cli.py +83 -0
  48. agent_env/cli/artifact/environment.py +44 -0
  49. agent_env/cli/artifact/environment_universe.py +163 -0
  50. agent_env/cli/artifact/file_artifact_universe.py +281 -0
  51. agent_env/cli/artifact/skill.py +128 -0
  52. agent_env/cli/banner.py +25 -0
  53. agent_env/cli/config.py +263 -0
  54. agent_env/cli/env/__init__.py +31 -0
  55. agent_env/cli/env/deploy.py +112 -0
  56. agent_env/cli/env/gateway.py +57 -0
  57. agent_env/cli/env/get_instance.py +40 -0
  58. agent_env/cli/env/mcp_server.py +410 -0
  59. agent_env/cli/env/multi.py +282 -0
  60. agent_env/cli/env/service_db.py +99 -0
  61. agent_env/cli/env/snapshot.py +35 -0
  62. agent_env/cli/env/state/__init__.py +6 -0
  63. agent_env/cli/env/state/init.py +108 -0
  64. agent_env/cli/env/state/teardown.py +44 -0
  65. agent_env/cli/env/website.py +216 -0
  66. agent_env/cli/env/website_browser.py +67 -0
  67. agent_env/cli/eval/__init__.py +16 -0
  68. agent_env/cli/eval/add_tasks.py +63 -0
  69. agent_env/cli/eval/create.py +49 -0
  70. agent_env/cli/eval/run.py +194 -0
  71. agent_env/cli/identity/__init__.py +5 -0
  72. agent_env/cli/identity/client_identity.py +37 -0
  73. agent_env/cli/plugin.py +438 -0
  74. agent_env/cli/run.py +285 -0
  75. agent_env/cli/task/__init__.py +21 -0
  76. agent_env/cli/task/create.py +109 -0
  77. agent_env/cli/task/get.py +17 -0
  78. agent_env/cli/task/get_instance.py +34 -0
  79. agent_env/cli/task/run.py +615 -0
  80. agent_env/cli/task/validate.py +31 -0
  81. agent_env/cli/up.py +123 -0
  82. agent_env/cli/utils.py +183 -0
  83. agent_env/config/__init__.py +50 -0
  84. agent_env/config/describe.py +1067 -0
  85. agent_env/config/errors.py +13 -0
  86. agent_env/config/loader.py +245 -0
  87. agent_env/config/model.py +154 -0
  88. agent_env/config/paths.py +25 -0
  89. agent_env/config/plugin_tables.py +101 -0
  90. agent_env/config/provenance.py +134 -0
  91. agent_env/config/runtime.py +1064 -0
  92. agent_env/config/snapshot.py +81 -0
  93. agent_env/entity_refs.py +186 -0
  94. agent_env/env/__init__.py +49 -0
  95. agent_env/env/env.py +433 -0
  96. agent_env/env/env_artifact_store.py +87 -0
  97. agent_env/env/envs/__init__.py +7 -0
  98. agent_env/env/envs/_deployment.py +281 -0
  99. agent_env/env/envs/gateway_server.py +32 -0
  100. agent_env/env/envs/mcp_server.py +387 -0
  101. agent_env/env/envs/multi_env.py +780 -0
  102. agent_env/env/envs/service_db/Dockerfile +20 -0
  103. agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
  104. agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
  105. agent_env/env/envs/service_db.py +92 -0
  106. agent_env/env/envs/website.py +294 -0
  107. agent_env/env/envs/website_browser/Dockerfile +40 -0
  108. agent_env/env/envs/website_browser/__init__.py +17 -0
  109. agent_env/env/envs/website_browser/entrypoint.sh +13 -0
  110. agent_env/env/gateway/Dockerfile +14 -0
  111. agent_env/env/gateway/__init__.py +37 -0
  112. agent_env/env/gateway/clock.py +184 -0
  113. agent_env/env/gateway/constants.py +190 -0
  114. agent_env/env/gateway/entrypoint.py +156 -0
  115. agent_env/env/gateway/gateway.py +1095 -0
  116. agent_env/env/gateway/get_time.py +60 -0
  117. agent_env/env/gateway/requirements.txt +4 -0
  118. agent_env/env/gateway/triggers.py +1193 -0
  119. agent_env/env/legacy_protocol.py +146 -0
  120. agent_env/env/registry.py +93 -0
  121. agent_env/env/snapshot_store.py +372 -0
  122. agent_env/env/store.py +411 -0
  123. agent_env/eval/__init__.py +14 -0
  124. agent_env/eval/eval.py +68 -0
  125. agent_env/eval/store.py +116 -0
  126. agent_env/examples/hello/README.md +9 -0
  127. agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
  128. agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
  129. agent_env/examples/hello/tasks/hello.json +10 -0
  130. agent_env/explorer/__init__.py +12 -0
  131. agent_env/explorer/app.py +277 -0
  132. agent_env/explorer/openapi_docs.py +242 -0
  133. agent_env/explorer/plugin.py +95 -0
  134. agent_env/explorer/routers/__init__.py +1 -0
  135. agent_env/explorer/routers/common.py +174 -0
  136. agent_env/explorer/routers/conversations.py +25 -0
  137. agent_env/explorer/routers/objects.py +112 -0
  138. agent_env/explorer/routers/runs.py +475 -0
  139. agent_env/plugins/__init__.py +59 -0
  140. agent_env/plugins/_cli.py +244 -0
  141. agent_env/plugins/_discovery.py +97 -0
  142. agent_env/plugins/_inventory.py +262 -0
  143. agent_env/plugins/_registration.py +268 -0
  144. agent_env/plugins/_report.py +47 -0
  145. agent_env/plugins/_requirements.py +55 -0
  146. agent_env/providers/__init__.py +37 -0
  147. agent_env/providers/env_providers/__init__.py +12 -0
  148. agent_env/providers/env_providers/constants.py +13 -0
  149. agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
  150. agent_env/providers/env_providers/env_provider.py +237 -0
  151. agent_env/providers/env_providers/env_server_provider.py +133 -0
  152. agent_env/providers/env_state/__init__.py +49 -0
  153. agent_env/providers/env_state/env_state_provider.py +554 -0
  154. agent_env/providers/env_state/local_postgres.py +390 -0
  155. agent_env/providers/env_state/store.py +112 -0
  156. agent_env/providers/sandbox_providers/__init__.py +29 -0
  157. agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
  158. agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
  159. agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
  160. agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
  161. agent_env/providers/sandbox_providers/e2b/template.py +158 -0
  162. agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
  163. agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
  164. agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
  165. agent_env/providers/sandbox_providers/sandbox.py +387 -0
  166. agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
  167. agent_env/py.typed +0 -0
  168. agent_env/runner/__init__.py +16 -0
  169. agent_env/runner/local_runner.py +190 -0
  170. agent_env/runner/runner.py +127 -0
  171. agent_env/runner/store.py +94 -0
  172. agent_env/store/__init__.py +126 -0
  173. agent_env/store/_google.py +68 -0
  174. agent_env/store/base.py +33 -0
  175. agent_env/store/document_store/__init__.py +60 -0
  176. agent_env/store/document_store/document_store.py +519 -0
  177. agent_env/store/document_store/dynamodb_document_store.py +306 -0
  178. agent_env/store/document_store/evaluation.py +199 -0
  179. agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
  180. agent_env/store/document_store/mongo_document_store.py +259 -0
  181. agent_env/store/document_store/sqlite_document_store.py +314 -0
  182. agent_env/store/ids.py +123 -0
  183. agent_env/store/image_store/__init__.py +34 -0
  184. agent_env/store/image_store/ecr_image_store.py +131 -0
  185. agent_env/store/image_store/google_credentials.py +113 -0
  186. agent_env/store/image_store/image_store.py +113 -0
  187. agent_env/store/image_store/local_registry_image_store.py +106 -0
  188. agent_env/store/image_store/oci_registry_credentials.py +196 -0
  189. agent_env/store/local_state.py +27 -0
  190. agent_env/store/object_store/__init__.py +24 -0
  191. agent_env/store/object_store/gcs_object_store.py +415 -0
  192. agent_env/store/object_store/local_object_store.py +143 -0
  193. agent_env/store/object_store/object_store.py +198 -0
  194. agent_env/store/object_store/s3_object_store.py +354 -0
  195. agent_env/store/query.py +112 -0
  196. agent_env/store/routing.py +609 -0
  197. agent_env/store/secret_store/__init__.py +19 -0
  198. agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
  199. agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
  200. agent_env/store/secret_store/local_secret_store.py +47 -0
  201. agent_env/store/secret_store/secret_store.py +19 -0
  202. agent_env/task/__init__.py +27 -0
  203. agent_env/task/interrupts.py +109 -0
  204. agent_env/task/registry.py +27 -0
  205. agent_env/task/step_journal.py +99 -0
  206. agent_env/task/store.py +940 -0
  207. agent_env/task/task.py +815 -0
  208. agent_env/task/teardown.py +142 -0
  209. agent_env/task_step/__init__.py +47 -0
  210. agent_env/task_step/context.py +195 -0
  211. agent_env/task_step/context_ops.py +224 -0
  212. agent_env/task_step/registry.py +182 -0
  213. agent_env/task_step/review_store.py +118 -0
  214. agent_env/task_step/snapshot_utils/__init__.py +2 -0
  215. agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
  216. agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
  217. agent_env/task_step/store.py +129 -0
  218. agent_env/task_step/task_step.py +173 -0
  219. agent_env/task_step/task_steps/__init__.py +27 -0
  220. agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
  221. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
  222. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
  223. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
  224. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
  225. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
  226. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
  227. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
  228. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
  229. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
  230. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
  231. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
  232. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
  233. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
  234. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
  235. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
  236. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
  237. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
  238. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
  239. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
  240. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
  241. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
  242. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
  243. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
  244. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
  245. agent_env/task_step/task_steps/add_skills.py +240 -0
  246. agent_env/task_step/task_steps/apply_server_config.py +288 -0
  247. agent_env/task_step/task_steps/collect_artifacts.py +940 -0
  248. agent_env/task_step/task_steps/deploy_agent.py +626 -0
  249. agent_env/task_step/task_steps/deploy_env.py +197 -0
  250. agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
  251. agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
  252. agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
  253. agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
  254. agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
  255. agent_env/task_step/task_steps/install_agent.py +376 -0
  256. agent_env/task_step/task_steps/load_artifact.py +775 -0
  257. agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
  258. agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
  259. agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
  260. agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
  261. agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
  262. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
  263. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
  264. agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
  265. agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
  266. agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
  267. agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
  268. agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
  269. agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
  270. agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
  271. agent_env/task_step/task_steps/peer_agents.py +92 -0
  272. agent_env/task_step/task_steps/prompt_agent.py +900 -0
  273. agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
  274. agent_env/task_step/task_steps/register_env_triggers.py +97 -0
  275. agent_env/task_step/task_steps/reset_env.py +71 -0
  276. agent_env/task_step/task_steps/review.py +152 -0
  277. agent_env/task_step/task_steps/run_code.py +461 -0
  278. agent_env/task_step/task_steps/run_code_runner.py +52 -0
  279. agent_env/task_step/task_steps/run_docker_container.py +380 -0
  280. agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
  281. agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
  282. agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
  283. agent_env/task_step/task_steps/snapshot_env.py +637 -0
  284. agent_env/task_step/task_steps/sync_env_clock.py +132 -0
  285. agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
  286. agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
  287. agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
  288. agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
  289. agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
  290. agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
  291. agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
  292. agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
  293. agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
  294. agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
  295. agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
  296. agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
  297. agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
  298. agent_env/task_step/thread_work.py +61 -0
  299. agent_env/utils/__init__.py +0 -0
  300. agent_env/utils/card_naming.py +108 -0
  301. agent_env/utils/deprecation.py +109 -0
  302. agent_env/utils/docker_build.py +29 -0
  303. agent_env/utils/exec_retry.py +77 -0
  304. agent_env/utils/litellm_attribution.py +59 -0
  305. agent_env/utils/paths.py +20 -0
  306. agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
  307. agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
  308. agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
  309. agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
  310. agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
  311. agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
  312. agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
@@ -0,0 +1,1354 @@
1
+ """The gateway environment provider: MCP servers and websites behind one agent-env gateway, on a VM or on Modal containers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import json
8
+ import logging
9
+ import re
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from typing import TYPE_CHECKING, Optional
14
+
15
+ from agent_env.attribution import Attribution
16
+ from agent_env.env.env import DeployedGatewayEnv, _mcp_url
17
+ from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode
18
+ from agent_env.env.gateway.constants import DEFAULT_MCP_SERVER_NAME, WELL_KNOWN_PATH, random_mcp_server_name
19
+ from agent_env.env.envs.service_db import (
20
+ DATABASE_SERVICE_NAME,
21
+ DB_MCP_CONTAINER_PORT,
22
+ DB_MCP_PORT,
23
+ DB_MCP_SERVICE_NAME,
24
+ DB_WEB_PORT,
25
+ PGWEB_SERVICE_NAME,
26
+ )
27
+ from agent_env.providers.env_providers.constants import (
28
+ AGENT_ENV_WEBSITE_BACKEND_PORT,
29
+ AGENT_ENV_WEBSITE_BACKEND_SUFFIX,
30
+ AGENT_ENV_WEBSITE_FRONTEND_SUFFIX,
31
+ DOCKER_COMPOSE_PATH,
32
+ GATEWAY_APP_DIR,
33
+ GATEWAY_SERVICE_NAME,
34
+ )
35
+ from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider, _size_kwargs, _tool_names
36
+ from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider
37
+ from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox
38
+ from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, SandboxProvider
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Awaitable, Callable
42
+
43
+ from agent_env.artifact import DockerImageArtifact
44
+ from agent_env.env.env import DeployedEnv, Env
45
+ from agent_env.providers.env_state import DatabaseStateProvider, EnvStateInstance, LocalPostgresStoreSpec
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # MCP server health-check budget. Some servers wait on an upstream before they bind; a server
50
+ # that is up passes its first probe, so the long start period only delays failing a broken one.
51
+ MCP_HC_RETRIES = 30
52
+
53
+ MCP_HC_START_PERIOD = "60s"
54
+
55
+
56
+ @dataclass
57
+ class MCPServerConfig:
58
+ """Configuration for an MCP server in a gateway deployment."""
59
+
60
+ image: str
61
+ environment_name: str
62
+ extra_env_vars: dict[str, str] | None = None
63
+
64
+
65
+ @dataclass
66
+ class DeployedGateway:
67
+ """Result of a gateway deployment."""
68
+
69
+ gateway_url: str
70
+ mcp_url: str
71
+ db_web_url: str
72
+ db_mcp_url: str | None = None
73
+ website_frontend_urls: dict[str, str] | None = None
74
+ mcp_server_name: str | None = None
75
+ # Ids of the EnvStateInstance record(s) this deploy acquired; the caller records them
76
+ # on DeployedGatewayEnv.env_state_instance_ids. One entry today (one backend per deploy).
77
+ env_state_instance_ids: list[str] = field(default_factory=list)
78
+ # The env card the readiness probe read, and when (ISO 8601, UTC).
79
+ environment_card: dict | None = None
80
+ environment_card_read_at_utc: str | None = None
81
+
82
+
83
+ @dataclass
84
+ class WebsiteConfig:
85
+ """Configuration for a website (frontend + backend) in a gateway deployment."""
86
+
87
+ backend_image: str
88
+ frontend_image: str
89
+ environment_name: str
90
+
91
+
92
+ @dataclass
93
+ class SidecarConfig:
94
+ """A plain co-deployed container on the gateway that is *not* an MCP server.
95
+
96
+ Rendered into the gateway's docker-compose as its own service, with its env
97
+ (secrets included) injected via the compose ``environment:`` block — so nothing
98
+ lands on a ``docker run`` command line / the host process table — and its port
99
+ published on the VM host so callers can reach it over the sandbox tunnel.
100
+
101
+ ``image_artifact`` is side-loaded onto the VM from the object store exactly like the
102
+ MCP server images (``load_docker_images``): no external-registry pull happens at
103
+ ``docker compose up`` time, so the deploy has no registry dependency or ``docker login``.
104
+ """
105
+
106
+ environment_name: str
107
+ image_artifact: "DockerImageArtifact"
108
+ container_port: int
109
+ host_port: int
110
+ env: dict[str, str] | None = None
111
+ restart: str = "unless-stopped"
112
+
113
+ @property
114
+ def image(self) -> str:
115
+ """The local image tag compose references — the same tag ``load_docker_images``
116
+ restores on the VM (the artifact's agent-env image name)."""
117
+ return self.image_artifact.image_name
118
+
119
+
120
+ class EnvironmentGatewayProvider(_SandboxEnvironmentProvider):
121
+ """Provider for creating gateway deployments."""
122
+
123
+ type = "gateway"
124
+ record_class = DeployedGatewayEnv
125
+
126
+ def __init__(self):
127
+ super().__init__()
128
+ self._sandbox: Sandbox | None = None
129
+ self._db_sandbox: Sandbox | None = None
130
+ self._pgweb_sandbox: Sandbox | None = None
131
+ self._db_mcp_sandbox: Sandbox | None = None
132
+ # Set by _deploy_gateway (external instance) or _build_local_store (local) on deploy, and by _reattach
133
+ self._state_provider: "DatabaseStateProvider | None" = None
134
+ self._state_instance: "EnvStateInstance | None" = None
135
+
136
+ @property
137
+ def sandbox(self) -> Sandbox | None:
138
+ return self._sandbox
139
+
140
+ async def close(self) -> None:
141
+ if self._state_provider is not None and self._state_instance is not None:
142
+ try:
143
+ await self._state_provider.teardown(self._state_instance)
144
+ except Exception as e:
145
+ logger.warning(f"Failed to tear down state backend {type(self._state_provider).__name__}: {e}")
146
+ finally:
147
+ self._state_provider = None
148
+ self._state_instance = None
149
+ await super().close()
150
+ self._db_sandbox = None
151
+ self._pgweb_sandbox = None
152
+ self._db_mcp_sandbox = None
153
+
154
+ async def install_changelog_triggers(self, environment_name: str) -> None:
155
+ """Install audit triggers on the given service schema.
156
+
157
+ Thin shim over the state provider (which owns the changelog SQL). The provider +
158
+ instance are set by ``_build_local_store``/``_deploy_gateway`` (deploy) or ``_reattach``
159
+ (reattach); when neither ran (no state store wired — e.g. a standalone env reattached without one) this is
160
+ a no-op, skipping rather than guessing a local backend.
161
+ """
162
+ from agent_env.providers.env_state import DatabaseStateProvider
163
+
164
+ state_provider, state_instance = self._state_provider, self._state_instance
165
+ if state_provider is None or state_instance is None:
166
+ logger.debug("install_changelog_triggers: no state provider/instance set; skipping")
167
+ return
168
+ if not isinstance(state_provider, DatabaseStateProvider):
169
+ return
170
+
171
+ if self._db_sandbox is not None:
172
+ async def store_exec(argv: list[str]) -> tuple[int, str, str]:
173
+ return await self._db_sandbox.exec_with_output(*argv)
174
+ else:
175
+ store_name = state_provider.env_state_docker_service_name()
176
+
177
+ async def store_exec(argv: list[str]) -> tuple[int, str, str]:
178
+ return await self._sandbox.exec_with_output(
179
+ "sudo", "docker", "compose", "-f", DOCKER_COMPOSE_PATH,
180
+ "exec", "-T", store_name, *argv,
181
+ )
182
+
183
+ try:
184
+ await state_provider.install_changelog_triggers(
185
+ environment_name, instance=state_instance, store_exec=store_exec,
186
+ )
187
+ logger.info(f"Changelog triggers installed for schema '{environment_name}'")
188
+ except Exception as e:
189
+ logger.warning(f"Failed to install changelog triggers for schema '{environment_name}': {type(e).__name__}: {e}")
190
+
191
+ def _all_environments(self, mcp_servers, website_configs) -> list[str]:
192
+ """The DB-client services (one schema each) for a deploy: MCP servers + website backends,
193
+ deduped. This is the authoritative list — it reflects any servers the gateway auto-adds (e.g.
194
+ website_browser) — and drives both the rendered compose and the state provider's ``prepare``."""
195
+ return list(dict.fromkeys(
196
+ [s.environment_name for s in mcp_servers]
197
+ + [wc.environment_name for wc in (website_configs or [])]
198
+ ))
199
+
200
+ def create_docker_compose(
201
+ self,
202
+ mcp_servers: list[MCPServerConfig],
203
+ gateway_image: str = "env-gateway",
204
+ gateway_port: int = AGENT_ENV_GATEWAY_MCP_PORT,
205
+ website_configs: list[WebsiteConfig] | None = None,
206
+ expose_gateway_port: bool = True,
207
+ gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
208
+ sidecars: list[SidecarConfig] | None = None,
209
+ *,
210
+ state_provider: "DatabaseStateProvider",
211
+ state_instance: "EnvStateInstance",
212
+ host_port: Optional[Callable[[int], int]] = None,
213
+ mcp_server_name: str | None = None,
214
+ ) -> str:
215
+ """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine.
216
+
217
+ Supports MCP servers, websites, or both. Website containers (backend + frontend) are added
218
+ to the same docker network. The gateway receives WEBSITE_URLS env var for discovery.
219
+
220
+ Args:
221
+ mcp_servers: List of MCP server configurations for internal servers.
222
+ gateway_image: Docker image for the gateway.
223
+ gateway_port: Port to expose for the gateway.
224
+ website_configs: Optional list of website configurations (frontend + backend pairs).
225
+
226
+ Returns:
227
+ docker-compose.yml content as a string.
228
+
229
+ Example output for mcp_servers=[MCPServerConfig("mcp-slack", "slack")]
230
+ and website_configs=[WebsiteConfig("slack-web-be", "slack-web-fe", "slack")]
231
+ (using AGENT_ENV_GATEWAY_MCP_PORT=18765):
232
+
233
+ services:
234
+ servicedb:
235
+ image: public.ecr.aws/docker/library/postgres:16-alpine
236
+ environment:
237
+ - POSTGRES_USER=agentenv
238
+ - POSTGRES_PASSWORD=agentenv
239
+ - POSTGRES_DB=agentenv
240
+ healthcheck:
241
+ test: ["CMD-SHELL", "pg_isready -U agentenv"]
242
+ interval: 5s
243
+ timeout: 5s
244
+ retries: 5
245
+ volumes:
246
+ - ./init-schemas.sql:/docker-entrypoint-initdb.d/init-schemas.sql
247
+ networks:
248
+ - env-network
249
+
250
+ slack:
251
+ image: mcp-slack
252
+ depends_on:
253
+ servicedb:
254
+ condition: service_healthy
255
+ environment:
256
+ - MCP_HOST=0.0.0.0
257
+ - MCP_PORT=<gateway_port>
258
+ - SERVICE_NAME=slack
259
+ - ENVIRONMENT_NAME=slack
260
+ - DATABASE_URL=postgresql://agentenv:agentenv@servicedb:5432/agentenv?options=-c%20search_path%3D%22slack%22,public
261
+ networks:
262
+ - env-network
263
+
264
+ gateway:
265
+ image: env-gateway
266
+ depends_on:
267
+ - slack
268
+ environment:
269
+ - MCP_HOST=0.0.0.0
270
+ - MCP_PORT=<gateway_port>
271
+ - MCP_SERVER_NAME=crm
272
+ - INTERNAL_MCP_SERVERS=slack
273
+ - INTERNAL_MCP_PORT=<gateway_port>
274
+ - WEBSITE_URLS=http://slack-website-frontend:80
275
+ - REST_PROXY_URLS=slack=http://slack-website-backend:8000
276
+ ports:
277
+ - "<gateway_port>:<gateway_port>"
278
+ networks:
279
+ - env-network
280
+
281
+ slack-website-backend:
282
+ image: slack-web-be
283
+ depends_on:
284
+ servicedb:
285
+ condition: service_healthy
286
+ environment:
287
+ - DATABASE_URL=postgresql://agentenv:agentenv@servicedb:5432/agentenv?options=-c%20search_path%3D%22slack%22,public
288
+ - SERVICE_NAME=slack
289
+ - ENVIRONMENT_NAME=slack
290
+ - PYTHONUNBUFFERED=1
291
+ healthcheck:
292
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
293
+ interval: 10s
294
+ timeout: 5s
295
+ start_period: 30s
296
+ retries: 3
297
+ networks:
298
+ - env-network
299
+
300
+ slack-website-frontend:
301
+ image: slack-web-fe
302
+ depends_on:
303
+ slack-website-backend:
304
+ condition: service_healthy
305
+ networks:
306
+ - env-network
307
+
308
+ networks:
309
+ env-network:
310
+ driver: bridge
311
+ """
312
+ lines = ["services:"]
313
+
314
+ # MCP servers and website backends are both DB clients (one schema each).
315
+ environment_names = self._all_environments(mcp_servers, website_configs)
316
+
317
+ lines.extend(
318
+ state_provider.render_compose_containers(
319
+ environment_names, instance=state_instance, host_port=host_port
320
+ )
321
+ )
322
+ store_dep_lines = state_provider.docker_service_dependency()
323
+
324
+ # Add internal server services
325
+ website_configs = website_configs or []
326
+ website_backend_servers = {
327
+ wc.environment_name: f"{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}"
328
+ for wc in website_configs
329
+ }
330
+ for server in mcp_servers:
331
+ db_url = state_provider.url_for_environment(server.environment_name, instance=state_instance)
332
+ lines.extend([
333
+ f" {server.environment_name}:",
334
+ f" image: {server.image}",
335
+ ])
336
+ dep_lines = list(store_dep_lines)
337
+ if server.environment_name in website_backend_servers:
338
+ dep_lines += [
339
+ f" {website_backend_servers[server.environment_name]}:",
340
+ " condition: service_healthy",
341
+ ]
342
+ if dep_lines:
343
+ lines.append(" depends_on:")
344
+ lines.extend(dep_lines)
345
+ lines.extend([
346
+ " environment:",
347
+ " - MCP_HOST=0.0.0.0",
348
+ f" - MCP_PORT={gateway_port}",
349
+ # Both names are injected during the service_name -> environment_name
350
+ # migration. The SDK resolves identity from ENVIRONMENT_NAME
351
+ # (then the card's own name) and no longer consults SERVICE_NAME; it stays
352
+ # only for images that read it themselves, and a later pass drops it.
353
+ f" - SERVICE_NAME={server.environment_name}",
354
+ f" - ENVIRONMENT_NAME={server.environment_name}",
355
+ f" - DATABASE_URL={db_url}",
356
+ ])
357
+ if server.extra_env_vars:
358
+ for key, value in server.extra_env_vars.items():
359
+ lines.append(f" - {key}={value}")
360
+ lines.extend([
361
+ " healthcheck:",
362
+ f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]',
363
+ " interval: 5s",
364
+ " timeout: 5s",
365
+ f" retries: {MCP_HC_RETRIES}",
366
+ f" start_period: {MCP_HC_START_PERIOD}",
367
+ " networks:",
368
+ " - env-network",
369
+ "",
370
+ ])
371
+
372
+ # Add gateway service
373
+ mcp_server_names = [s.environment_name for s in mcp_servers]
374
+ lines.extend([
375
+ f" {GATEWAY_SERVICE_NAME}:",
376
+ f" image: {gateway_image}",
377
+ ])
378
+ dep_lines = list(store_dep_lines)
379
+ for name in mcp_server_names:
380
+ dep_lines += [f" {name}:", " condition: service_healthy"]
381
+ for backend_name in website_backend_servers.values():
382
+ dep_lines += [f" {backend_name}:", " condition: service_healthy"]
383
+ if dep_lines:
384
+ lines.append(" depends_on:")
385
+ lines.extend(dep_lines)
386
+ server_name = (mcp_server_name or DEFAULT_MCP_SERVER_NAME).replace("$", "$$") # compose would interpolate a bare $
387
+ gateway_env = [
388
+ " - MCP_HOST=0.0.0.0",
389
+ f" - MCP_PORT={gateway_port}",
390
+ f" - MCP_SERVER_NAME={server_name}",
391
+ f" - INTERNAL_MCP_SERVERS={','.join(mcp_server_names)}",
392
+ f" - INTERNAL_MCP_PORT={gateway_port}",
393
+ " - GATEWAY_TRAJECTORY_FILE=/var/log/agentenv/trajectory.jsonl",
394
+ ]
395
+ # Build REST_PROXY_URLS: mcp-{name} for MCP servers + {name} for website backends
396
+ rest_proxy_urls = []
397
+ for server in mcp_servers:
398
+ rest_proxy_urls.append(
399
+ f"mcp-{server.environment_name}=http://{server.environment_name}:{gateway_port}"
400
+ )
401
+ if website_configs:
402
+ website_urls = ",".join(
403
+ f"{wc.environment_name}=http://{wc.environment_name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}:80"
404
+ for wc in website_configs
405
+ )
406
+ gateway_env.append(f" - WEBSITE_URLS={website_urls}")
407
+ for wc in website_configs:
408
+ rest_proxy_urls.append(
409
+ f"{wc.environment_name}=http://{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}:{AGENT_ENV_WEBSITE_BACKEND_PORT}"
410
+ )
411
+ if rest_proxy_urls:
412
+ gateway_env.append(f" - REST_PROXY_URLS={','.join(rest_proxy_urls)}")
413
+ gateway_env.append(f" - GATEWAY_MODE={gateway_mode.value}")
414
+ gateway_env.append(f" - SERVICE_DB_URL={state_provider.base_url(state_instance)}")
415
+ publish = host_port or (lambda port: port)
416
+ exposed_gateway_ports = []
417
+ if expose_gateway_port:
418
+ exposed_gateway_ports.extend([
419
+ " ports:",
420
+ f' - "{publish(gateway_port)}:{gateway_port}"',
421
+ ])
422
+ lines.extend([
423
+ " environment:",
424
+ *gateway_env,
425
+ *exposed_gateway_ports,
426
+ " restart: on-failure",
427
+ " healthcheck:",
428
+ f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]',
429
+ " interval: 15s",
430
+ " timeout: 5s",
431
+ " retries: 10",
432
+ " start_period: 15s",
433
+ " networks:",
434
+ " - env-network",
435
+ "",
436
+ ])
437
+
438
+ # Add website services
439
+ for wc in website_configs:
440
+ backend_name = f"{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}"
441
+ frontend_name = f"{wc.environment_name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}"
442
+ db_url = state_provider.url_for_environment(wc.environment_name, instance=state_instance)
443
+ lines.extend([
444
+ f" {backend_name}:",
445
+ f" image: {wc.backend_image}",
446
+ ])
447
+ # depends_on the backend's readiness service.
448
+ lines.append(" depends_on:")
449
+ lines.extend(store_dep_lines)
450
+ lines.extend([
451
+ " environment:",
452
+ f" - DATABASE_URL={db_url}",
453
+ f" - SERVICE_NAME={wc.environment_name}",
454
+ f" - ENVIRONMENT_NAME={wc.environment_name}",
455
+ f" - PORT={AGENT_ENV_WEBSITE_BACKEND_PORT}",
456
+ " - PYTHONUNBUFFERED=1",
457
+ " healthcheck:",
458
+ f' test: ["CMD", "curl", "-f", "http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}/api/health"]',
459
+ " interval: 10s",
460
+ " timeout: 5s",
461
+ " start_period: 30s",
462
+ " retries: 3",
463
+ " networks:",
464
+ " - env-network",
465
+ "",
466
+ f" {frontend_name}:",
467
+ f" image: {wc.frontend_image}",
468
+ " depends_on:",
469
+ f" {backend_name}:",
470
+ " condition: service_healthy",
471
+ " networks:",
472
+ " - env-network",
473
+ "",
474
+ ])
475
+
476
+ # Add sidecar services.
477
+ # Secrets go through the compose environment block, never a docker-run command line.
478
+ for sc in sidecars or []:
479
+ lines.extend([
480
+ f" {sc.environment_name}:",
481
+ f" image: {sc.image}",
482
+ ])
483
+ if sc.env:
484
+ lines.append(" environment:")
485
+ for key, value in sc.env.items():
486
+ lines.append(f" - {key}={value}")
487
+ lines.extend([
488
+ " ports:",
489
+ f' - "{publish(sc.host_port)}:{sc.container_port}"',
490
+ f" restart: {sc.restart}",
491
+ " networks:",
492
+ " - env-network",
493
+ "",
494
+ ])
495
+
496
+ # Add networks
497
+ lines.extend([
498
+ "networks:",
499
+ " env-network:",
500
+ " driver: bridge",
501
+ ])
502
+ return "\n".join(lines)
503
+
504
+ async def deploy(
505
+ self,
506
+ env: Env,
507
+ sandbox_provider: SandboxProvider,
508
+ *,
509
+ gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
510
+ env_state_type: str | None = None,
511
+ env_state_instance_id: str | None = None,
512
+ ttl_seconds: int = 10800,
513
+ disk_size_gb: float = 10,
514
+ cpu: float | None = None,
515
+ memory_mb: int | None = None,
516
+ priority: Optional[int] = None,
517
+ attribution: Optional[Attribution] = None,
518
+ ) -> DeployedGatewayEnv:
519
+ """A gateway in front of the env, with the state store it acquires (local Postgres by default); returns the record.
520
+
521
+ A MultiEnv's gateway fronts its MCP servers and websites, a WebsiteEnv's its website, and any other env's its one MCP server.
522
+ """
523
+ from agent_env.config import get_config
524
+ from agent_env.env.env import Env
525
+ from agent_env.providers.env_state import LOCAL_POSTGRES_STATE_TYPE, acquire_state_for_deploy
526
+
527
+ topology = _gateway_topology(env)
528
+ state_instance = await acquire_state_for_deploy(
529
+ env_state_type=env_state_type, ttl_seconds=ttl_seconds, name_hint=env.id, env_state_instance_id=env_state_instance_id,
530
+ )
531
+ if state_instance is None or state_instance.state_type == LOCAL_POSTGRES_STATE_TYPE:
532
+ Env.get(get_config().default_service_db_env_id) # the local store runs from it: check it before any sandbox
533
+ deployed = await self._launch(
534
+ sandbox_provider, env.id, state_instance=state_instance, mcp_servers=topology.mcp_servers,
535
+ mcp_server_images=topology.mcp_server_images, website_configs=topology.website_configs,
536
+ website_images=topology.website_images, mcp_server_name=topology.mcp_server_name, gateway_mode=gateway_mode,
537
+ ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, priority=priority, attribution=attribution,
538
+ )
539
+ return DeployedGatewayEnv(
540
+ env_id=env.id,
541
+ env_version=env.version,
542
+ env_provider_type=self.type,
543
+ gateway_url=deployed.gateway_url,
544
+ db_web_url=deployed.db_web_url,
545
+ sandbox_id=self._sandbox.sandbox_id, sandbox_type=self._sandbox.type,
546
+ sandbox_ids=self._sandbox_ids(always_name_gateway=topology.names_gateway_on_vm),
547
+ db_mcp_url=deployed.db_mcp_url,
548
+ environment_card_url=f"{deployed.gateway_url}{WELL_KNOWN_PATH}",
549
+ environment_card=deployed.environment_card,
550
+ environment_card_read_at_utc=deployed.environment_card_read_at_utc,
551
+ website_frontend_urls=deployed.website_frontend_urls,
552
+ gateway_mode=gateway_mode.value,
553
+ env_state_instance_ids=deployed.env_state_instance_ids,
554
+ )
555
+
556
+ async def create_gateway(
557
+ self,
558
+ sandbox_provider: SandboxProvider,
559
+ mcp_servers: list[MCPServerConfig],
560
+ mcp_server_images: list[DockerImageArtifact],
561
+ gateway_port: int = AGENT_ENV_GATEWAY_MCP_PORT,
562
+ website_configs: list[WebsiteConfig] | None = None,
563
+ website_images: list[DockerImageArtifact] | None = None,
564
+ gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
565
+ ttl_seconds: int = 10800,
566
+ disk_size_gb: float = 10,
567
+ cpu: float | None = None,
568
+ memory_mb: int | None = None,
569
+ priority: Optional[int] = None,
570
+ existing_sandbox: Sandbox | None = None,
571
+ env_id: str | None = None,
572
+ state_instance: "EnvStateInstance | None" = None,
573
+ sidecars: list[SidecarConfig] | None = None,
574
+ mcp_server_name: str | None = None,
575
+ *,
576
+ attribution: Optional[Attribution] = None,
577
+ ) -> DeployedGateway:
578
+ """Create and start a gateway from explicit topology, for a caller that builds its own record rather than calling ``deploy()``.
579
+
580
+ ``state_instance`` is a pre-acquired *external* store handle (e.g. remote Postgres),
581
+ provisioned upstream by the caller. ``None`` => local Postgres, which the gateway self-provisions
582
+ (its store container's host doesn't exist until deploy). See ``_build_local_store``.
583
+
584
+ ``existing_sandbox`` lets callers pre-allocate the gateway VM in parallel
585
+ with other work and pass the handle in. VM-mode only — Modal container
586
+ deploys manage their own sandbox lifecycle and reject this kwarg.
587
+
588
+ ``mcp_server_name`` is the name the gateway presents on its card and as its MCP server; drawn as ``env`` +
589
+ 4 random digits when the env declares none; the record takes it from the gateway's card.
590
+ """
591
+ return await self._launch(
592
+ sandbox_provider, env_id, state_instance=state_instance, mcp_servers=mcp_servers, mcp_server_images=mcp_server_images,
593
+ gateway_port=gateway_port, website_configs=website_configs, website_images=website_images,
594
+ gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb,
595
+ priority=priority, existing_sandbox=existing_sandbox, sidecars=sidecars, mcp_server_name=mcp_server_name,
596
+ attribution=attribution,
597
+ )
598
+
599
+ async def _launch(self, sandbox_provider: SandboxProvider, env_id: str | None, *, mcp_server_name: str | None = None, **gateway) -> DeployedGateway:
600
+ """One gateway, deployed on the sandbox provider or each chained member in turn, then its tools probed."""
601
+ attempt = functools.partial(
602
+ self._deploy_gateway, **gateway,
603
+ mcp_server_name=mcp_server_name or random_mcp_server_name(), # drawn once, so every chained attempt presents the same name
604
+ )
605
+ result = await self._run(sandbox_provider, env_id, attempt)
606
+ await _probe_tools(env_id, result)
607
+ return result
608
+
609
+ async def _deploy_via_vm(
610
+ self,
611
+ sandbox_provider: SandboxProvider,
612
+ mcp_servers: list[MCPServerConfig],
613
+ mcp_server_images: list[DockerImageArtifact],
614
+ gateway_port: int,
615
+ website_configs: list[WebsiteConfig] | None,
616
+ website_images: list[DockerImageArtifact] | None,
617
+ gateway_mode: GatewayMode,
618
+ ttl_seconds: int,
619
+ disk_size_gb: float,
620
+ cpu: float | None = None,
621
+ memory_mb: int | None = None,
622
+ attribution: Optional[Attribution] = None,
623
+ priority: Optional[int] = None,
624
+ existing_sandbox: Sandbox | None = None,
625
+ sidecars: list[SidecarConfig] | None = None,
626
+ mcp_server_name: str | None = None,
627
+ ) -> DeployedGateway:
628
+ from agent_env.env.env import Env
629
+ from agent_env.config import get_config
630
+
631
+ website_configs = website_configs or []
632
+ website_images = website_images or []
633
+ sidecars = sidecars or []
634
+
635
+ if existing_sandbox is not None:
636
+ self._sandbox = existing_sandbox
637
+ else:
638
+ vm_kwargs: dict = {}
639
+ if cpu is not None:
640
+ vm_kwargs["cpu"] = cpu
641
+ if memory_mb is not None:
642
+ vm_kwargs["memory"] = memory_mb
643
+ self._sandbox = await sandbox_provider.create_vm(
644
+ exposed_ports=[gateway_port, DB_WEB_PORT, DB_MCP_PORT] + [sc.host_port for sc in sidecars],
645
+ timeout=ttl_seconds,
646
+ disk_size_gb=disk_size_gb,
647
+ attribution=attribution,
648
+ priority=priority,
649
+ **vm_kwargs,
650
+ )
651
+ sandbox = self._sandbox
652
+
653
+ config = get_config()
654
+ gateway_env = Env.get(config.default_gateway_env_id)
655
+
656
+ # Auto-include website_browser MCP server when websites are present
657
+ if website_configs:
658
+ website_browser_env = Env.get(config.default_website_browser_env_id)
659
+ browser_server = MCPServerConfig(
660
+ image=website_browser_env.docker_image_artifact.image_name,
661
+ environment_name=website_browser_env.environment_name,
662
+ )
663
+ mcp_servers = list(mcp_servers) + [browser_server]
664
+ mcp_server_images = list(mcp_server_images) + [website_browser_env.docker_image_artifact]
665
+
666
+ # Local Postgres also preloads its servicedb image set (servicedb + pgweb + db-mcp); an
667
+ # external store has no co-deployed containers, and image pull dominates VM deploy latency.
668
+ all_images = [
669
+ gateway_env.docker_image_artifact,
670
+ *mcp_server_images,
671
+ *website_images,
672
+ *[sc.image_artifact for sc in sidecars], # sidecars side-load like MCP images (no registry pull)
673
+ ]
674
+ if self._needs_local_postgres:
675
+ all_images += self._state_provider.store_images_to_load()
676
+ await sandbox.load_docker_images(all_images)
677
+
678
+ # Compose declares the local_state service by name, so "standing up" is just naming the host;
679
+ # the container is rendered into the compose and started on deploy.
680
+ async def _stand_up(_spec):
681
+ return DATABASE_SERVICE_NAME
682
+
683
+ local_store_spec = (
684
+ await self._build_local_store(
685
+ mcp_servers, _stand_up, website_configs=website_configs, ttl_seconds=ttl_seconds
686
+ )
687
+ if self._needs_local_postgres else None
688
+ )
689
+ state_instance = self._state_instance
690
+
691
+ # Stage 2 (prepare): create per-service schemas now that the final service list is known —
692
+ # it includes the auto-added website_browser above. Local no-ops (its servicedb container
693
+ # runs the init script declaratively); remote/external stores create schemas here.
694
+ if self._state_provider is not None:
695
+ await self._state_provider.prepare(
696
+ self._all_environments(mcp_servers, website_configs), instance=state_instance
697
+ )
698
+
699
+ compose_content = self.create_docker_compose(
700
+ mcp_servers=mcp_servers,
701
+ gateway_image=gateway_env.docker_image_artifact.image_name,
702
+ gateway_port=gateway_port,
703
+ website_configs=website_configs,
704
+ gateway_mode=gateway_mode,
705
+ sidecars=sidecars,
706
+ state_provider=self._state_provider,
707
+ state_instance=state_instance,
708
+ host_port=sandbox.host_port,
709
+ mcp_server_name=mcp_server_name,
710
+ )
711
+ logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}")
712
+
713
+ # Write files to VM
714
+ logger.info("Writing files to VM...")
715
+ await sandbox.exec_script(f"mkdir -p {GATEWAY_APP_DIR}", max_retries=2)
716
+
717
+ # Only local postgres needs its init script to be directly run by the gateway's servicedb
718
+ # container; remote/external stores are already initialized upstream.
719
+ if self._needs_local_postgres:
720
+ init_script = local_store_spec.init_sql
721
+ logger.info(f"Generated init-schemas.sql:\n{init_script}")
722
+ init_script_path = f"{GATEWAY_APP_DIR}/init-schemas.sql"
723
+ write_init_script = f'''cat > {init_script_path} << 'INIT_EOF'
724
+ {init_script}
725
+ INIT_EOF'''
726
+ await sandbox.exec_script(write_init_script, max_retries=2)
727
+
728
+ # Write docker-compose.yml
729
+ write_compose_script = f'''cat > {DOCKER_COMPOSE_PATH} << 'COMPOSE_EOF'
730
+ {compose_content}
731
+ COMPOSE_EOF'''
732
+ await sandbox.exec_script(write_compose_script, max_retries=2)
733
+
734
+ # Start MCP services with docker compose
735
+ logger.info("Starting MCP services...")
736
+ await sandbox.exec_script(f"cd {GATEWAY_APP_DIR} && docker compose up -d", max_retries=2)
737
+
738
+ # Wait for containers to start
739
+ await asyncio.sleep(10)
740
+
741
+ # Verify containers are running
742
+ logger.info("Verifying containers...")
743
+ exit_code, stdout, stderr = await sandbox.exec_with_output( "sudo", "docker", "ps", "-a")
744
+ logger.info(f" docker ps -a:\n{stdout}")
745
+ if exit_code != 0:
746
+ raise RuntimeError(f"docker ps failed: {stderr}")
747
+
748
+ # Check gateway container is running
749
+ exit_code, stdout, stderr = await sandbox.exec_with_output( "sudo", "docker", "ps")
750
+ if GATEWAY_SERVICE_NAME not in stdout:
751
+ raise RuntimeError(f"Gateway container not running. docker ps: {stdout}")
752
+
753
+ # Wait for gateway to be fully ready (needs time to discover internal servers)
754
+ # Host-side port: exec_with_output runs inside the VM on per-VM backends but on
755
+ # the host for LocalSandbox, where the gateway is published elsewhere.
756
+ gateway_ready = await self._wait_for_gateway(sandbox, sandbox.host_port(gateway_port))
757
+
758
+ # Print gateway logs for debugging
759
+ logger.info("Gateway logs:")
760
+ gateway_container_id = await self._get_container_id(sandbox, GATEWAY_SERVICE_NAME)
761
+ if gateway_container_id:
762
+ exit_code, logs, stderr = await sandbox.exec_with_output( "sudo", "docker", "logs", gateway_container_id)
763
+ logger.info(f" stdout:\n{logs}")
764
+ if stderr:
765
+ logger.info(f" stderr:\n{stderr}")
766
+ if not gateway_ready:
767
+ raise RuntimeError("Gateway did not become ready in time")
768
+
769
+ # Check container status only on the sidecars the provider actually rendered
770
+ sidecar_names = (
771
+ self._state_provider.rendered_sidecar_service_names()
772
+ if self._needs_local_postgres else []
773
+ )
774
+ for name in sidecar_names:
775
+ container_id = await self._get_container_id(sandbox, name)
776
+ if container_id:
777
+ exit_code, logs, stderr = await sandbox.exec_with_output("sudo", "docker", "logs", container_id)
778
+ logger.info(f"{name} logs:\n stdout:\n{logs}")
779
+ if stderr:
780
+ logger.info(f" stderr:\n{stderr}")
781
+ else:
782
+ logger.warning(f"{name} container not found")
783
+
784
+ gateway_url = sandbox.tunnel_urls.get(gateway_port)
785
+ db_web_base_url = sandbox.tunnel_urls.get(DB_WEB_PORT) if PGWEB_SERVICE_NAME in sidecar_names else None
786
+ db_web_url = f"{db_web_base_url}/" if db_web_base_url else None
787
+ db_mcp_base_url = sandbox.tunnel_urls.get(DB_MCP_PORT) if DB_MCP_SERVICE_NAME in sidecar_names else None
788
+ db_mcp_url = f"{db_mcp_base_url}/mcp" if db_mcp_base_url else None
789
+ logger.info(f"pgweb URL: {db_web_url}")
790
+ if db_mcp_url:
791
+ logger.info(f"DB MCP endpoint: {db_mcp_url}")
792
+
793
+ # Wait for the tunnel to serve the env card, which the deploy records
794
+ card = await self._wait_for_tunnel(gateway_url, timeout=120)
795
+ if card is None:
796
+ raise RuntimeError("Tunnel did not become ready in time")
797
+ card_read_at_utc = datetime.now(timezone.utc).isoformat()
798
+ mcp_url = _mcp_url(gateway_url, card)
799
+ logger.info(f"MCP endpoint: {mcp_url}")
800
+
801
+ # Build frontend URLs proxied through the gateway
802
+ website_frontend_urls: dict[str, str] | None = None
803
+ if website_configs:
804
+ website_frontend_urls = {
805
+ wc.environment_name: f"{gateway_url}/website/{wc.environment_name}/"
806
+ for wc in website_configs
807
+ }
808
+ for svc_name, url in website_frontend_urls.items():
809
+ logger.info(f"Website frontend ({svc_name}): {url}")
810
+
811
+ logger.info(f"Gateway URL: {gateway_url}")
812
+ return DeployedGateway(
813
+ gateway_url=gateway_url, mcp_url=mcp_url, db_web_url=db_web_url, db_mcp_url=db_mcp_url,
814
+ website_frontend_urls=website_frontend_urls, mcp_server_name=mcp_server_name,
815
+ env_state_instance_ids=[state_instance.instance_id],
816
+ environment_card=card, environment_card_read_at_utc=card_read_at_utc,
817
+ )
818
+
819
+ async def _deploy_via_containers(
820
+ self,
821
+ sandbox_provider: SandboxProvider,
822
+ mcp_servers: list[MCPServerConfig],
823
+ mcp_server_images: list[DockerImageArtifact],
824
+ gateway_port: int,
825
+ website_configs: list[WebsiteConfig] | None,
826
+ gateway_mode: GatewayMode,
827
+ ttl_seconds: int,
828
+ disk_size_gb: float,
829
+ cpu: float | None = None,
830
+ memory_mb: int | None = None,
831
+ attribution: Optional[Attribution] = None,
832
+ priority: Optional[int] = None,
833
+ mcp_server_name: str | None = None,
834
+ ) -> DeployedGateway:
835
+ from agent_env.env.env import Env
836
+ from agent_env.config import get_config
837
+
838
+ if website_configs:
839
+ raise NotImplementedError(
840
+ "Container-mode gateway deploy does not support websites yet; use a VM-mode sandbox provider."
841
+ )
842
+
843
+ config = get_config()
844
+ gateway_env = Env.get(config.default_gateway_env_id)
845
+
846
+ i6pn_kwargs = {"i6pn": True, "region": config.modal_default_region} if isinstance(sandbox_provider, ModalSandboxProvider) else {}
847
+ deploy = _ContainerDeploy(
848
+ sandbox_provider=sandbox_provider, cpu=cpu, disk_size_gb=disk_size_gb, ttl_seconds=ttl_seconds,
849
+ attribution=attribution, priority=priority, i6pn_kwargs=i6pn_kwargs,
850
+ )
851
+
852
+ try:
853
+ if self._needs_local_postgres:
854
+ await self._build_local_store(
855
+ mcp_servers, functools.partial(self._stand_up_servicedb, deploy), ttl_seconds=ttl_seconds
856
+ )
857
+ state_instance = self._state_instance
858
+
859
+ # Stage 2 (prepare): create per-service schemas before the MCP servers connect. Local
860
+ # no-ops (its servicedb init runs above); remote/external stores create schemas here.
861
+ if self._state_provider is not None:
862
+ await self._state_provider.prepare(
863
+ self._all_environments(mcp_servers, None), instance=state_instance
864
+ )
865
+
866
+ sidecar_specs = (
867
+ self._state_provider.sidecar_specs(instance=state_instance)
868
+ if self._needs_local_postgres else []
869
+ )
870
+ # Step 2: MCP server sandboxes (HTTPS), provisioned in parallel.
871
+ mcp_coros = [
872
+ self._provision_mcp(deploy, cfg, art, state_instance)
873
+ for cfg, art in zip(mcp_servers, mcp_server_images)
874
+ ]
875
+ aux_coros = [self._provision_sidecar(deploy, spec) for spec in sidecar_specs]
876
+ all_results = await asyncio.gather(*mcp_coros, *aux_coros, return_exceptions=True)
877
+
878
+ mcp_results: list[tuple[str, "Sandbox"]] = []
879
+ pgweb_sb = None
880
+ db_mcp_sb = None
881
+ errors: list[BaseException] = []
882
+ for i, r in enumerate(all_results):
883
+ if isinstance(r, BaseException):
884
+ logger.warning(f"sandbox batch child failed [{type(r).__name__}]: {(str(r) or repr(r))[-500:]}")
885
+ errors.append(r)
886
+ continue
887
+ if i < len(mcp_coros):
888
+ environment_name, mcp_sb = r
889
+ self._container_sandboxes.append(mcp_sb)
890
+ self._environment_sandboxes[environment_name] = mcp_sb
891
+ mcp_results.append((environment_name, mcp_sb))
892
+ else:
893
+ spec = sidecar_specs[i - len(mcp_coros)]
894
+ self._container_sandboxes.append(r)
895
+ if spec.name == PGWEB_SERVICE_NAME:
896
+ pgweb_sb = r
897
+ self._pgweb_sandbox = r
898
+ elif spec.name == DB_MCP_SERVICE_NAME:
899
+ db_mcp_sb = r
900
+ self._db_mcp_sandbox = r
901
+
902
+ if errors:
903
+ raise RuntimeError(f"{len(errors)} of {len(all_results)} sandbox provisioning tasks failed") from errors[0]
904
+
905
+ mcp_url_public_by_name: dict[str, str] = {}
906
+ mcp_url_i6pn_by_name: dict[str, str] = {}
907
+ for environment_name, mcp_sb in mcp_results:
908
+ mcp_url_public_by_name[environment_name] = f"{mcp_sb.tunnel_urls[AGENT_ENV_GATEWAY_MCP_PORT]}/mcp"
909
+ mcp_i6pn = getattr(mcp_sb, "i6pn_address", None)
910
+ if mcp_i6pn:
911
+ mcp_url_i6pn_by_name[environment_name] = f"http://[{mcp_i6pn}]:{AGENT_ENV_GATEWAY_MCP_PORT}/mcp"
912
+
913
+ if i6pn_kwargs:
914
+ missing_i6pn = [n for n in mcp_url_public_by_name if n not in mcp_url_i6pn_by_name]
915
+ if missing_i6pn:
916
+ raise RuntimeError(f"i6pn requested but resolution failed for MCPs: {missing_i6pn}")
917
+ internal_servers_by_name = mcp_url_i6pn_by_name
918
+ else:
919
+ internal_servers_by_name = mcp_url_public_by_name
920
+
921
+ # Step 3: gateway sandbox (HTTPS) with full MCP URLs in env
922
+ logger.info(f"Provisioning gateway from {gateway_env.docker_image_artifact.image_name}...")
923
+ gateway_env_vars = {
924
+ "INTERNAL_MCP_SERVERS": ",".join(f"{name}={url}" for name, url in internal_servers_by_name.items()),
925
+ "REST_PROXY_URLS": ",".join(f"mcp-{name}={url.removesuffix('/mcp')}" for name, url in internal_servers_by_name.items()),
926
+ "GATEWAY_MODE": gateway_mode.value,
927
+ "SERVICE_DB_URL": self._state_provider.base_url(state_instance),
928
+ "MCP_SERVER_NAME": mcp_server_name or DEFAULT_MCP_SERVER_NAME,
929
+ }
930
+ gateway_sb = await sandbox_provider.create_container(
931
+ image_name=gateway_env.docker_image_artifact.image_name,
932
+ port=gateway_port,
933
+ env=gateway_env_vars,
934
+ **_size_kwargs(cpu, memory_mb),
935
+ disk_size_gb=disk_size_gb, timeout=ttl_seconds,
936
+ attribution=attribution,
937
+ priority=priority,
938
+ **i6pn_kwargs,
939
+ )
940
+ self._container_sandboxes.append(gateway_sb)
941
+ self._sandbox = gateway_sb
942
+ gateway_url = gateway_sb.tunnel_urls[gateway_port]
943
+ logger.info(f"Gateway URL: {gateway_url}")
944
+
945
+ card = await self._wait_for_tunnel(gateway_url, timeout=120)
946
+ if card is None:
947
+ raise RuntimeError("Gateway tunnel did not become ready in time")
948
+ card_read_at_utc = datetime.now(timezone.utc).isoformat()
949
+
950
+ db_web_url = pgweb_sb.tunnel_urls[DB_WEB_PORT] + "/" if pgweb_sb else None
951
+ db_mcp_url = db_mcp_sb.tunnel_urls[DB_MCP_CONTAINER_PORT] + "/mcp" if db_mcp_sb else None
952
+ return DeployedGateway(
953
+ gateway_url=gateway_url,
954
+ mcp_url=_mcp_url(gateway_url, card),
955
+ db_web_url=db_web_url,
956
+ db_mcp_url=db_mcp_url,
957
+ website_frontend_urls=None,
958
+ mcp_server_name=mcp_server_name,
959
+ env_state_instance_ids=[state_instance.instance_id],
960
+ environment_card=card,
961
+ environment_card_read_at_utc=card_read_at_utc,
962
+ )
963
+ except Exception:
964
+ await self.close()
965
+ raise
966
+
967
+ async def _wait_for_gateway(self, sandbox: VmSandbox, port: int, timeout: int = 300) -> bool:
968
+ """Wait for the gateway to answer on ``port``.
969
+
970
+ Probes the well-known document, not ``/mcp``: a healthy MCP endpoint replies with
971
+ an open event-stream, which a readiness check has no business holding. Any status
972
+ below 500 means the app is up and routing.
973
+
974
+ Each attempt is bounded so one hung request cannot consume the whole budget --
975
+ ``exec_with_output`` runs on the host for some backends, where a stream can stall.
976
+ """
977
+ logger.info(f"Waiting for gateway to be ready (timeout={timeout}s)...")
978
+ probe_url = f"http://localhost:{port}{WELL_KNOWN_PATH}"
979
+ for i in range(timeout):
980
+ exit_code, stdout, stderr = await sandbox.exec_with_output(
981
+ "curl", "-s", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", probe_url
982
+ )
983
+ http_code = stdout.strip()
984
+ if http_code.isdigit() and int(http_code) > 0 and int(http_code) < 500:
985
+ logger.info(f" Gateway ready (HTTP {http_code}) after {i+1}s")
986
+ return True
987
+ if i % 10 == 0:
988
+ logger.info(f" Still waiting... (HTTP {http_code})")
989
+ await asyncio.sleep(1)
990
+ logger.info(f" Gateway not ready after {timeout}s")
991
+ return False
992
+
993
+ async def read_trajectory(self, sandbox: VmSandbox) -> list[dict]:
994
+ """Read trajectory JSONL from gateway container."""
995
+ container_id = await self._get_container_id(sandbox, GATEWAY_SERVICE_NAME)
996
+ if not container_id:
997
+ return []
998
+
999
+ exit_code, stdout, stderr = await sandbox.exec_with_output(
1000
+ "sudo", "docker", "exec", container_id, "cat", "/var/log/agentenv/trajectory.jsonl"
1001
+ )
1002
+ events = []
1003
+ for line in stdout.strip().split("\n"):
1004
+ if line:
1005
+ events.append(json.loads(line))
1006
+ return events
1007
+
1008
+ async def _get_container_id(self, sandbox: VmSandbox, compose_service: str) -> str | None:
1009
+ """Get container ID for a docker-compose service (including exited containers)."""
1010
+ exit_code, stdout, stderr = await sandbox.exec_with_output(
1011
+ "sudo", "docker", "compose", "-f", DOCKER_COMPOSE_PATH, "ps", "-a", "-q", compose_service
1012
+ )
1013
+ container_id = stdout.strip()
1014
+ return container_id if container_id else None
1015
+
1016
+ async def _provision_sidecar(self, deploy: _ContainerDeploy, spec) -> Sandbox:
1017
+ logger.info(f"Provisioning {spec.name} from {spec.image}...")
1018
+ return await deploy.sandbox_provider.create_container(
1019
+ image_name=spec.image,
1020
+ port=spec.port,
1021
+ env=spec.env,
1022
+ command=spec.command,
1023
+ **_size_kwargs(deploy.cpu, None),
1024
+ disk_size_gb=deploy.disk_size_gb, timeout=deploy.ttl_seconds,
1025
+ attribution=deploy.attribution,
1026
+ priority=deploy.priority,
1027
+ **deploy.i6pn_kwargs,
1028
+ )
1029
+
1030
+ @property
1031
+ def _needs_local_postgres(self) -> bool:
1032
+ from agent_env.providers.env_state import LocalPostgresStateProvider
1033
+
1034
+ return isinstance(self._state_provider, LocalPostgresStateProvider)
1035
+
1036
+ async def _build_local_store(
1037
+ self,
1038
+ mcp_servers: list[MCPServerConfig],
1039
+ stand_up: Callable[[LocalPostgresStoreSpec], Awaitable[str]],
1040
+ website_configs: list[WebsiteConfig] | None = None,
1041
+ ttl_seconds: int = 10800,
1042
+ ) -> LocalPostgresStoreSpec:
1043
+ """A **local** Postgres store, which can only be built within the gateway."""
1044
+ from agent_env.providers.env_state import (
1045
+ LocalPostgresStateContext,
1046
+ LocalPostgresStateProvider,
1047
+ )
1048
+
1049
+ # MCP servers and websites are both DB clients (one schema each); the container path
1050
+ # passes no websites, so normalize to a list once.
1051
+ configs = [*mcp_servers, *(website_configs or [])]
1052
+ environment_names = list(dict.fromkeys(c.environment_name for c in configs))
1053
+ provider = self._state_provider # set by _deploy_gateway
1054
+ assert isinstance(provider, LocalPostgresStateProvider)
1055
+ spec = provider.store_spec(environment_names)
1056
+ host = await stand_up(spec)
1057
+ self._state_instance = await provider.acquire(
1058
+ LocalPostgresStateContext(
1059
+ environment_names=environment_names, host=host, ttl_seconds=ttl_seconds
1060
+ )
1061
+ )
1062
+ return spec
1063
+
1064
+ async def _init_service_db_via_exec(self, db_sb: Sandbox, init_sql: str) -> None:
1065
+ from agent_env.env.envs.service_db import DB_NAME, DB_USER
1066
+
1067
+ logger.info("Initializing service-db schemas + changelog functions via sandbox exec...")
1068
+ for attempt in range(60):
1069
+ exit_code, _, stderr = await db_sb.exec_with_output("pg_isready", "-U", DB_USER, "-d", DB_NAME)
1070
+ if exit_code == 0:
1071
+ break
1072
+ if attempt % 10 == 0:
1073
+ logger.info(f" Waiting for postgres to be ready... (last stderr: {stderr.strip()[-200:]})")
1074
+ await asyncio.sleep(1)
1075
+ else:
1076
+ raise RuntimeError("Postgres not ready after 60s")
1077
+
1078
+ await db_sb.write_file_from_text(init_sql, "/tmp/init-schemas.sql")
1079
+ exit_code, stdout, stderr = await db_sb.exec_with_output(
1080
+ "psql", "-U", DB_USER, "-d", DB_NAME, "-v", "ON_ERROR_STOP=1", "-f", "/tmp/init-schemas.sql",
1081
+ )
1082
+ if exit_code != 0:
1083
+ raise RuntimeError(f"Service-db init failed: {stderr[-1000:]}")
1084
+ logger.info("Service-db schemas initialized.")
1085
+
1086
+ async def _stand_up_servicedb(self, deploy: _ContainerDeploy, spec: "LocalPostgresStoreSpec") -> str:
1087
+ """Container mode stands the servicedb up as its own i6pn-only sandbox; returns its host."""
1088
+ logger.info("Provisioning service-db sandbox (Modal, i6pn-only)...")
1089
+ # Local backend only (external skips standup); the provider is set by _deploy_gateway.
1090
+ db_sb = await deploy.sandbox_provider.create_container(
1091
+ image_name=self._state_provider.container_store_image(),
1092
+ port=spec.port,
1093
+ env=spec.env,
1094
+ **_size_kwargs(deploy.cpu, None),
1095
+ disk_size_gb=deploy.disk_size_gb, timeout=deploy.ttl_seconds,
1096
+ expose_externally=False,
1097
+ attribution=deploy.attribution,
1098
+ priority=deploy.priority,
1099
+ **deploy.i6pn_kwargs,
1100
+ )
1101
+ self._container_sandboxes.append(db_sb)
1102
+ self._db_sandbox = db_sb
1103
+ db_i6pn_address = getattr(db_sb, "i6pn_address", None)
1104
+ if not db_i6pn_address:
1105
+ raise RuntimeError("service-db has no i6pn address; cannot route postgres traffic")
1106
+ host = f"[{db_i6pn_address}]"
1107
+ await self._init_service_db_via_exec(db_sb, spec.init_sql)
1108
+ logger.info(f"Service-db ready at {host}:{spec.port} (i6pn-only)")
1109
+ return host
1110
+
1111
+ async def _provision_mcp(
1112
+ self,
1113
+ deploy: _ContainerDeploy,
1114
+ cfg: MCPServerConfig,
1115
+ image_artifact: "DockerImageArtifact",
1116
+ state_instance: "EnvStateInstance | None",
1117
+ ) -> tuple[str, Sandbox]:
1118
+ logger.info(f"Provisioning MCP server '{cfg.environment_name}' from {image_artifact.image_name}...")
1119
+ env = dict(cfg.extra_env_vars or {})
1120
+ env["DATABASE_URL"] = self._state_provider.url_for_environment(cfg.environment_name, instance=state_instance)
1121
+ env["ENVIRONMENT_NAME"] = cfg.environment_name
1122
+ if deploy.i6pn_kwargs:
1123
+ env.setdefault("MCP_HOST", "::")
1124
+ sb = await deploy.sandbox_provider.create_container(
1125
+ image_name=image_artifact.image_name,
1126
+ port=AGENT_ENV_GATEWAY_MCP_PORT,
1127
+ env=env,
1128
+ **_size_kwargs(deploy.cpu, None),
1129
+ disk_size_gb=deploy.disk_size_gb, timeout=deploy.ttl_seconds,
1130
+ attribution=deploy.attribution,
1131
+ priority=deploy.priority,
1132
+ **deploy.i6pn_kwargs,
1133
+ )
1134
+ return cfg.environment_name, sb
1135
+
1136
+ async def _deploy_gateway(
1137
+ self,
1138
+ sandbox_provider: SandboxProvider,
1139
+ *,
1140
+ state_instance: "EnvStateInstance | None",
1141
+ mcp_servers: list[MCPServerConfig],
1142
+ mcp_server_images: list[DockerImageArtifact],
1143
+ gateway_port: int = AGENT_ENV_GATEWAY_MCP_PORT,
1144
+ website_configs: list[WebsiteConfig] | None = None,
1145
+ website_images: list[DockerImageArtifact] | None = None,
1146
+ gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
1147
+ ttl_seconds: int = 10800,
1148
+ disk_size_gb: float = 10,
1149
+ cpu: float | None = None,
1150
+ memory_mb: int | None = None,
1151
+ priority: Optional[int] = None,
1152
+ existing_sandbox: Sandbox | None = None,
1153
+ sidecars: list[SidecarConfig] | None = None,
1154
+ mcp_server_name: str | None = None,
1155
+ attribution: Optional[Attribution] = None,
1156
+ ) -> DeployedGateway:
1157
+ """One gateway deploy on one (unchained) sandbox provider: Modal containers for a ``ModalSandboxProvider``, else a VM."""
1158
+ from agent_env.providers.env_state import LocalPostgresStateProvider, build_state_provider
1159
+
1160
+ # Every attempt starts from the caller's store: a failed attempt's close() cleared it.
1161
+ self._state_instance = state_instance
1162
+ self._state_provider = (
1163
+ build_state_provider(state_instance.state_type)
1164
+ if state_instance is not None
1165
+ else LocalPostgresStateProvider()
1166
+ )
1167
+ deploy_kwargs = {"attribution": dict(attribution or {}), "priority": priority}
1168
+ if isinstance(sandbox_provider, ModalSandboxProvider):
1169
+ if existing_sandbox is not None:
1170
+ raise ValueError("existing_sandbox is only supported for VM-mode providers")
1171
+ if sidecars:
1172
+ raise NotImplementedError("sidecars are only supported for VM-mode gateway deploys")
1173
+ return await self._deploy_via_containers(
1174
+ sandbox_provider, mcp_servers, mcp_server_images,
1175
+ gateway_port=gateway_port, website_configs=website_configs, gateway_mode=gateway_mode,
1176
+ ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb,
1177
+ cpu=cpu, memory_mb=memory_mb, mcp_server_name=mcp_server_name, **deploy_kwargs,
1178
+ )
1179
+ return await self._deploy_via_vm(
1180
+ sandbox_provider, mcp_servers, mcp_server_images,
1181
+ gateway_port=gateway_port, website_configs=website_configs, website_images=website_images,
1182
+ gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb,
1183
+ cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars,
1184
+ mcp_server_name=mcp_server_name, **deploy_kwargs,
1185
+ )
1186
+
1187
+ def _sandbox_ids(self, always_name_gateway: bool = False) -> dict[str, str | dict[str, str]]:
1188
+ """The containers a container-mode deploy created, in the record's ``sandbox_ids`` shape; ``{}`` on a VM, or only the
1189
+ gateway where ``always_name_gateway``, as a MultiEnv's records have always named it."""
1190
+ from agent_env.env.envs.gateway_server import GatewayEnv
1191
+ from agent_env.env.envs.mcp_server import MCPServerEnv
1192
+ from agent_env.env.envs.service_db import ServiceDBEnv
1193
+
1194
+ if not self._environment_sandboxes and not always_name_gateway:
1195
+ return {}
1196
+ sandbox_ids: dict[str, str | dict[str, str]] = {GatewayEnv.type: self._sandbox.sandbox_id}
1197
+ if self._environment_sandboxes:
1198
+ sandbox_ids[MCPServerEnv.type] = {name: sb.sandbox_id for name, sb in self._environment_sandboxes.items()}
1199
+ service_db = {role: sb.sandbox_id for role, sb in [
1200
+ (DATABASE_SERVICE_NAME, self._db_sandbox), (PGWEB_SERVICE_NAME, self._pgweb_sandbox), (DB_MCP_SERVICE_NAME, self._db_mcp_sandbox),
1201
+ ] if sb is not None}
1202
+ if service_db:
1203
+ sandbox_ids[ServiceDBEnv.type] = service_db
1204
+ return sandbox_ids
1205
+
1206
+ async def _reattach(self, env: Env, deployed: DeployedEnv) -> Sandbox:
1207
+ """A record this provider wrote, reattached: a MultiEnv's through its own rebuild, any other env's as the base reattaches it."""
1208
+ from agent_env.env.envs.multi_env import MultiEnv
1209
+
1210
+ if isinstance(env, MultiEnv):
1211
+ return await self._reattach_multi_env(deployed)
1212
+ return await super()._reattach(env, deployed)
1213
+
1214
+ async def _reattach_multi_env(self, deployed: DeployedEnv) -> Sandbox:
1215
+ """A MultiEnv's record reattached: every sandbox it names and its state store, for its children's loads and close().
1216
+
1217
+ Unlike a single server's reattach, which skips a dead database or sidecar, any dead sandbox the record names raises.
1218
+ """
1219
+ from agent_env.env.envs.mcp_server import MCPServerEnv
1220
+ from agent_env.env.envs.service_db import ServiceDBEnv
1221
+ from agent_env.providers import build_sandbox_provider, get_env_sandbox_provider
1222
+ from agent_env.providers.env_state import LocalPostgresStateProvider, build_state_provider
1223
+ from agent_env.providers.env_state.store import get_env_state_instance_store
1224
+ from agent_env.store.base import NotFoundError
1225
+
1226
+ provider = build_sandbox_provider(deployed.sandbox_type) if deployed.sandbox_type else get_env_sandbox_provider()
1227
+ sandbox_ids = deployed.sandbox_ids or {}
1228
+
1229
+ self._sandbox = await provider.get_sandbox(deployed.sandbox_id)
1230
+
1231
+ for environment_name, sb_id in (sandbox_ids.get(MCPServerEnv.type) or {}).items():
1232
+ sb = await provider.get_sandbox(sb_id)
1233
+ self._environment_sandboxes[environment_name] = sb
1234
+ self._container_sandboxes.append(sb)
1235
+
1236
+ db_ids = sandbox_ids.get(ServiceDBEnv.type) or {}
1237
+ for role, slot_name in [
1238
+ (DATABASE_SERVICE_NAME, "_db_sandbox"),
1239
+ (PGWEB_SERVICE_NAME, "_pgweb_sandbox"),
1240
+ (DB_MCP_SERVICE_NAME, "_db_mcp_sandbox"),
1241
+ ]:
1242
+ sb_id = db_ids.get(role)
1243
+ if sb_id:
1244
+ sb = await provider.get_sandbox(sb_id)
1245
+ setattr(self, slot_name, sb)
1246
+ self._container_sandboxes.append(sb)
1247
+
1248
+ if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
1249
+ self._container_sandboxes.append(self._sandbox)
1250
+
1251
+ state_instance = None
1252
+ ids = deployed.env_state_instance_ids if isinstance(deployed, DeployedGatewayEnv) else None
1253
+ if ids:
1254
+ try:
1255
+ state_instance = get_env_state_instance_store().get(ids[0])
1256
+ except NotFoundError:
1257
+ logger.warning(f"env_state_instance {ids[0]} not found on reattach; defaulting to local")
1258
+ # No record (env predates state instances, or it expired) → default to the local store.
1259
+ state_instance = state_instance or LocalPostgresStateProvider.default_instance()
1260
+ self._state_instance = state_instance
1261
+ self._state_provider = build_state_provider(state_instance.state_type)
1262
+ return self._sandbox
1263
+
1264
+
1265
+ @dataclass(frozen=True)
1266
+ class _GatewayTopology:
1267
+ """What a gateway fronts for one env, and the one way its record differs by env type."""
1268
+
1269
+ mcp_servers: list[MCPServerConfig]
1270
+ mcp_server_images: list[DockerImageArtifact]
1271
+ website_configs: list[WebsiteConfig] | None
1272
+ website_images: list[DockerImageArtifact] | None
1273
+ mcp_server_name: str | None # a MultiEnv's name; None draws one
1274
+ names_gateway_on_vm: bool # a MultiEnv's record names its gateway sandbox even on a VM
1275
+
1276
+
1277
+ def _gateway_topology(env: Env) -> _GatewayTopology:
1278
+ """What a gateway fronts for env: a MultiEnv's MCP servers and websites (whatever their own env_provider_type) under its
1279
+ name, a WebsiteEnv's website, or else the env's one MCP server."""
1280
+ from agent_env.env.envs.multi_env import MultiEnv
1281
+ from agent_env.env.envs.website import WebsiteEnv
1282
+
1283
+ is_multi = isinstance(env, MultiEnv)
1284
+ if is_multi:
1285
+ servers, websites, name = env.mcp_server_envs, env.website_envs, env.name
1286
+ elif isinstance(env, WebsiteEnv):
1287
+ servers, websites, name = [], [env], None
1288
+ else:
1289
+ servers, websites, name = [env], [], None
1290
+ return _GatewayTopology(
1291
+ mcp_servers=[MCPServerConfig(image=s.docker_image_artifact.image_name, environment_name=s.environment_name) for s in servers],
1292
+ mcp_server_images=[s.docker_image_artifact for s in servers],
1293
+ website_configs=[
1294
+ WebsiteConfig(backend_image=w.backend_docker_image_artifact.image_name, frontend_image=w.frontend_docker_image_artifact.image_name,
1295
+ environment_name=w.environment_name)
1296
+ for w in websites
1297
+ ] or None,
1298
+ website_images=[image for w in websites for image in (w.backend_docker_image_artifact, w.frontend_docker_image_artifact)] or None,
1299
+ mcp_server_name=name,
1300
+ names_gateway_on_vm=is_multi,
1301
+ )
1302
+
1303
+
1304
+ # Compose `environment:` keys whose values are secrets and must be masked before the
1305
+ # rendered compose is logged. Matches the key to the left of the first '=' on an
1306
+ # ` - KEY=VALUE` env line.
1307
+ _SECRET_ENV_KEY_RE = re.compile(r"^(\s*-\s*)([A-Za-z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY)[A-Za-z0-9_]*)=.*$")
1308
+
1309
+
1310
+ def _redact_compose_secrets(compose_content: str) -> str:
1311
+ """Mask secret-valued env lines so the logged compose never carries a live secret."""
1312
+ return "\n".join(
1313
+ _SECRET_ENV_KEY_RE.sub(r"\1\2=***", line) for line in compose_content.splitlines()
1314
+ )
1315
+
1316
+
1317
+ # Cap on the post-readiness tools/list probe; it only logs, so a slow gateway costs deploy time, never the deploy.
1318
+ _TOOLS_PROBE_TIMEOUT_S = 10.0
1319
+
1320
+
1321
+ async def _probe_tools(env_id: str | None, gateway: DeployedGateway) -> None:
1322
+ """Log whether the gateway's MCP tools cover every tool its card's children advertise; never raises."""
1323
+ start = time.monotonic()
1324
+ head = f"env_deploy_tools_probe env_id={env_id or 'unknown'}"
1325
+ children = ""
1326
+ try:
1327
+ cards = (gateway.environment_card or {}).get("children_environments") or []
1328
+ children = ",".join(c.get("name") or "" for c in cards)
1329
+ async with asyncio.timeout(_TOOLS_PROBE_TIMEOUT_S):
1330
+ tools = set(await _tool_names(gateway.mcp_url))
1331
+ missing = [c.get("name") or "" for c in cards if not _card_tool_names(c) <= tools]
1332
+ except Exception as e:
1333
+ logger.warning(f"{head} status=failed duration_s={time.monotonic() - start:.1f} children={children} error_type={type(e).__name__} error={e!r}")
1334
+ return
1335
+ status = "ok" if tools and not missing else "missing"
1336
+ log = logger.info if status == "ok" else logger.warning
1337
+ log(f"{head} status={status} tools={len(tools)} duration_s={time.monotonic() - start:.1f} children={children} missing={','.join(missing)}")
1338
+
1339
+
1340
+ def _card_tool_names(card: dict) -> set[str]:
1341
+ return {tool.get("name") for tool in (card.get("capabilities") or {}).get("tools") or []}
1342
+
1343
+
1344
+ @dataclass(frozen=True)
1345
+ class _ContainerDeploy:
1346
+ """What every container of one container-mode deploy is created with."""
1347
+
1348
+ sandbox_provider: SandboxProvider
1349
+ cpu: float | None
1350
+ disk_size_gb: float
1351
+ ttl_seconds: int
1352
+ attribution: Optional[Attribution]
1353
+ priority: Optional[int]
1354
+ i6pn_kwargs: dict