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,113 @@
1
+ """Artifact Registry credentials for OciRegistryImageStore (the ``gcp`` extra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import threading
8
+ import time
9
+ from datetime import UTC, datetime, timedelta
10
+
11
+ import google.auth
12
+ from google.auth import impersonated_credentials
13
+ from google.api_core.exceptions import RetryError
14
+ from google.auth.exceptions import DefaultCredentialsError, RefreshError, TransportError
15
+
16
+ from agent_env.config.errors import ConfigError
17
+ from agent_env.store import _google
18
+ from agent_env.store.image_store.oci_registry_credentials import (
19
+ OciRegistryCredentials,
20
+ RegistryAuth,
21
+ normalize_registry_host,
22
+ )
23
+
24
+ # A token must outlast what it is handed to: an image build logs in with it to push at its end.
25
+ _MIN_REMAINING = timedelta(minutes=45)
26
+ _USERNAME = "oauth2accesstoken"
27
+ _TRANSIENT_IAM_CODES = frozenset({429, 500, 502, 503, 504})
28
+ # A mint that found IAM unreachable spent a whole retry window; callers that come just after it
29
+ # share its failure rather than each spending another.
30
+ _OUTAGE_REUSE_SECONDS = 10.0
31
+
32
+
33
+ def _transient(error: Exception) -> bool:
34
+ """A dropped connection, or IAM declining for now. google-auth reports an IAM refusal as a
35
+ RefreshError carrying the response body: a 429 or 5xx, or a body that is no JSON at all, as a
36
+ front end's error page is. A token endpoint's refusal carries a parsed mapping, and is final."""
37
+ if _google.dropped_connection(error):
38
+ return True
39
+ if not isinstance(error, RefreshError) or len(error.args) < 2 or not isinstance(error.args[1], str):
40
+ return False
41
+ try:
42
+ body = json.loads(error.args[1])
43
+ except ValueError:
44
+ return True
45
+ status = body.get("error") if isinstance(body, dict) else None
46
+ return isinstance(status, dict) and status.get("code") in _TRANSIENT_IAM_CODES
47
+
48
+
49
+ _MINT_RETRY = _google.RETRY.with_predicate(_transient)
50
+
51
+
52
+ class GoogleAccessTokenCredentials(OciRegistryCredentials):
53
+ """Docker-login material for Artifact Registry: an access token of ``service_account``, which
54
+ Application Default Credentials impersonate. The token is handed to sandboxes, so the account
55
+ should hold nothing but write access to the registry's repository."""
56
+
57
+ def __init__(self, service_account: str) -> None:
58
+ if not service_account.strip():
59
+ raise ConfigError("GoogleAccessTokenCredentials needs a service_account")
60
+ self._service_account = service_account.strip()
61
+ self._credentials: impersonated_credentials.Credentials | None = None
62
+ self._request = None
63
+ self._lock = threading.Lock()
64
+ self._failure: tuple[float, Exception] | None = None
65
+
66
+ def mint(self, host: str) -> RegistryAuth:
67
+ arrived = time.monotonic()
68
+ with self._lock:
69
+ # A caller that queued behind a failed attempt, or arrives just after IAM proved
70
+ # unreachable, gets its failure rather than another retry window.
71
+ if self._failure is not None:
72
+ failed_at, failure = self._failure
73
+ outage = isinstance(failure, TransportError) and time.monotonic() - failed_at < _OUTAGE_REUSE_SECONDS
74
+ if failed_at > arrived or outage:
75
+ raise copy.copy(failure).with_traceback(None) from failure
76
+ try:
77
+ token = self._token()
78
+ except Exception as e:
79
+ self._failure = (time.monotonic(), e)
80
+ raise
81
+ self._failure = None
82
+ return RegistryAuth(registry=normalize_registry_host(host), username=_USERNAME, password=token)
83
+
84
+ def _token(self) -> str:
85
+ if self._credentials is None:
86
+ self._credentials, self._request = self._impersonate()
87
+ if _remaining(self._credentials) < _MIN_REMAINING:
88
+ try:
89
+ _MINT_RETRY(self._credentials.refresh)(self._request)
90
+ except RefreshError as e:
91
+ # Look the credentials up again next time, in case they were replaced.
92
+ self._credentials = self._request = None
93
+ raise ConfigError(f"Cannot mint an access token as {self._service_account}: {e}") from e
94
+ except RetryError as e:
95
+ raise TransportError(f"IAM minted no access token as {self._service_account} within the retry window: {e.cause}") from e
96
+ return self._credentials.token
97
+
98
+ def _impersonate(self):
99
+ try:
100
+ source, _ = google.auth.default(scopes=_google.SCOPES)
101
+ except (DefaultCredentialsError, OSError) as e:
102
+ raise ConfigError(f"No Google credentials to impersonate {self._service_account} with: {e}") from e
103
+ credentials = impersonated_credentials.Credentials(
104
+ source_credentials=source, target_principal=self._service_account, target_scopes=_google.SCOPES
105
+ )
106
+ return credentials, _google.pooled_request()
107
+
108
+
109
+ def _remaining(credentials) -> timedelta:
110
+ if credentials.token is None:
111
+ return timedelta(0)
112
+ # google-auth keeps expiry as naive UTC.
113
+ return credentials.expiry - datetime.now(UTC).replace(tzinfo=None)
@@ -0,0 +1,113 @@
1
+ """Backend-agnostic container image (registry) store abstraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Mapping
7
+ from typing import Any, Self
8
+
9
+ from agent_env.store.image_store.oci_registry_credentials import (
10
+ OciRegistryCredentials,
11
+ RegistryAuth,
12
+ normalize_registry_host,
13
+ registry_host_from_ref,
14
+ )
15
+
16
+
17
+ class ImageStore(ABC):
18
+ """Image store interface. Images are addressed by a logical ``repository`` and ``tag``.
19
+
20
+ ``image_ref`` builds a fully-qualified, pullable ref; ``auth`` returns the
21
+ docker-login material a remote executor applies — the store never runs docker.
22
+ """
23
+
24
+ @classmethod
25
+ def from_config(cls, *args: Any, **config: Any) -> Self:
26
+ """Construct from a resolved config table; backends that build a client override this, taking
27
+ the table's keys as named parameters, which ``(*args, **config)`` lets a type checker accept."""
28
+ return cls(*args, **config)
29
+
30
+ @abstractmethod
31
+ def image_ref(self, repository: str, tag: str) -> str:
32
+ """Fully-qualified, pullable ref for ``(repository, tag)`` in this store."""
33
+
34
+ def ensure_repository(self, repository: str) -> None:
35
+ """Create the repository if the backend requires pre-creation; no-op otherwise."""
36
+
37
+ def owns(self, ref: str) -> bool:
38
+ """Whether ``ref`` is in this store, as ``auth`` decides it: a caller can tell a pull this
39
+ store authorizes from one it knows nothing about. The default owns nothing. Overriding
40
+ ``owns`` does not change which refs ``auth`` logs in for."""
41
+ return False
42
+
43
+ @abstractmethod
44
+ def auth(self, ref: str) -> RegistryAuth | None:
45
+ """Freshly-minted docker-login material for ``ref``, or None when none is needed
46
+ (anonymous/local registry, a public ref, or a ref not in this store)."""
47
+
48
+
49
+ class OciRegistryImageStore(ImageStore):
50
+ """Generic OCI registry whose repository names and credentials are configurable."""
51
+
52
+ def __init__(
53
+ self,
54
+ registry_host: str,
55
+ repository_prefix: str = "",
56
+ credentials: OciRegistryCredentials | None = None,
57
+ ) -> None:
58
+ self._registry = normalize_registry_host(registry_host)
59
+ self._prefix = repository_prefix
60
+ self._credentials = credentials
61
+
62
+ @classmethod
63
+ def from_config(
64
+ cls,
65
+ *,
66
+ registry_host: str,
67
+ repository_prefix: str = "",
68
+ credentials: OciRegistryCredentials | Mapping[str, object] | None = None,
69
+ ) -> Self:
70
+ if isinstance(credentials, Mapping):
71
+ from agent_env.config import ConfigError, load_impl
72
+
73
+ credential_config = dict(credentials)
74
+ impl = credential_config.pop("impl", None)
75
+ if not impl:
76
+ raise ConfigError(
77
+ "Registry credentials are missing an 'impl' dotted path"
78
+ )
79
+ credential_cls = load_impl(impl, OciRegistryCredentials)
80
+ credentials = credential_cls.from_config(**credential_config)
81
+ elif credentials is not None and not isinstance(
82
+ credentials, OciRegistryCredentials
83
+ ):
84
+ raise TypeError("credentials must implement OciRegistryCredentials")
85
+ return cls(
86
+ registry_host=registry_host,
87
+ repository_prefix=repository_prefix,
88
+ credentials=credentials,
89
+ )
90
+
91
+ @property
92
+ def registry_host(self) -> str:
93
+ return self._registry
94
+
95
+ @property
96
+ def credentials(self) -> OciRegistryCredentials | None:
97
+ return self._credentials
98
+
99
+ def image_ref(self, repository: str, tag: str) -> str:
100
+ return f"{self._registry}/{self._repo_name(repository)}:{tag}"
101
+
102
+ def owns(self, ref: str) -> bool:
103
+ """A ref on this store's registry, whatever its repository: a registry login covers the
104
+ whole host."""
105
+ return registry_host_from_ref(ref) == self._registry
106
+
107
+ def auth(self, ref: str) -> RegistryAuth | None:
108
+ if registry_host_from_ref(ref) != self._registry or self._credentials is None:
109
+ return None
110
+ return self._credentials.mint(self._registry)
111
+
112
+ def _repo_name(self, repository: str) -> str:
113
+ return f"{self._prefix}/{repository}" if self._prefix else repository
@@ -0,0 +1,106 @@
1
+ """Local OCI registry implementation of the ImageStore interface (no cloud services)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import time
7
+ from urllib.parse import urlsplit
8
+
9
+ from agent_env.store.image_store.image_store import OciRegistryImageStore
10
+ from agent_env.store.image_store.oci_registry_credentials import OciRegistryCredentials
11
+
12
+ _REGISTRY_CONTAINER = "agentenv-registry"
13
+
14
+
15
+ class LocalRegistryImageStore(OciRegistryImageStore):
16
+ """ImageStore backed by an anonymous local OCI registry.
17
+
18
+ Repositories are created automatically on push.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ registry_host: str = "localhost:5000",
24
+ repository_prefix: str = "",
25
+ credentials: OciRegistryCredentials | None = None,
26
+ ) -> None:
27
+ super().__init__(
28
+ registry_host=registry_host,
29
+ repository_prefix=repository_prefix,
30
+ credentials=credentials,
31
+ )
32
+
33
+ def ensure_repository(self, repository: str) -> None:
34
+ """A local registry creates repositories on push, so this instead lazily brings up the
35
+ backing ``registry:2`` — idempotently, and safe to call from many concurrent pushes. A
36
+ no-op when something already serves the registry at ``registry_host`` (a registry you
37
+ run yourself, or one a concurrent push already started); it never removes a container
38
+ that might be serving. Called from the image-push path, so no caller manages it."""
39
+ port = urlsplit(f"//{self.registry_host}").port or 5000
40
+ if self._registry_listening(port):
41
+ return
42
+ run = self._run_registry(port)
43
+ if run.returncode != 0:
44
+ # `docker run` creates the named container atomically, so a failure is either a name
45
+ # conflict (our container already exists) or something unrelated — disambiguate on
46
+ # the existing container instead of blindly retrying, so a real error isn't masked.
47
+ existing_port = self._container_configured_port(_REGISTRY_CONTAINER)
48
+ if existing_port is None:
49
+ # No such container: the failure is real — the port is taken by another process,
50
+ # the image pull failed, or the daemon is down. Surface it now, don't wait 30s.
51
+ raise RuntimeError(f"could not start the local registry: {run.stderr.strip()}")
52
+ if existing_port != port:
53
+ # A stale container of ours on a different port (registry_host changed since it
54
+ # was created). It isn't serving our port — the check above proved that — so
55
+ # replacing it is safe.
56
+ subprocess.run(["docker", "rm", "-f", _REGISTRY_CONTAINER], capture_output=True, text=True)
57
+ run = self._run_registry(port)
58
+ if run.returncode != 0:
59
+ raise RuntimeError(f"could not start the local registry: {run.stderr.strip()}")
60
+ else:
61
+ # A concurrent push won the race, or a stopped container of ours is on our port:
62
+ # (re)start it (a no-op if it's already running) and surface a genuine failure.
63
+ start = subprocess.run(["docker", "start", _REGISTRY_CONTAINER], capture_output=True, text=True)
64
+ if start.returncode != 0:
65
+ raise RuntimeError(
66
+ f"local registry container exists but could not be started: {start.stderr.strip()}"
67
+ )
68
+ deadline = time.time() + 30
69
+ while time.time() < deadline:
70
+ if self._registry_listening(port):
71
+ return
72
+ time.sleep(0.5)
73
+ raise RuntimeError(f"the local registry started but did not answer /v2/ on port {port} within 30s")
74
+
75
+ @staticmethod
76
+ def _run_registry(port: int) -> subprocess.CompletedProcess:
77
+ return subprocess.run(
78
+ ["docker", "run", "-d", "--restart", "unless-stopped", "--name", _REGISTRY_CONTAINER,
79
+ "-p", f"127.0.0.1:{port}:5000", "registry:2"],
80
+ capture_output=True, text=True,
81
+ )
82
+
83
+ @staticmethod
84
+ def _container_configured_port(name: str) -> int | None:
85
+ """The host port ``name`` maps container-port 5000 to (works for stopped containers too),
86
+ or None when no such container exists — i.e. the ``docker run`` failure was not a name
87
+ clash but a real error (port taken elsewhere / pull / daemon)."""
88
+ r = subprocess.run(
89
+ ["docker", "inspect", "-f",
90
+ '{{ (index (index .HostConfig.PortBindings "5000/tcp") 0).HostPort }}', name],
91
+ capture_output=True, text=True,
92
+ )
93
+ if r.returncode != 0:
94
+ return None
95
+ out = r.stdout.strip()
96
+ return int(out) if out.isdigit() else None
97
+
98
+ @staticmethod
99
+ def _registry_listening(port: int) -> bool:
100
+ """True if an OCI registry answers the ``/v2/`` ping on ``port`` (ours or one you run)."""
101
+ import httpx
102
+
103
+ try:
104
+ return httpx.get(f"http://localhost:{port}/v2/", timeout=2).status_code in (200, 401)
105
+ except Exception:
106
+ return False
@@ -0,0 +1,196 @@
1
+ """Credential providers for OCI registries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from urllib.parse import urlsplit
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RegistryAuth:
19
+ """docker-login material for a registry (the password may be a short-lived token)."""
20
+
21
+ registry: str
22
+ username: str
23
+ password: str = field(repr=False)
24
+
25
+
26
+ class OciRegistryCredentials(ABC):
27
+ """Produces docker-login material for a registry.
28
+
29
+ Implementations resolve secret or identity inputs inside ``mint`` rather than
30
+ ``__init__`` so a process-cached image store can observe credential rotation.
31
+ """
32
+
33
+ @classmethod
34
+ def from_config(cls, **config) -> OciRegistryCredentials:
35
+ return cls(**config)
36
+
37
+ @abstractmethod
38
+ def mint(self, host: str) -> RegistryAuth | None:
39
+ """Return credentials for ``host``, or ``None`` when none are available."""
40
+
41
+
42
+ # Docker Hub is special: unqualified image references resolve there, Docker config
43
+ # commonly keys its credentials by ``index.docker.io``, and pulls may use
44
+ # ``registry-1.docker.io``. Canonicalizing these aliases lets one credential entry
45
+ # cover all equivalent forms; every other registry still requires an exact host match.
46
+ _DOCKER_HUB_HOSTS = {
47
+ "docker.io",
48
+ "index.docker.io",
49
+ "registry-1.docker.io",
50
+ }
51
+
52
+
53
+ def normalize_registry_host(host: str) -> str:
54
+ """Normalize a registry authority without weakening exact host matching."""
55
+ normalized = host.strip().lower()
56
+ if not normalized:
57
+ raise ValueError("registry_host must not be empty")
58
+ if "://" in normalized or "/" in normalized:
59
+ raise ValueError("registry_host must be an authority without a scheme or path")
60
+ return "docker.io" if normalized in _DOCKER_HUB_HOSTS else normalized
61
+
62
+
63
+ def registry_host_from_ref(ref: str) -> str | None:
64
+ """Return the normalized registry authority from a Docker/OCI image reference.
65
+
66
+ Docker treats a first path component as a registry only when it is ``localhost``
67
+ or contains a dot or port. Unqualified references therefore resolve to Docker Hub.
68
+ Schemed values are not valid image references and deliberately do not match.
69
+ """
70
+ if not ref or "://" in ref:
71
+ return None
72
+ first, separator, _ = ref.partition("/")
73
+ if not first:
74
+ return None
75
+ if not separator or not (
76
+ first.lower() == "localhost" or "." in first or ":" in first
77
+ ):
78
+ return "docker.io"
79
+ try:
80
+ return normalize_registry_host(first)
81
+ except ValueError:
82
+ return None
83
+
84
+
85
+ class SecretStoreCredentials(OciRegistryCredentials):
86
+ """Read inline Docker-config credentials from the configured secret store."""
87
+
88
+ # The default is a secret-store lookup key, not credential material.
89
+ def __init__(self, secret_key: str = "registry_auths") -> None: # nosec B107
90
+ self._secret_key = secret_key
91
+
92
+ def mint(self, host: str) -> RegistryAuth | None:
93
+ from agent_env.config import ConfigError, get_config
94
+
95
+ raw = get_config().get_secret_store().get(self._secret_key)
96
+ if raw is None:
97
+ return None
98
+ try:
99
+ document = json.loads(raw)
100
+ except (TypeError, json.JSONDecodeError):
101
+ raise ConfigError(
102
+ f"Registry credential secret {self._secret_key!r} must be a JSON string "
103
+ "containing a Docker config 'auths' mapping; nested YAML mappings are not supported"
104
+ ) from None
105
+ if not isinstance(document, Mapping):
106
+ raise ConfigError(
107
+ f"Registry credential secret {self._secret_key!r} must contain a JSON object"
108
+ )
109
+
110
+ if "credsStore" in document or "credHelpers" in document:
111
+ logger.debug(
112
+ "Registry credential secret %r contains Docker credential-helper settings; "
113
+ "agent-env only reads inline auth entries",
114
+ self._secret_key,
115
+ )
116
+
117
+ auths = document.get("auths", document)
118
+ if not isinstance(auths, Mapping):
119
+ raise ConfigError(
120
+ f"Registry credential secret {self._secret_key!r} has a non-object 'auths' value"
121
+ )
122
+
123
+ normalized_host = normalize_registry_host(host)
124
+ entry = next(
125
+ (
126
+ candidate
127
+ for key, candidate in auths.items()
128
+ if _docker_config_registry_host(str(key)) == normalized_host
129
+ ),
130
+ None,
131
+ )
132
+ if entry is None:
133
+ return None
134
+ if not isinstance(entry, Mapping):
135
+ raise ConfigError(
136
+ f"Registry credential secret {self._secret_key!r} has invalid credentials "
137
+ f"for registry {normalized_host!r}"
138
+ )
139
+
140
+ if entry.get("auth") is not None:
141
+ username, password = _decode_auth(
142
+ entry["auth"], self._secret_key, normalized_host
143
+ )
144
+ else:
145
+ username = entry.get("username")
146
+ password = entry.get("password")
147
+ if not isinstance(username, str) or not isinstance(password, str):
148
+ raise ConfigError(
149
+ f"Registry credential secret {self._secret_key!r} must provide either 'auth' "
150
+ f"or string 'username'/'password' fields for registry {normalized_host!r}"
151
+ )
152
+ return RegistryAuth(
153
+ registry=normalized_host, username=username, password=password
154
+ )
155
+
156
+
157
+ def _docker_config_registry_host(key: str) -> str | None:
158
+ candidate = key.strip()
159
+ if not candidate:
160
+ return None
161
+ if "://" in candidate:
162
+ parsed = urlsplit(candidate)
163
+ if not parsed.hostname:
164
+ return None
165
+ try:
166
+ candidate = parsed.hostname
167
+ if parsed.port is not None:
168
+ candidate = f"{candidate}:{parsed.port}"
169
+ except ValueError:
170
+ return None
171
+ else:
172
+ candidate = candidate.split("/", 1)[0]
173
+ try:
174
+ return normalize_registry_host(candidate)
175
+ except ValueError:
176
+ return None
177
+
178
+
179
+ def _decode_auth(value: object, secret_key: str, host: str) -> tuple[str, str]:
180
+ if not isinstance(value, str):
181
+ raise _invalid_auth(secret_key, host)
182
+ try:
183
+ decoded = base64.b64decode(value, validate=True).decode()
184
+ username, password = decoded.split(":", 1)
185
+ except (binascii.Error, UnicodeDecodeError, ValueError):
186
+ raise _invalid_auth(secret_key, host) from None
187
+ return username, password
188
+
189
+
190
+ def _invalid_auth(secret_key: str, host: str):
191
+ from agent_env.config import ConfigError
192
+
193
+ return ConfigError(
194
+ f"Registry credential secret {secret_key!r} has invalid base64 'auth' "
195
+ f"for registry {host!r}"
196
+ )
@@ -0,0 +1,27 @@
1
+ """On-disk state directories for the local (no-infra) stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from agent_env.config.errors import ConfigError
8
+
9
+
10
+ def ensure_state_dir(path: Path) -> None:
11
+ """Create ``path`` if missing. A directory this creates is private to the user (``0o700``)
12
+ and gets a ``.gitignore`` of ``*`` so generated state is never committed; a directory that
13
+ already existed is left as is. A directory that cannot be created is a ``ConfigError``."""
14
+ try:
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ try:
17
+ path.mkdir(mode=0o700)
18
+ except FileExistsError:
19
+ if path.is_dir():
20
+ return
21
+ raise
22
+ (path / ".gitignore").write_text("*\n")
23
+ except OSError as e:
24
+ raise ConfigError(
25
+ f"cannot create the local store directory {path} ({e}); set XDG_STATE_HOME to a "
26
+ "writable directory, or point the store somewhere writable in config.toml"
27
+ ) from e
@@ -0,0 +1,24 @@
1
+ """Backend-agnostic object store abstraction + its implementations.
2
+
3
+ ``object_store`` defines the abstraction (ObjectStore); ``s3_object_store``,
4
+ ``local_object_store`` and ``gcs_object_store`` are implementations. The last needs
5
+ the ``gcp`` extra, so it is not re-exported here: an impl pointer names its module.
6
+ """
7
+
8
+ from agent_env.store.object_store.local_object_store import LocalFilesystemObjectStore
9
+ from agent_env.store.object_store.object_store import (
10
+ DEFAULT_CONTENT_TYPE,
11
+ ObjectMetadata,
12
+ ObjectStore,
13
+ UploadPolicy,
14
+ )
15
+ from agent_env.store.object_store.s3_object_store import S3ObjectStore
16
+
17
+ __all__ = [
18
+ "ObjectStore",
19
+ "ObjectMetadata",
20
+ "UploadPolicy",
21
+ "DEFAULT_CONTENT_TYPE",
22
+ "S3ObjectStore",
23
+ "LocalFilesystemObjectStore",
24
+ ]