agent-learning-kit 0.1.0__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 (642) hide show
  1. agent_learning_kit-0.1.0.dist-info/METADATA +381 -0
  2. agent_learning_kit-0.1.0.dist-info/RECORD +642 -0
  3. agent_learning_kit-0.1.0.dist-info/WHEEL +4 -0
  4. agent_learning_kit-0.1.0.dist-info/entry_points.txt +5 -0
  5. agent_learning_kit-0.1.0.dist-info/licenses/LICENSE +173 -0
  6. agent_learning_kit-0.1.0.dist-info/licenses/NOTICE +7 -0
  7. fi/__init__.py +5 -0
  8. fi/alk/__init__.py +57 -0
  9. fi/alk/_facade.py +31 -0
  10. fi/alk/_module_alias.py +68 -0
  11. fi/alk/_paths.py +14 -0
  12. fi/alk/_schema.py +522 -0
  13. fi/alk/actions.py +727 -0
  14. fi/alk/bench/__init__.py +517 -0
  15. fi/alk/bench/_codeexec.py +213 -0
  16. fi/alk/bench/_coding.py +215 -0
  17. fi/alk/bench/_docker.py +237 -0
  18. fi/alk/bench/_grader.py +286 -0
  19. fi/alk/bench/_pull.py +212 -0
  20. fi/alk/bench/_voice.py +147 -0
  21. fi/alk/capabilities.py +627 -0
  22. fi/alk/cli.py +6396 -0
  23. fi/alk/config.py +130 -0
  24. fi/alk/cua_loop.py +562 -0
  25. fi/alk/evals.py +2351 -0
  26. fi/alk/extensions.py +163 -0
  27. fi/alk/harness/ARCHITECTURE.md +231 -0
  28. fi/alk/harness/DESIGN.md +246 -0
  29. fi/alk/harness/ENVIRONMENT_CONFORMANCE.md +127 -0
  30. fi/alk/harness/HOW-IT-WORKS.md +297 -0
  31. fi/alk/harness/IMPLEMENTATION_AND_VALIDATION_STATUS.md +229 -0
  32. fi/alk/harness/README.md +417 -0
  33. fi/alk/harness/__init__.py +77 -0
  34. fi/alk/harness/__main__.py +3 -0
  35. fi/alk/harness/amend.py +312 -0
  36. fi/alk/harness/artifacts.py +319 -0
  37. fi/alk/harness/authoring_entrypoint.py +189 -0
  38. fi/alk/harness/authoring_runtime_validation.py +267 -0
  39. fi/alk/harness/backends/README.md +43 -0
  40. fi/alk/harness/backends/__init__.py +122 -0
  41. fi/alk/harness/backends/base.py +241 -0
  42. fi/alk/harness/backends/claude.py +211 -0
  43. fi/alk/harness/backends/files.py +182 -0
  44. fi/alk/harness/backends/vertex_gemini.py +457 -0
  45. fi/alk/harness/background_noise.py +95 -0
  46. fi/alk/harness/build.py +385 -0
  47. fi/alk/harness/bundle.py +593 -0
  48. fi/alk/harness/bundle_author_v2.py +1831 -0
  49. fi/alk/harness/bundle_v2.py +719 -0
  50. fi/alk/harness/call_runner.py +1440 -0
  51. fi/alk/harness/callback_http_adapter.py +111 -0
  52. fi/alk/harness/catalogue.py +287 -0
  53. fi/alk/harness/chat.py +428 -0
  54. fi/alk/harness/chat_call_runner.py +506 -0
  55. fi/alk/harness/checks.py +136 -0
  56. fi/alk/harness/cli.py +1354 -0
  57. fi/alk/harness/config.py +338 -0
  58. fi/alk/harness/contract.py +718 -0
  59. fi/alk/harness/credentials.py +674 -0
  60. fi/alk/harness/data/persona_vocabulary.json +111 -0
  61. fi/alk/harness/environment.py +99 -0
  62. fi/alk/harness/environment_plan.py +168 -0
  63. fi/alk/harness/events.py +125 -0
  64. fi/alk/harness/executor.py +304 -0
  65. fi/alk/harness/folder.py +234 -0
  66. fi/alk/harness/generated_runtime.py +815 -0
  67. fi/alk/harness/github.py +72 -0
  68. fi/alk/harness/hosted_authoring_entrypoint.py +183 -0
  69. fi/alk/harness/hosted_entrypoint.py +2402 -0
  70. fi/alk/harness/hosted_scheduler.py +2218 -0
  71. fi/alk/harness/job.py +426 -0
  72. fi/alk/harness/judge.py +184 -0
  73. fi/alk/harness/livekit_source.py +50 -0
  74. fi/alk/harness/livekit_tool_trace_bootstrap.py +71 -0
  75. fi/alk/harness/observability.py +208 -0
  76. fi/alk/harness/outbound.py +3252 -0
  77. fi/alk/harness/packaging.py +515 -0
  78. fi/alk/harness/persona_guides.py +157 -0
  79. fi/alk/harness/platform.py +692 -0
  80. fi/alk/harness/process_preflight.py +764 -0
  81. fi/alk/harness/process_runtime.py +5670 -0
  82. fi/alk/harness/prove.py +425 -0
  83. fi/alk/harness/provider_import.py +703 -0
  84. fi/alk/harness/provider_lifecycle.py +392 -0
  85. fi/alk/harness/provision.py +2896 -0
  86. fi/alk/harness/reception.py +147 -0
  87. fi/alk/harness/retell_chat_call_runner.py +373 -0
  88. fi/alk/harness/run/__init__.py +296 -0
  89. fi/alk/harness/run/alk.py +184 -0
  90. fi/alk/harness/run/call.py +162 -0
  91. fi/alk/harness/run/conversation.py +264 -0
  92. fi/alk/harness/run/data/voices_by_language_and_gender.json +693 -0
  93. fi/alk/harness/run/evidence.py +195 -0
  94. fi/alk/harness/run/grade.py +598 -0
  95. fi/alk/harness/run/live.py +297 -0
  96. fi/alk/harness/run/models.py +56 -0
  97. fi/alk/harness/run/platform_evals.py +227 -0
  98. fi/alk/harness/run/sdk_voice.py +130 -0
  99. fi/alk/harness/run/simulation.py +1209 -0
  100. fi/alk/harness/run/stage.py +91 -0
  101. fi/alk/harness/run/targets.py +508 -0
  102. fi/alk/harness/run/tools.py +601 -0
  103. fi/alk/harness/run/voice.py +340 -0
  104. fi/alk/harness/runtime.py +172 -0
  105. fi/alk/harness/sandbox_server.py +2011 -0
  106. fi/alk/harness/sandbox_worker.py +44 -0
  107. fi/alk/harness/scenario.py +1048 -0
  108. fi/alk/harness/scenario_source.py +879 -0
  109. fi/alk/harness/scenario_tools.py +1143 -0
  110. fi/alk/harness/scenarios.py +915 -0
  111. fi/alk/harness/secrets.py +168 -0
  112. fi/alk/harness/service_catalog.py +97 -0
  113. fi/alk/harness/session.py +391 -0
  114. fi/alk/harness/sessions.py +372 -0
  115. fi/alk/harness/simulator.py +76 -0
  116. fi/alk/harness/simulator_voice.py +928 -0
  117. fi/alk/harness/skills/build-environment/SKILL.md +538 -0
  118. fi/alk/harness/skills/harness.md +131 -0
  119. fi/alk/harness/skills/kinds/chat.md +48 -0
  120. fi/alk/harness/skills/kinds/voice-voicemail.md +63 -0
  121. fi/alk/harness/skills/kinds/voice.md +59 -0
  122. fi/alk/harness/skills/plan-suite/SKILL.md +103 -0
  123. fi/alk/harness/skills/provision-environment/SKILL.md +136 -0
  124. fi/alk/harness/skills/run-scenarios/SKILL.md +112 -0
  125. fi/alk/harness/skills/understand-agent/SKILL.md +251 -0
  126. fi/alk/harness/skills/write-scenarios/SKILL.md +606 -0
  127. fi/alk/harness/skills/write-scenarios/references/refusals.md +28 -0
  128. fi/alk/harness/skills/write-scenarios/references/world-api.md +92 -0
  129. fi/alk/harness/source_data_invariants.py +444 -0
  130. fi/alk/harness/source_tool_evidence.py +79 -0
  131. fi/alk/harness/sources.py +253 -0
  132. fi/alk/harness/spend.py +140 -0
  133. fi/alk/harness/tool_trace_proxy.py +104 -0
  134. fi/alk/harness/tools.py +1018 -0
  135. fi/alk/harness/understand.py +169 -0
  136. fi/alk/harness/voicemail_audio.py +74 -0
  137. fi/alk/harness/world/__init__.py +33 -0
  138. fi/alk/harness/world/errors.py +68 -0
  139. fi/alk/harness/world/expectations.py +91 -0
  140. fi/alk/harness/world/handle.py +538 -0
  141. fi/alk/harness/world/kinds.py +196 -0
  142. fi/alk/harness/world/mutate.py +186 -0
  143. fi/alk/harness/world/probe.py +413 -0
  144. fi/alk/harness/world/provision.py +511 -0
  145. fi/alk/harness/world/provisioned.py +191 -0
  146. fi/alk/harness/world/runtime.py +616 -0
  147. fi/alk/harness/world/snapshot.py +288 -0
  148. fi/alk/harness/world/stores/__init__.py +305 -0
  149. fi/alk/harness/world/stores/container.py +215 -0
  150. fi/alk/harness/world/stores/inprocess.py +346 -0
  151. fi/alk/harness/world/stores/postgres.py +481 -0
  152. fi/alk/harness/world/stores/prove.py +202 -0
  153. fi/alk/harness/world/stores/sqlite.py +245 -0
  154. fi/alk/harness/world/stores/written.py +182 -0
  155. fi/alk/harness/world/tools.py +1516 -0
  156. fi/alk/harness/world/workspace.py +144 -0
  157. fi/alk/image_loop.py +453 -0
  158. fi/alk/image_perturb.py +241 -0
  159. fi/alk/improve.py +274 -0
  160. fi/alk/live/__init__.py +154 -0
  161. fi/alk/live/_attribution.py +184 -0
  162. fi/alk/live/_capture.py +264 -0
  163. fi/alk/live/_codec.py +391 -0
  164. fi/alk/live/_contract.py +134 -0
  165. fi/alk/live/_loopback.py +316 -0
  166. fi/alk/live/_perturb.py +449 -0
  167. fi/alk/live/_runner.py +386 -0
  168. fi/alk/live/_stats.py +561 -0
  169. fi/alk/live/_transcript.py +240 -0
  170. fi/alk/live/_workers/__init__.py +9 -0
  171. fi/alk/live/_workers/a2a_worker.py +316 -0
  172. fi/alk/live/_workers/langgraph_worker.py +217 -0
  173. fi/alk/live/_workers/livekit_worker.py +207 -0
  174. fi/alk/live/_workers/mcp_loopback_server.py +46 -0
  175. fi/alk/live/_workers/mcp_worker.py +158 -0
  176. fi/alk/live/_workers/pipecat_worker.py +189 -0
  177. fi/alk/live/a2a_lane.py +138 -0
  178. fi/alk/live/langgraph_lane.py +339 -0
  179. fi/alk/live/livekit_lane.py +376 -0
  180. fi/alk/live/mcp_lane.py +172 -0
  181. fi/alk/live/pipecat_lane.py +341 -0
  182. fi/alk/live/voice_redteam.py +494 -0
  183. fi/alk/loss.py +306 -0
  184. fi/alk/optimize.py +36260 -0
  185. fi/alk/practice/__init__.py +51 -0
  186. fi/alk/practice/_assess.py +103 -0
  187. fi/alk/practice/_budget.py +81 -0
  188. fi/alk/practice/_calibrate.py +69 -0
  189. fi/alk/practice/_capstone.py +86 -0
  190. fi/alk/practice/_contract.py +91 -0
  191. fi/alk/practice/_diagnose.py +79 -0
  192. fi/alk/practice/_drill.py +196 -0
  193. fi/alk/practice/_experiment.py +720 -0
  194. fi/alk/practice/_schedule.py +102 -0
  195. fi/alk/practice/_store.py +194 -0
  196. fi/alk/practice/_trainer.py +245 -0
  197. fi/alk/practice/_update.py +125 -0
  198. fi/alk/redteam.py +2621 -0
  199. fi/alk/rewardhack.py +237 -0
  200. fi/alk/simulate.py +10351 -0
  201. fi/alk/studio/__init__.py +82 -0
  202. fi/alk/studio/_bias.py +314 -0
  203. fi/alk/studio/_calibration.py +522 -0
  204. fi/alk/studio/_coverage.py +262 -0
  205. fi/alk/studio/_download.py +665 -0
  206. fi/alk/studio/_fidelity_attack.py +114 -0
  207. fi/alk/studio/_generate.py +652 -0
  208. fi/alk/studio/_library.py +370 -0
  209. fi/alk/studio/_scan.py +134 -0
  210. fi/alk/studio/_upgrade.py +42 -0
  211. fi/alk/studio/_vendor.py +172 -0
  212. fi/alk/suite.py +4200 -0
  213. fi/alk/tasks.py +828 -0
  214. fi/alk/telemetry/__init__.py +149 -0
  215. fi/alk/telemetry/_contract.py +141 -0
  216. fi/alk/telemetry/_emit.py +182 -0
  217. fi/alk/telemetry/_ledger.py +296 -0
  218. fi/alk/telemetry/_queue.py +127 -0
  219. fi/alk/telemetry/_row.py +294 -0
  220. fi/alk/telemetry/_run.py +233 -0
  221. fi/alk/telemetry/_sync.py +193 -0
  222. fi/alk/telemetry/_url.py +119 -0
  223. fi/alk/trinity.py +49397 -0
  224. fi/alk/voice_loop.py +174 -0
  225. fi/api/__init__.py +1 -0
  226. fi/api/auth.py +137 -0
  227. fi/api/types.py +29 -0
  228. fi/cli/__init__.py +9 -0
  229. fi/cli/assertions/__init__.py +25 -0
  230. fi/cli/assertions/conditions.py +76 -0
  231. fi/cli/assertions/evaluator.py +286 -0
  232. fi/cli/assertions/exit_codes.py +20 -0
  233. fi/cli/assertions/parser.py +131 -0
  234. fi/cli/assertions/reporter.py +194 -0
  235. fi/cli/commands/__init__.py +9 -0
  236. fi/cli/commands/config.py +165 -0
  237. fi/cli/commands/export.py +208 -0
  238. fi/cli/commands/init.py +112 -0
  239. fi/cli/commands/list_cmd.py +213 -0
  240. fi/cli/commands/run.py +486 -0
  241. fi/cli/commands/validate.py +173 -0
  242. fi/cli/commands/view.py +424 -0
  243. fi/cli/config/__init__.py +6 -0
  244. fi/cli/config/defaults.py +206 -0
  245. fi/cli/config/loader.py +155 -0
  246. fi/cli/config/schema.py +174 -0
  247. fi/cli/main.py +78 -0
  248. fi/cli/output/__init__.py +6 -0
  249. fi/cli/output/formatters.py +106 -0
  250. fi/cli/output/reporters.py +46 -0
  251. fi/cli/storage/__init__.py +5 -0
  252. fi/cli/storage/run_history.py +249 -0
  253. fi/cli/utils/__init__.py +5 -0
  254. fi/cli/utils/console.py +44 -0
  255. fi/evals/__init__.py +131 -0
  256. fi/evals/autoeval/__init__.py +137 -0
  257. fi/evals/autoeval/analyzer.py +211 -0
  258. fi/evals/autoeval/config.py +244 -0
  259. fi/evals/autoeval/export.py +213 -0
  260. fi/evals/autoeval/interactive.py +283 -0
  261. fi/evals/autoeval/pipeline.py +625 -0
  262. fi/evals/autoeval/prompts.py +139 -0
  263. fi/evals/autoeval/recommender.py +242 -0
  264. fi/evals/autoeval/rules.py +589 -0
  265. fi/evals/autoeval/templates.py +299 -0
  266. fi/evals/autoeval/types.py +232 -0
  267. fi/evals/core/__init__.py +16 -0
  268. fi/evals/core/cloud_registry.py +184 -0
  269. fi/evals/core/engines.py +368 -0
  270. fi/evals/core/evaluate.py +319 -0
  271. fi/evals/core/judge_prompt.py +90 -0
  272. fi/evals/core/prompt_generator.py +83 -0
  273. fi/evals/core/registry.py +57 -0
  274. fi/evals/core/result.py +55 -0
  275. fi/evals/evaluator.py +721 -0
  276. fi/evals/execution.py +168 -0
  277. fi/evals/feedback/__init__.py +32 -0
  278. fi/evals/feedback/calibrator.py +160 -0
  279. fi/evals/feedback/collector.py +214 -0
  280. fi/evals/feedback/hooks.py +81 -0
  281. fi/evals/feedback/retriever.py +128 -0
  282. fi/evals/feedback/store.py +272 -0
  283. fi/evals/feedback/types.py +99 -0
  284. fi/evals/framework/README.md +79 -0
  285. fi/evals/framework/__init__.py +267 -0
  286. fi/evals/framework/backends/Dockerfile.eval-runner +33 -0
  287. fi/evals/framework/backends/__init__.py +99 -0
  288. fi/evals/framework/backends/_container.py +141 -0
  289. fi/evals/framework/backends/_utils.py +145 -0
  290. fi/evals/framework/backends/base.py +223 -0
  291. fi/evals/framework/backends/celery_backend.py +417 -0
  292. fi/evals/framework/backends/celery_worker.py +78 -0
  293. fi/evals/framework/backends/kubernetes_backend.py +665 -0
  294. fi/evals/framework/backends/ray_backend.py +521 -0
  295. fi/evals/framework/backends/temporal.py +350 -0
  296. fi/evals/framework/backends/temporal_worker.py +126 -0
  297. fi/evals/framework/backends/thread_pool.py +286 -0
  298. fi/evals/framework/context.py +258 -0
  299. fi/evals/framework/enrichment.py +306 -0
  300. fi/evals/framework/evals/__init__.py +68 -0
  301. fi/evals/framework/evals/agentic.py +399 -0
  302. fi/evals/framework/evals/builder.py +609 -0
  303. fi/evals/framework/evals/semantic.py +142 -0
  304. fi/evals/framework/evaluator.py +647 -0
  305. fi/evals/framework/evaluators/__init__.py +22 -0
  306. fi/evals/framework/evaluators/blocking.py +347 -0
  307. fi/evals/framework/evaluators/non_blocking.py +577 -0
  308. fi/evals/framework/propagation.py +421 -0
  309. fi/evals/framework/protocols.py +385 -0
  310. fi/evals/framework/registry.py +370 -0
  311. fi/evals/framework/resilience/__init__.py +150 -0
  312. fi/evals/framework/resilience/circuit_breaker.py +309 -0
  313. fi/evals/framework/resilience/degradation.py +355 -0
  314. fi/evals/framework/resilience/health.py +505 -0
  315. fi/evals/framework/resilience/rate_limiter.py +228 -0
  316. fi/evals/framework/resilience/retry.py +274 -0
  317. fi/evals/framework/resilience/types.py +288 -0
  318. fi/evals/framework/resilience/wrapper.py +433 -0
  319. fi/evals/framework/types.py +218 -0
  320. fi/evals/guardrails/README.md +915 -0
  321. fi/evals/guardrails/__init__.py +96 -0
  322. fi/evals/guardrails/backends/__init__.py +43 -0
  323. fi/evals/guardrails/backends/azure.py +361 -0
  324. fi/evals/guardrails/backends/base.py +88 -0
  325. fi/evals/guardrails/backends/generic_llm.py +163 -0
  326. fi/evals/guardrails/backends/granite.py +216 -0
  327. fi/evals/guardrails/backends/llamaguard.py +221 -0
  328. fi/evals/guardrails/backends/local_base.py +479 -0
  329. fi/evals/guardrails/backends/openai.py +365 -0
  330. fi/evals/guardrails/backends/qwen.py +170 -0
  331. fi/evals/guardrails/backends/shieldgemma.py +154 -0
  332. fi/evals/guardrails/backends/turing.py +235 -0
  333. fi/evals/guardrails/backends/vllm_client.py +321 -0
  334. fi/evals/guardrails/backends/wildguard.py +188 -0
  335. fi/evals/guardrails/base.py +888 -0
  336. fi/evals/guardrails/config.py +221 -0
  337. fi/evals/guardrails/discovery.py +243 -0
  338. fi/evals/guardrails/gateway.py +437 -0
  339. fi/evals/guardrails/registry.py +231 -0
  340. fi/evals/guardrails/scanners/__init__.py +127 -0
  341. fi/evals/guardrails/scanners/base.py +191 -0
  342. fi/evals/guardrails/scanners/code_injection.py +243 -0
  343. fi/evals/guardrails/scanners/eval_delegate.py +574 -0
  344. fi/evals/guardrails/scanners/invisible_chars.py +351 -0
  345. fi/evals/guardrails/scanners/jailbreak.py +412 -0
  346. fi/evals/guardrails/scanners/language.py +288 -0
  347. fi/evals/guardrails/scanners/pipeline.py +260 -0
  348. fi/evals/guardrails/scanners/regex.py +311 -0
  349. fi/evals/guardrails/scanners/secrets.py +274 -0
  350. fi/evals/guardrails/scanners/topics.py +649 -0
  351. fi/evals/guardrails/scanners/urls.py +341 -0
  352. fi/evals/guardrails/types.py +96 -0
  353. fi/evals/llm/__init__.py +3 -0
  354. fi/evals/llm/base_llm_provider.py +35 -0
  355. fi/evals/llm/providers/litellm.py +70 -0
  356. fi/evals/local/__init__.py +90 -0
  357. fi/evals/local/evaluator.py +690 -0
  358. fi/evals/local/execution_mode.py +121 -0
  359. fi/evals/local/llm.py +489 -0
  360. fi/evals/local/metrics/__init__.py +19 -0
  361. fi/evals/local/registry.py +360 -0
  362. fi/evals/manager.py +1018 -0
  363. fi/evals/manager_types.py +362 -0
  364. fi/evals/metrics/__init__.py +185 -0
  365. fi/evals/metrics/agents/__init__.py +74 -0
  366. fi/evals/metrics/agents/metrics.py +693 -0
  367. fi/evals/metrics/agents/report.py +36463 -0
  368. fi/evals/metrics/agents/types.py +160 -0
  369. fi/evals/metrics/base_llm_metric.py +111 -0
  370. fi/evals/metrics/base_metric.py +138 -0
  371. fi/evals/metrics/code_security/__init__.py +305 -0
  372. fi/evals/metrics/code_security/analyzer.py +985 -0
  373. fi/evals/metrics/code_security/benchmarks/__init__.py +73 -0
  374. fi/evals/metrics/code_security/benchmarks/builtin.py +750 -0
  375. fi/evals/metrics/code_security/benchmarks/loader.py +580 -0
  376. fi/evals/metrics/code_security/benchmarks/types.py +308 -0
  377. fi/evals/metrics/code_security/detectors/__init__.py +186 -0
  378. fi/evals/metrics/code_security/detectors/base.py +394 -0
  379. fi/evals/metrics/code_security/detectors/cryptography.py +345 -0
  380. fi/evals/metrics/code_security/detectors/injection.py +744 -0
  381. fi/evals/metrics/code_security/detectors/secrets.py +287 -0
  382. fi/evals/metrics/code_security/detectors/serialization.py +192 -0
  383. fi/evals/metrics/code_security/joint_metrics.py +588 -0
  384. fi/evals/metrics/code_security/judges/__init__.py +83 -0
  385. fi/evals/metrics/code_security/judges/base.py +238 -0
  386. fi/evals/metrics/code_security/judges/dual_judge.py +534 -0
  387. fi/evals/metrics/code_security/judges/llm_judge.py +301 -0
  388. fi/evals/metrics/code_security/judges/pattern_judge.py +515 -0
  389. fi/evals/metrics/code_security/metrics.py +388 -0
  390. fi/evals/metrics/code_security/modes/__init__.py +63 -0
  391. fi/evals/metrics/code_security/modes/adversarial.py +284 -0
  392. fi/evals/metrics/code_security/modes/autocomplete.py +198 -0
  393. fi/evals/metrics/code_security/modes/base.py +283 -0
  394. fi/evals/metrics/code_security/modes/instruct.py +253 -0
  395. fi/evals/metrics/code_security/modes/repair.py +230 -0
  396. fi/evals/metrics/code_security/reports/__init__.py +57 -0
  397. fi/evals/metrics/code_security/reports/generator.py +404 -0
  398. fi/evals/metrics/code_security/reports/leaderboard.py +509 -0
  399. fi/evals/metrics/code_security/types.py +534 -0
  400. fi/evals/metrics/function_calling/__init__.py +34 -0
  401. fi/evals/metrics/function_calling/metrics.py +573 -0
  402. fi/evals/metrics/function_calling/types.py +87 -0
  403. fi/evals/metrics/hallucination/__init__.py +54 -0
  404. fi/evals/metrics/hallucination/detector.py +149 -0
  405. fi/evals/metrics/hallucination/metrics.py +390 -0
  406. fi/evals/metrics/hallucination/nli.py +253 -0
  407. fi/evals/metrics/hallucination/sentinel.py +106 -0
  408. fi/evals/metrics/hallucination/types.py +132 -0
  409. fi/evals/metrics/heuristics/aggregation_metrics.py +85 -0
  410. fi/evals/metrics/heuristics/json_metrics.py +87 -0
  411. fi/evals/metrics/heuristics/similarity_metrics.py +375 -0
  412. fi/evals/metrics/heuristics/string_metrics.py +391 -0
  413. fi/evals/metrics/llm_as_judges/__init__.py +17 -0
  414. fi/evals/metrics/llm_as_judges/custom_judge/metric.py +112 -0
  415. fi/evals/metrics/llm_as_judges/custom_judge/prompts.py +26 -0
  416. fi/evals/metrics/llm_as_judges/types.py +48 -0
  417. fi/evals/metrics/rag/__init__.py +111 -0
  418. fi/evals/metrics/rag/advanced/__init__.py +14 -0
  419. fi/evals/metrics/rag/advanced/multi_hop.py +283 -0
  420. fi/evals/metrics/rag/advanced/source_attribution.py +344 -0
  421. fi/evals/metrics/rag/generation/__init__.py +17 -0
  422. fi/evals/metrics/rag/generation/answer_relevancy.py +176 -0
  423. fi/evals/metrics/rag/generation/context_utilization.py +245 -0
  424. fi/evals/metrics/rag/generation/faithfulness.py +241 -0
  425. fi/evals/metrics/rag/generation/groundedness.py +131 -0
  426. fi/evals/metrics/rag/rag_score.py +277 -0
  427. fi/evals/metrics/rag/retrieval/__init__.py +20 -0
  428. fi/evals/metrics/rag/retrieval/context_entity_recall.py +124 -0
  429. fi/evals/metrics/rag/retrieval/context_precision.py +158 -0
  430. fi/evals/metrics/rag/retrieval/context_recall.py +106 -0
  431. fi/evals/metrics/rag/retrieval/noise_sensitivity.py +163 -0
  432. fi/evals/metrics/rag/retrieval/ranking.py +261 -0
  433. fi/evals/metrics/rag/types.py +100 -0
  434. fi/evals/metrics/rag/utils/__init__.py +62 -0
  435. fi/evals/metrics/rag/utils/claims.py +189 -0
  436. fi/evals/metrics/rag/utils/entities.py +244 -0
  437. fi/evals/metrics/rag/utils/nli.py +92 -0
  438. fi/evals/metrics/rag/utils/similarity.py +345 -0
  439. fi/evals/metrics/structured/__init__.py +114 -0
  440. fi/evals/metrics/structured/field_completeness.py +313 -0
  441. fi/evals/metrics/structured/hierarchy_score.py +366 -0
  442. fi/evals/metrics/structured/json_validation.py +190 -0
  443. fi/evals/metrics/structured/schema_compliance.py +280 -0
  444. fi/evals/metrics/structured/structured_output_score.py +298 -0
  445. fi/evals/metrics/structured/types.py +108 -0
  446. fi/evals/metrics/structured/validators/__init__.py +30 -0
  447. fi/evals/metrics/structured/validators/base.py +189 -0
  448. fi/evals/metrics/structured/validators/json_validator.py +196 -0
  449. fi/evals/metrics/structured/validators/pydantic_validator.py +178 -0
  450. fi/evals/metrics/structured/validators/yaml_validator.py +248 -0
  451. fi/evals/otel/__init__.py +266 -0
  452. fi/evals/otel/config.py +400 -0
  453. fi/evals/otel/conventions.py +463 -0
  454. fi/evals/otel/enrichment.py +371 -0
  455. fi/evals/otel/instrumentors/__init__.py +140 -0
  456. fi/evals/otel/instrumentors/anthropic.py +517 -0
  457. fi/evals/otel/instrumentors/base.py +382 -0
  458. fi/evals/otel/instrumentors/openai.py +673 -0
  459. fi/evals/otel/processors/__init__.py +36 -0
  460. fi/evals/otel/processors/base.py +473 -0
  461. fi/evals/otel/processors/cost.py +445 -0
  462. fi/evals/otel/processors/evaluation.py +559 -0
  463. fi/evals/otel/processors/llm.py +462 -0
  464. fi/evals/otel/tracer.py +506 -0
  465. fi/evals/otel/types.py +232 -0
  466. fi/evals/otel_utils.py +23 -0
  467. fi/evals/protect.py +671 -0
  468. fi/evals/protect_input_adapter.py +154 -0
  469. fi/evals/streaming/__init__.py +88 -0
  470. fi/evals/streaming/buffer.py +213 -0
  471. fi/evals/streaming/evaluator.py +551 -0
  472. fi/evals/streaming/policy.py +307 -0
  473. fi/evals/streaming/scorers.py +368 -0
  474. fi/evals/streaming/types.py +238 -0
  475. fi/evals/templates.py +472 -0
  476. fi/evals/types.py +156 -0
  477. fi/opt/__init__.py +221 -0
  478. fi/opt/_objective_scoring.py +85 -0
  479. fi/opt/base/__init__.py +11 -0
  480. fi/opt/base/base_generator.py +33 -0
  481. fi/opt/base/base_mapper.py +26 -0
  482. fi/opt/base/base_optimizer.py +45 -0
  483. fi/opt/base/evaluator.py +211 -0
  484. fi/opt/components.py +3095 -0
  485. fi/opt/datamappers/__init__.py +3 -0
  486. fi/opt/datamappers/basic_mapper.py +40 -0
  487. fi/opt/deployment.py +1021 -0
  488. fi/opt/evidence.py +4332 -0
  489. fi/opt/generators/__init__.py +3 -0
  490. fi/opt/generators/litellm.py +66 -0
  491. fi/opt/integrations/__init__.py +23 -0
  492. fi/opt/integrations/generative_suite.py +410 -0
  493. fi/opt/integrations/simulate.py +1313 -0
  494. fi/opt/mutations.py +771 -0
  495. fi/opt/observability.py +4639 -0
  496. fi/opt/optimizer_trace.py +889 -0
  497. fi/opt/optimizers/__init__.py +80 -0
  498. fi/opt/optimizers/agent.py +331 -0
  499. fi/opt/optimizers/agent_bandit.py +392 -0
  500. fi/opt/optimizers/agent_curriculum.py +635 -0
  501. fi/opt/optimizers/agent_evolution.py +894 -0
  502. fi/opt/optimizers/agent_feedback.py +1863 -0
  503. fi/opt/optimizers/agent_pareto.py +547 -0
  504. fi/opt/optimizers/agent_social_memory.py +1113 -0
  505. fi/opt/optimizers/agent_tpe.py +321 -0
  506. fi/opt/optimizers/bayesian_search.py +449 -0
  507. fi/opt/optimizers/council.py +2075 -0
  508. fi/opt/optimizers/futureagi_replay.py +799 -0
  509. fi/opt/optimizers/gepa.py +322 -0
  510. fi/opt/optimizers/metaprompt.py +243 -0
  511. fi/opt/optimizers/promptwizard.py +417 -0
  512. fi/opt/optimizers/protegi.py +329 -0
  513. fi/opt/optimizers/random_search.py +224 -0
  514. fi/opt/research.py +518 -0
  515. fi/opt/simulation.py +260 -0
  516. fi/opt/targets.py +232 -0
  517. fi/opt/types.py +66 -0
  518. fi/opt/utils/__init__.py +4 -0
  519. fi/opt/utils/early_stopping.py +266 -0
  520. fi/opt/utils/setup_logging.py +82 -0
  521. fi/simulate/__init__.py +540 -0
  522. fi/simulate/_hashing.py +35 -0
  523. fi/simulate/_logging.py +10 -0
  524. fi/simulate/adapters.py +87 -0
  525. fi/simulate/agent/__init__.py +120 -0
  526. fi/simulate/agent/browser.py +658 -0
  527. fi/simulate/agent/definition.py +587 -0
  528. fi/simulate/agent/frameworks.py +3528 -0
  529. fi/simulate/agent/generic.py +8286 -0
  530. fi/simulate/agent/import_probe.py +227 -0
  531. fi/simulate/agent/memory.py +905 -0
  532. fi/simulate/agent/mocks.py +101 -0
  533. fi/simulate/agent/multi_agent.py +361 -0
  534. fi/simulate/agent/orchestration.py +903 -0
  535. fi/simulate/agent/realtime.py +665 -0
  536. fi/simulate/agent/wrapper.py +99 -0
  537. fi/simulate/agent/wrappers/__init__.py +18 -0
  538. fi/simulate/agent/wrappers/anthropic.py +62 -0
  539. fi/simulate/agent/wrappers/gemini.py +65 -0
  540. fi/simulate/agent/wrappers/http.py +404 -0
  541. fi/simulate/agent/wrappers/langchain.py +80 -0
  542. fi/simulate/agent/wrappers/openai.py +75 -0
  543. fi/simulate/agent/wrappers/websocket.py +326 -0
  544. fi/simulate/artifacts/__init__.py +11 -0
  545. fi/simulate/artifacts/manifest.py +62 -0
  546. fi/simulate/cli.py +20560 -0
  547. fi/simulate/endpoints/__init__.py +45 -0
  548. fi/simulate/endpoints/_http_actor.py +73 -0
  549. fi/simulate/endpoints/actor_sources.py +243 -0
  550. fi/simulate/endpoints/base.py +107 -0
  551. fi/simulate/endpoints/builtins.py +10 -0
  552. fi/simulate/endpoints/callable.py +95 -0
  553. fi/simulate/endpoints/http.py +76 -0
  554. fi/simulate/endpoints/livekit.py +138 -0
  555. fi/simulate/endpoints/originators.py +132 -0
  556. fi/simulate/endpoints/profiles.py +348 -0
  557. fi/simulate/endpoints/retell.py +633 -0
  558. fi/simulate/endpoints/vapi.py +205 -0
  559. fi/simulate/endpoints/websocket.py +76 -0
  560. fi/simulate/environment.py +33026 -0
  561. fi/simulate/environments/__init__.py +11 -0
  562. fi/simulate/environments/base.py +73 -0
  563. fi/simulate/environments/chat.py +697 -0
  564. fi/simulate/environments/voice.py +212 -0
  565. fi/simulate/evaluation/__init__.py +4 -0
  566. fi/simulate/evaluation/ai_eval.py +227 -0
  567. fi/simulate/evidence/__init__.py +35 -0
  568. fi/simulate/evidence/base.py +59 -0
  569. fi/simulate/evidence/caller_observed.py +50 -0
  570. fi/simulate/evidence/livekit_instrumentation.py +51 -0
  571. fi/simulate/evidence/livekit_room.py +50 -0
  572. fi/simulate/evidence/otel.py +49 -0
  573. fi/simulate/evidence/providers/__init__.py +24 -0
  574. fi/simulate/evidence/providers/base.py +61 -0
  575. fi/simulate/evidence/providers/retell.py +376 -0
  576. fi/simulate/evidence/providers/vapi.py +426 -0
  577. fi/simulate/hosted/__init__.py +32 -0
  578. fi/simulate/hosted/child_entrypoint.py +306 -0
  579. fi/simulate/hosted/job.py +150 -0
  580. fi/simulate/hosted/targets.py +53 -0
  581. fi/simulate/instrumentation/__init__.py +5 -0
  582. fi/simulate/instrumentation/livekit/__init__.py +122 -0
  583. fi/simulate/manifest.py +1033 -0
  584. fi/simulate/matrix_cli.py +165 -0
  585. fi/simulate/realtime/__init__.py +40 -0
  586. fi/simulate/realtime/events.py +107 -0
  587. fi/simulate/realtime/media.py +61 -0
  588. fi/simulate/realtime/session.py +91 -0
  589. fi/simulate/recording/__init__.py +5 -0
  590. fi/simulate/recording/room_recorder.py +326 -0
  591. fi/simulate/registry.py +185 -0
  592. fi/simulate/results/__init__.py +9 -0
  593. fi/simulate/results/base.py +18 -0
  594. fi/simulate/results/filesystem.py +71 -0
  595. fi/simulate/results/futureagi.py +1340 -0
  596. fi/simulate/runtime/__init__.py +85 -0
  597. fi/simulate/runtime/capabilities.py +40 -0
  598. fi/simulate/runtime/events.py +63 -0
  599. fi/simulate/runtime/failures.py +25 -0
  600. fi/simulate/runtime/ids.py +34 -0
  601. fi/simulate/runtime/plan.py +70 -0
  602. fi/simulate/runtime/planner.py +102 -0
  603. fi/simulate/runtime/report.py +174 -0
  604. fi/simulate/runtime/run.py +75 -0
  605. fi/simulate/runtime/runner.py +333 -0
  606. fi/simulate/runtime/spec.py +186 -0
  607. fi/simulate/simulation/__init__.py +30 -0
  608. fi/simulate/simulation/behavior_policy.py +425 -0
  609. fi/simulate/simulation/bridge/__init__.py +9 -0
  610. fi/simulate/simulation/bridge/audio.py +29 -0
  611. fi/simulate/simulation/bridge/connector.py +46 -0
  612. fi/simulate/simulation/bridge/livekit.py +252 -0
  613. fi/simulate/simulation/bridge/retell.py +188 -0
  614. fi/simulate/simulation/bridge/vapi.py +177 -0
  615. fi/simulate/simulation/contract.py +419 -0
  616. fi/simulate/simulation/engines/__init__.py +12 -0
  617. fi/simulate/simulation/engines/base.py +21 -0
  618. fi/simulate/simulation/engines/cloud.py +517 -0
  619. fi/simulate/simulation/engines/livekit.py +4167 -0
  620. fi/simulate/simulation/engines/local_text.py +89 -0
  621. fi/simulate/simulation/fidelity.py +374 -0
  622. fi/simulate/simulation/gemini_tts_stream.py +110 -0
  623. fi/simulate/simulation/generator.py +91 -0
  624. fi/simulate/simulation/goal_machine.py +185 -0
  625. fi/simulate/simulation/livekit_models.py +467 -0
  626. fi/simulate/simulation/matrix.py +170 -0
  627. fi/simulate/simulation/models.py +279 -0
  628. fi/simulate/simulation/runner.py +153 -0
  629. fi/simulate/simulation/synthetic.py +880 -0
  630. fi/simulate/simulation/voice_prompt.py +502 -0
  631. fi/simulate/simulator/__init__.py +55 -0
  632. fi/simulate/simulator/builtins.py +53 -0
  633. fi/simulate/suite.py +1288 -0
  634. fi/simulate/utils/routes.py +164 -0
  635. fi/simulate/voice.py +225 -0
  636. fi/simulate/voice_cli.py +182 -0
  637. fi/utils/__init__.py +1 -0
  638. fi/utils/constants.py +14 -0
  639. fi/utils/errors.py +200 -0
  640. fi/utils/executor.py +26 -0
  641. fi/utils/routes.py +119 -0
  642. fi/utils/utils.py +17 -0
@@ -0,0 +1,2896 @@
1
+ """Provision the runtime an agent repository already describes.
2
+
3
+ The source repository is the authority for infrastructure. If it ships a Compose file, the
4
+ harness starts that file under an isolated project name, substitutes free host ports, waits for
5
+ its health checks, and records the URLs that the agent must receive. It does not ask a model to
6
+ rewrite Dockerfiles, migrations, seed data, or service implementations.
7
+
8
+ This module deliberately has no Docker SDK dependency. Compose itself is the parser and the
9
+ lifecycle manager, so every Compose feature the user's project supports keeps working here too.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import secrets
19
+ import socket
20
+ import subprocess
21
+ import time
22
+ import uuid
23
+ from dataclasses import asdict, dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+ from urllib import error as urllib_error
27
+ from urllib import request as urllib_request
28
+ from urllib.parse import quote, urlsplit, urlunsplit
29
+
30
+ import yaml
31
+
32
+ from .packaging import PackagingKind, inspect_packaging
33
+ from .generated_runtime import (
34
+ GENERATED_DOCKERFILE,
35
+ GeneratedRuntimeError,
36
+ GeneratedRuntimePlan,
37
+ can_generate_runtime,
38
+ prepare_generated_runtime,
39
+ )
40
+ from .livekit_source import infer_livekit_agent_name_from_source
41
+ from .service_catalog import address, profile_for
42
+ from .secrets import runtime_configuration_value
43
+
44
+ MANIFEST = "environment.json"
45
+ COMPOSE_FILES = (
46
+ "compose.yaml",
47
+ "compose.yml",
48
+ "docker-compose.yaml",
49
+ "docker-compose.yml",
50
+ )
51
+
52
+ # ${TOOLS_PORT:-18090}:8080 is the common way a repository makes a published port movable.
53
+ _PORT_VARIABLE = re.compile(
54
+ r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?::-?(?P<default>\d+))?\}"
55
+ r"\s*:\s*(?P<target>\d+)"
56
+ )
57
+ # Covers os.environ.get/getenv and the equivalent bracket lookup. The default URL is useful:
58
+ # it identifies which published service this setting points to without guessing from its name.
59
+ _URL_SETTING = re.compile(
60
+ r"(?:os\.(?:environ\.get|getenv)\(\s*|os\.environ\[\s*)"
61
+ r"[\"'](?P<name>[A-Za-z_][A-Za-z0-9_]*)[\"']"
62
+ r"(?:\s*,\s*[\"'](?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://[^\"']+)[\"'])?"
63
+ )
64
+ _JS_URL_SETTING = re.compile(
65
+ r"process\.env\.(?P<name>[A-Z][A-Z0-9_]{2,})\s*(?:\?\?|\|\|)\s*"
66
+ r"[\"'](?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://[^\"']+)[\"']"
67
+ )
68
+ _DOTENV_URL_SETTING = re.compile(
69
+ r"(?m)^\s*(?P<name>[A-Z][A-Z0-9_]{2,})\s*=\s*"
70
+ r"(?P<url>[a-zA-Z][a-zA-Z0-9+.-]*://\S+)\s*$"
71
+ )
72
+ _ENV_NAME = re.compile(
73
+ r"(?:os\.(?:environ\.get|getenv)\(\s*|os\.environ\[\s*|process\.env\.)"
74
+ r"[\"']?(?P<name>[A-Z][A-Z0-9_]{2,})"
75
+ )
76
+
77
+
78
+ class ProvisionError(RuntimeError):
79
+ """The source environment could not be discovered, started, or inspected."""
80
+
81
+
82
+ @dataclass
83
+ class ProvisionedEnvironment:
84
+ source: str
85
+ compose_file: str
86
+ project: str
87
+ compose_override_file: str = ""
88
+ services: list[str] = field(default_factory=list)
89
+ port_variables: dict[str, str] = field(default_factory=dict)
90
+ overrides: dict[str, str] = field(default_factory=dict)
91
+ internal_overrides: dict[str, str] = field(default_factory=dict)
92
+ service_endpoints: list[dict[str, Any]] = field(default_factory=list)
93
+ runtime_services: list[str] = field(default_factory=list)
94
+ runtime_container: str = ""
95
+ runtime_trace_volume: str = ""
96
+ runtime_trace_path: str = ""
97
+ # Container-port -> runner-reachable address for conversational ingress. Values are
98
+ # ephemeral localhost bindings locally or private container-network addresses in a hosted
99
+ # runner. They contain no credentials and disappear when the runtime is stopped.
100
+ runtime_endpoints: dict[str, str] = field(default_factory=dict)
101
+ runner_network: str = ""
102
+ running: bool = False
103
+ source_fingerprint: str = ""
104
+ provision_seconds: float = 0.0
105
+ managed: bool = False
106
+ generated_runtime_plan: str = ""
107
+ runtime_fingerprint: str = ""
108
+ # Names only. Values are resolved from the job secret environment immediately before the
109
+ # ephemeral worker starts and are never serialized into environment.json or a bundle.
110
+ runtime_configuration_names: list[str] = field(default_factory=list)
111
+
112
+ def save(self, destination: Path) -> Path:
113
+ destination.mkdir(parents=True, exist_ok=True)
114
+ target = destination / MANIFEST
115
+ target.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
116
+ return target
117
+
118
+ @classmethod
119
+ def load(cls, destination: Path) -> ProvisionedEnvironment | None:
120
+ target = Path(destination) / MANIFEST
121
+ if not target.exists():
122
+ return None
123
+ return cls(**json.loads(target.read_text(encoding="utf-8")))
124
+
125
+
126
+ def compose_file(source: str | Path) -> Path | None:
127
+ root = Path(source).expanduser().resolve()
128
+ for name in COMPOSE_FILES:
129
+ candidate = root / name
130
+ if candidate.is_file():
131
+ return candidate
132
+ packaging = inspect_packaging(root)
133
+ if packaging.ready and packaging.selected_kind is PackagingKind.COMPOSE:
134
+ assert packaging.selected_path is not None
135
+ return root / packaging.selected_path
136
+ return None
137
+
138
+
139
+ _FINGERPRINT_IGNORED = {
140
+ ".git",
141
+ ".mypy_cache",
142
+ ".pytest_cache",
143
+ ".ruff_cache",
144
+ ".venv",
145
+ "__pycache__",
146
+ "artifacts",
147
+ "build",
148
+ "dist",
149
+ "node_modules",
150
+ }
151
+
152
+
153
+ def source_fingerprint(source: str | Path) -> str:
154
+ """Identify the exact submitted source used to build an environment.
155
+
156
+ A path is not a version. Reusing a healthy Compose project after files at that path changed
157
+ silently runs yesterday's image against today's contract and scenarios. Hash the build
158
+ context while excluding local caches and generated artifacts so reuse is both safe and fast.
159
+ """
160
+ root = Path(source).expanduser().resolve()
161
+ digest = hashlib.sha256()
162
+ for path in sorted(root.rglob("*")):
163
+ if any(part in _FINGERPRINT_IGNORED for part in path.relative_to(root).parts):
164
+ continue
165
+ if path.is_symlink():
166
+ # Never follow a submitted link while fingerprinting. A malicious
167
+ # repository could otherwise make preflight read an arbitrary host file.
168
+ relative = path.relative_to(root).as_posix().encode()
169
+ target = os.readlink(path).encode(errors="surrogateescape")
170
+ digest.update(len(relative).to_bytes(8, "big"))
171
+ digest.update(relative)
172
+ digest.update(b"\0symlink\0")
173
+ digest.update(target)
174
+ continue
175
+ if not path.is_file():
176
+ continue
177
+ relative = path.relative_to(root).as_posix().encode()
178
+ digest.update(len(relative).to_bytes(8, "big"))
179
+ digest.update(relative)
180
+ try:
181
+ with path.open("rb") as source_file:
182
+ while chunk := source_file.read(1024 * 1024):
183
+ digest.update(chunk)
184
+ except OSError as exc:
185
+ raise ProvisionError(f"could not fingerprint {path}: {exc}") from exc
186
+ return digest.hexdigest()
187
+
188
+
189
+ def _free_port() -> int:
190
+ with socket.socket() as held:
191
+ held.bind(("127.0.0.1", 0))
192
+ return int(held.getsockname()[1])
193
+
194
+
195
+ def port_variables(path: Path) -> dict[str, str]:
196
+ """Ask Docker to allocate every interpolated host port atomically.
197
+
198
+ Binding a temporary socket to discover a free port and releasing it before
199
+ Compose starts is inherently racy: another concurrent world can claim the
200
+ port in that gap. A published port of zero delegates allocation to Docker at
201
+ container creation, and ``_published_port`` resolves the assigned value once
202
+ the service is running.
203
+ """
204
+ text = path.read_text(encoding="utf-8")
205
+ assigned: dict[str, str] = {}
206
+ for match in _PORT_VARIABLE.finditer(text):
207
+ name = match.group("name")
208
+ if name in assigned:
209
+ continue
210
+ assigned[name] = "0"
211
+ return assigned
212
+
213
+
214
+ def _declared_port_targets(path: Path) -> dict[int, int]:
215
+ """Map the repository's default host ports to their container ports."""
216
+ targets: dict[int, int] = {}
217
+ for match in _PORT_VARIABLE.finditer(path.read_text(encoding="utf-8")):
218
+ if match.group("default"):
219
+ targets[int(match.group("default"))] = int(match.group("target"))
220
+ return targets
221
+
222
+
223
+ def _run(
224
+ environment: ProvisionedEnvironment,
225
+ *arguments: str,
226
+ check: bool = True,
227
+ timeout: int = 900,
228
+ process_overrides: dict[str, str] | None = None,
229
+ ) -> str:
230
+ files = ["--file", environment.compose_file]
231
+ if environment.compose_override_file:
232
+ files.extend(("--file", environment.compose_override_file))
233
+ command = [
234
+ "docker",
235
+ "compose",
236
+ *files,
237
+ "--project-name",
238
+ environment.project,
239
+ *arguments,
240
+ ]
241
+ # Runtime credentials are inherited by Compose and selected with ``--env NAME``. They must
242
+ # never be serialized into argv, where error messages and host process listings expose them.
243
+ process_env = {
244
+ **os.environ,
245
+ **environment.port_variables,
246
+ }
247
+ # Uploaded values are namespaced inside the harness worker. Expose them under their submitted
248
+ # names only to Compose, where interpolation and explicitly inherited service variables need
249
+ # them. This never falls back to runner/controller credentials.
250
+ for name in environment.runtime_configuration_names:
251
+ value = runtime_configuration_value(name).strip()
252
+ if value:
253
+ process_env[name] = value
254
+ # Explicit ALK endpoint/credential-path substitutions are the final authority. Applying
255
+ # uploaded values after these substitutions silently restores development hostnames such as
256
+ # ``harness`` or ``localhost`` at the exact point Compose starts the submitted runtime.
257
+ process_env.update(process_overrides or {})
258
+
259
+ def execute() -> subprocess.CompletedProcess[str]:
260
+ return subprocess.run(
261
+ command,
262
+ cwd=environment.source,
263
+ env=process_env,
264
+ capture_output=True,
265
+ text=True,
266
+ check=False,
267
+ timeout=timeout,
268
+ )
269
+
270
+ try:
271
+ completed = execute()
272
+ except FileNotFoundError as exc:
273
+ raise ProvisionError("Docker is not installed or is not on PATH") from exc
274
+ except subprocess.TimeoutExpired as exc:
275
+ raise ProvisionError(f"environment command timed out after {timeout}s") from exc
276
+ output = ((completed.stdout or "") + (completed.stderr or "")).strip()
277
+ if (
278
+ completed.returncode
279
+ and "all predefined address pools have been fully subnetted" in output
280
+ ):
281
+ # Interrupted local runs can leave empty Compose networks behind even after their
282
+ # containers and volumes are gone. After enough runs Docker cannot allocate another
283
+ # subnet and every otherwise valid environment becomes unrunnable. Reclaim only empty,
284
+ # ALK-owned networks, never customer/platform networks or a network with a container.
285
+ if _remove_unused_harness_networks(exclude_project=environment.project):
286
+ completed = execute()
287
+ output = ((completed.stdout or "") + (completed.stderr or "")).strip()
288
+ if check and completed.returncode:
289
+ shown = _command_failure_output(output)
290
+ raise ProvisionError(
291
+ f"{' '.join(command)} failed ({completed.returncode}): {shown}"
292
+ )
293
+ return output
294
+
295
+
296
+ def _start_managed_services(
297
+ environment: ProvisionedEnvironment, services: list[str], *, build: bool = True
298
+ ) -> None:
299
+ """Start dependencies, retrying one clean boot for harness-owned stacks only."""
300
+ attempts = 2 if environment.managed else 1
301
+ for attempt in range(attempts):
302
+ try:
303
+ # Managed dependencies are independent. Starting heavyweight brokers and object
304
+ # stores in one burst can exhaust a local/hosted sandbox's process budget even when
305
+ # its steady-state capacity is sufficient. Admit each service to readiness before
306
+ # starting the next; submitted Compose retains its own native dependency graph.
307
+ groups = (
308
+ ([service] for service in services)
309
+ if environment.managed
310
+ else [services]
311
+ )
312
+ for group in groups:
313
+ _run(
314
+ environment,
315
+ "up",
316
+ "--detach",
317
+ "--build" if build else "--no-build",
318
+ "--wait",
319
+ *group,
320
+ )
321
+ return
322
+ except ProvisionError as exc:
323
+ detail = str(exc).lower()
324
+ transient_boot = " exited (" in detail or "unhealthy" in detail
325
+ if attempt + 1 >= attempts or not transient_boot:
326
+ raise
327
+ # A retry starts from a genuinely clean state. Reusing a partially initialized
328
+ # broker/database volume makes the second attempt neither isolated nor diagnostic.
329
+ _run(
330
+ environment,
331
+ "down",
332
+ "--volumes",
333
+ "--remove-orphans",
334
+ check=False,
335
+ timeout=120,
336
+ )
337
+
338
+
339
+ _TRANSIENT_BUILD_FAILURES = (
340
+ "unexpected eof",
341
+ "tls handshake timeout",
342
+ "i/o timeout",
343
+ "connection reset by peer",
344
+ "temporary failure in name resolution",
345
+ "no such host",
346
+ "502 bad gateway",
347
+ "503 service unavailable",
348
+ "429 too many requests",
349
+ )
350
+
351
+
352
+ def _build_runtime(environment: ProvisionedEnvironment) -> None:
353
+ """Build the submitted image with bounded retries for registry transport failures.
354
+
355
+ Repository and dependency-resolution failures remain deterministic and surface immediately.
356
+ A public registry can fail before BuildKit receives a base-image manifest; replaying that
357
+ side-effect-free build is safe and should not force a complete platform-job resubmission.
358
+ """
359
+ attempts = 3
360
+ for attempt in range(1, attempts + 1):
361
+ try:
362
+ _run(environment, "build", *environment.runtime_services)
363
+ return
364
+ except ProvisionError as exc:
365
+ transient = any(
366
+ marker in str(exc).lower() for marker in _TRANSIENT_BUILD_FAILURES
367
+ )
368
+ if not transient or attempt == attempts:
369
+ raise
370
+ time.sleep(2 ** (attempt - 1))
371
+
372
+
373
+ def _command_failure_output(output: str, *, limit: int = 16_000) -> str:
374
+ """Keep actionable build context without putting megabytes of logs into job state/UI."""
375
+ if not output:
376
+ return "no output"
377
+ if len(output) <= limit:
378
+ return output
379
+ head_size = min(2000, max(1, limit // 4))
380
+ head = output[:head_size]
381
+ tail = output[-(limit - len(head)) :]
382
+ omitted = len(output) - len(head) - len(tail)
383
+ return f"{head}\n... {omitted} characters omitted ...\n{tail}"
384
+
385
+
386
+ def _config(environment: ProvisionedEnvironment) -> dict[str, Any]:
387
+ # Include opt-in profiles while inspecting. They are not started as infrastructure, but the
388
+ # harness must still discover which service is the submitted agent runtime.
389
+ rendered = _run(
390
+ environment,
391
+ "--profile",
392
+ "*",
393
+ "config",
394
+ "--format",
395
+ "json",
396
+ timeout=60,
397
+ )
398
+ try:
399
+ value = json.loads(rendered)
400
+ except json.JSONDecodeError as exc:
401
+ raise ProvisionError(
402
+ f"Docker Compose returned invalid configuration: {exc}"
403
+ ) from exc
404
+ if not isinstance(value, dict) or not isinstance(value.get("services"), dict):
405
+ raise ProvisionError("Docker Compose configuration has no services")
406
+ return value
407
+
408
+
409
+ def _started_services(
410
+ config: dict[str, Any], runtime_services: list[str] | None = None
411
+ ) -> list[str]:
412
+ """Services Compose starts by default; opt-in profile services stay opt-in."""
413
+ runtimes = set(runtime_services or [])
414
+ return sorted(
415
+ name
416
+ for name, service in config["services"].items()
417
+ if _service_starts_by_default(service) and name not in runtimes
418
+ )
419
+
420
+
421
+ def _service_starts_by_default(service: dict[str, Any]) -> bool:
422
+ profiles = [str(value) for value in (service.get("profiles") or [])]
423
+ # Compose treats an explicit empty-string profile as active by default. Dify uses this to
424
+ # make one database/vector backend the default while retaining named alternatives.
425
+ return not profiles or "" in profiles
426
+
427
+
428
+ def _runtime_interface_port(contract: Any | None) -> int | None:
429
+ runtime = getattr(contract, "runtime", None)
430
+ interface = getattr(runtime, "interface", None)
431
+ port = getattr(interface, "port", None)
432
+ return int(port) if port else None
433
+
434
+
435
+ def _service_container_ports(service: dict[str, Any]) -> set[int]:
436
+ ports: set[int] = set()
437
+ for entry in service.get("ports") or []:
438
+ if isinstance(entry, dict) and entry.get("target"):
439
+ ports.add(int(entry["target"]))
440
+ elif isinstance(entry, (str, int)):
441
+ rendered = str(entry).split("/")[0]
442
+ target = rendered.rsplit(":", 1)[-1]
443
+ if target.isdigit():
444
+ ports.add(int(target))
445
+ for entry in service.get("expose") or []:
446
+ rendered = str(entry).split("/")[0]
447
+ if rendered.isdigit():
448
+ ports.add(int(rendered))
449
+ return ports
450
+
451
+
452
+ def _runtime_services(config: dict[str, Any], contract: Any | None = None) -> list[str]:
453
+ """Opt-in services that identify the submitted agent/worker runtime.
454
+
455
+ Compose profiles are also commonly used for pgAdmin, dashboards and debugging tools. Treating
456
+ every profiled service as an agent causes the harness to launch infrastructure UIs as if they
457
+ were voice workers. A generated harness runtime is explicit; repository runtimes must carry a
458
+ recognizable agent/worker/bot name.
459
+ """
460
+ profiled = [
461
+ name
462
+ for name, service in config["services"].items()
463
+ if not _service_starts_by_default(service)
464
+ ]
465
+ explicit = [
466
+ name
467
+ for name in profiled
468
+ if "harness-runtime" in (config["services"][name].get("profiles") or [])
469
+ ]
470
+ if explicit:
471
+ return sorted(explicit)
472
+ preferred = [
473
+ name
474
+ for name in profiled
475
+ if any(word in name.lower() for word in ("agent", "worker", "bot"))
476
+ ]
477
+ if preferred:
478
+ return sorted(preferred)
479
+ interface_port = _runtime_interface_port(contract)
480
+ if interface_port is None:
481
+ return []
482
+ candidates = sorted(
483
+ name
484
+ for name, service in config["services"].items()
485
+ if interface_port in _service_container_ports(service)
486
+ )
487
+ if len(candidates) > 1:
488
+ raise ProvisionError(
489
+ f"chat runtime port {interface_port} is exposed by several Compose services: "
490
+ + ", ".join(candidates)
491
+ + "; select one runtime component explicitly"
492
+ )
493
+ return candidates
494
+
495
+
496
+ def _validate_compose_security(config: dict[str, Any]) -> None:
497
+ """Reject host-escape primitives before any submitted container is created."""
498
+ violations: list[str] = []
499
+ for name, service in config["services"].items():
500
+ if service.get("privileged"):
501
+ violations.append(f"{name}: privileged")
502
+ for key in ("network_mode", "pid", "ipc"):
503
+ if str(service.get(key) or "").lower() == "host":
504
+ violations.append(f"{name}: {key}=host")
505
+ if service.get("devices"):
506
+ violations.append(f"{name}: host devices")
507
+ for volume in service.get("volumes") or []:
508
+ source = (
509
+ str(volume.get("source") or "")
510
+ if isinstance(volume, dict)
511
+ else str(volume)
512
+ )
513
+ if (
514
+ source in {"/var/run/docker.sock", "/run/docker.sock"}
515
+ or "docker.sock:" in source
516
+ ):
517
+ violations.append(f"{name}: Docker socket mount")
518
+ if violations:
519
+ raise ProvisionError(
520
+ "submitted Compose requests forbidden host access: " + ", ".join(violations)
521
+ )
522
+
523
+
524
+ def _write_port_override(
525
+ destination: Path,
526
+ environment: ProvisionedEnvironment,
527
+ config: dict[str, Any],
528
+ ) -> None:
529
+ """Replace every fixed published port with a job-owned port.
530
+
531
+ Environment variables cover only repositories that deliberately parameterise their Compose
532
+ ports. Most repositories publish constants, which collide as soon as two jobs overlap. A
533
+ generated Compose override makes isolation universal while leaving container ports intact.
534
+ """
535
+ services: list[tuple[str, list[dict[str, Any]], bool]] = []
536
+ dynamic_targets = set(
537
+ _declared_port_targets(Path(environment.compose_file)).values()
538
+ )
539
+ bind_host = os.environ.get("ALK_DOCKER_BIND_HOST", "").strip() or (
540
+ "127.0.0.1" if _published_host() == "127.0.0.1" else "0.0.0.0"
541
+ )
542
+ for name, service in config["services"].items():
543
+ # Compose excludes profiled services unless that profile is explicitly enabled. Do not
544
+ # allocate ports for dormant TURN/admin/debug services; doing so can exhaust a runner's
545
+ # ephemeral port range before the selected environment even starts.
546
+ if not _service_starts_by_default(service):
547
+ continue
548
+ ports: list[dict[str, Any]] = []
549
+ for item in service.get("ports") or []:
550
+ target = int(item.get("target") or 0)
551
+ if not target or target in dynamic_targets:
552
+ continue
553
+ ports.append(
554
+ {
555
+ "target": target,
556
+ # Let Docker reserve an available port in the same atomic
557
+ # operation that creates the container. Preselecting a
558
+ # supposedly free port races concurrent worlds.
559
+ "published": "0",
560
+ "host_ip": bind_host,
561
+ "protocol": str(item.get("protocol") or "tcp"),
562
+ }
563
+ )
564
+ fixed_container_name = bool(str(service.get("container_name") or "").strip())
565
+ if ports or fixed_container_name:
566
+ services.append((name, ports, fixed_container_name))
567
+ reset_env_files = _missing_env_file_services(Path(environment.compose_file))
568
+ if not services and not reset_env_files:
569
+ return
570
+ lines = ["services:"]
571
+ for name in reset_env_files:
572
+ lines.extend((f" {json.dumps(name)}:", " env_file: !reset []"))
573
+ for name, ports, fixed_container_name in services:
574
+ lines.append(f" {json.dumps(name)}:")
575
+ if fixed_container_name:
576
+ # A submitted container_name bypasses Compose project scoping and collides across
577
+ # concurrent jobs. Compose's reset tag restores its normal project-owned name.
578
+ lines.append(" container_name: !reset null")
579
+ if ports:
580
+ lines.append(" ports: !override")
581
+ for item in ports:
582
+ lines.extend(
583
+ (
584
+ f" - target: {item['target']}",
585
+ f" published: {json.dumps(item['published'])}",
586
+ f" host_ip: {json.dumps(item['host_ip'])}",
587
+ f" protocol: {item['protocol']}",
588
+ )
589
+ )
590
+ destination.mkdir(parents=True, exist_ok=True)
591
+ target = destination / "compose.harness.override.yaml"
592
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
593
+ environment.compose_override_file = str(target.resolve())
594
+
595
+
596
+ def _missing_env_file_services(compose: Path) -> list[str]:
597
+ """Find services whose repository-local dotenv file is absent.
598
+
599
+ Hosted uploads intentionally exclude secret dotenv files. Compose otherwise refuses even to
600
+ render a service whose ``env_file`` is absent. The generated override removes only that file
601
+ reference; job-scoped uploaded values are injected separately at the process/container
602
+ boundary and are never written into the checkout or artifacts.
603
+ """
604
+ try:
605
+ document = yaml.safe_load(compose.read_text(encoding="utf-8")) or {}
606
+ except (OSError, UnicodeDecodeError, yaml.YAMLError):
607
+ return []
608
+ services = document.get("services") if isinstance(document, dict) else None
609
+ if not isinstance(services, dict):
610
+ return []
611
+ missing: list[str] = []
612
+ for name, service in services.items():
613
+ if not isinstance(service, dict) or not service.get("env_file"):
614
+ continue
615
+ entries = service["env_file"]
616
+ if not isinstance(entries, list):
617
+ entries = [entries]
618
+ paths = [
619
+ str(item.get("path") if isinstance(item, dict) else item)
620
+ for item in entries
621
+ ]
622
+ if any(path and not (compose.parent / path).is_file() for path in paths):
623
+ missing.append(str(name))
624
+ return sorted(missing)
625
+
626
+
627
+ def _write_initial_env_file_override(
628
+ destination: Path, environment: ProvisionedEnvironment
629
+ ) -> None:
630
+ """Make a submitted Compose model renderable before port inspection."""
631
+ services = _missing_env_file_services(Path(environment.compose_file))
632
+ if not services:
633
+ return
634
+ destination.mkdir(parents=True, exist_ok=True)
635
+ target = destination / "compose.harness.override.yaml"
636
+ lines = ["services:"]
637
+ for name in services:
638
+ lines.extend((f" {json.dumps(name)}:", " env_file: !reset []"))
639
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
640
+ environment.compose_override_file = str(target.resolve())
641
+
642
+
643
+ def _refresh_compose_ports(
644
+ destination: Path, environment: ProvisionedEnvironment
645
+ ) -> dict[str, Any]:
646
+ """Render the submitted stack with a newly allocated host-port set."""
647
+ environment.port_variables = port_variables(Path(environment.compose_file))
648
+ environment.compose_override_file = None
649
+ _write_initial_env_file_override(destination, environment)
650
+ config = _config(environment)
651
+ _validate_compose_security(config)
652
+ _write_port_override(destination, environment, config)
653
+ if environment.compose_override_file:
654
+ config = _config(environment)
655
+ _validate_compose_security(config)
656
+ return config
657
+
658
+
659
+ def _published_port(
660
+ environment: ProvisionedEnvironment, service: str, target: int
661
+ ) -> int:
662
+ shown = _run(environment, "port", service, str(target), timeout=30)
663
+ line = next((line for line in shown.splitlines() if line.strip()), "")
664
+ try:
665
+ return int(line.rsplit(":", 1)[1])
666
+ except (IndexError, ValueError) as exc:
667
+ raise ProvisionError(
668
+ f"could not determine the host port for {service}:{target}: {shown or 'no mapping'}"
669
+ ) from exc
670
+
671
+
672
+ def _url_settings(source: Path) -> list[tuple[str, str]]:
673
+ found: list[tuple[str, str]] = []
674
+ ignored = {".git", ".venv", "node_modules", "dist", "build", "__pycache__"}
675
+ for path in source.rglob("*"):
676
+ if (
677
+ path.suffix.lower() not in {".py", ".js", ".jsx", ".ts", ".tsx"}
678
+ and path.name != ".env.example"
679
+ ):
680
+ continue
681
+ if any(part in ignored for part in path.parts):
682
+ continue
683
+ try:
684
+ text = path.read_text(encoding="utf-8")
685
+ except (OSError, UnicodeDecodeError):
686
+ continue
687
+ for match in _URL_SETTING.finditer(text):
688
+ url = match.group("url") or ""
689
+ if url:
690
+ found.append((match.group("name"), url))
691
+ for pattern in (_JS_URL_SETTING, _DOTENV_URL_SETTING):
692
+ found.extend(
693
+ (match.group("name"), match.group("url"))
694
+ for match in pattern.finditer(text)
695
+ )
696
+ return found
697
+
698
+
699
+ def _declared_configuration_names(source: Path) -> set[str]:
700
+ """Configuration variables the submitted source actually reads or documents."""
701
+ found = {name for name, _ in _url_settings(source)}
702
+ ignored = {".git", ".venv", "node_modules", "dist", "build", "__pycache__"}
703
+ for path in source.rglob("*"):
704
+ if (
705
+ path.suffix.lower() not in {".py", ".js", ".jsx", ".ts", ".tsx"}
706
+ and path.name != ".env.example"
707
+ ):
708
+ continue
709
+ if not path.is_file() or any(part in ignored for part in path.parts):
710
+ continue
711
+ try:
712
+ text = path.read_text(encoding="utf-8")
713
+ except (OSError, UnicodeDecodeError):
714
+ continue
715
+ found.update(match.group("name") for match in _ENV_NAME.finditer(text))
716
+ if path.name == ".env.example":
717
+ found.update(
718
+ match.group(1)
719
+ for match in re.finditer(r"(?m)^\s*([A-Z][A-Z0-9_]{2,})\s*=", text)
720
+ )
721
+ return found
722
+
723
+
724
+ def _replace_endpoint(value: str, host: str, port: int) -> str:
725
+ parsed = urlsplit(value)
726
+ userinfo = ""
727
+ if parsed.username is not None:
728
+ userinfo = quote(parsed.username, safe="")
729
+ if parsed.password is not None:
730
+ userinfo += ":" + quote(parsed.password, safe="")
731
+ userinfo += "@"
732
+ return urlunsplit(
733
+ (
734
+ parsed.scheme,
735
+ f"{userinfo}{host}:{port}",
736
+ parsed.path,
737
+ parsed.query,
738
+ parsed.fragment,
739
+ )
740
+ )
741
+
742
+
743
+ def _service_endpoints(
744
+ environment: ProvisionedEnvironment, config: dict[str, Any]
745
+ ) -> list[dict[str, Any]]:
746
+ """Describe every published dependency; unknown images remain usable TCP services."""
747
+ records: list[dict[str, Any]] = []
748
+ for service_name in environment.services:
749
+ service = config["services"][service_name]
750
+ image = str(service.get("image") or "")
751
+ for port in service.get("ports") or []:
752
+ target = int(port.get("target") or 0)
753
+ if not target:
754
+ continue
755
+ configured_host_port = int(port.get("published") or 0)
756
+ try:
757
+ host_port = _published_port(environment, service_name, target)
758
+ except ProvisionError:
759
+ # Primarily useful for provider adapters and dry-run validation. A real started
760
+ # Compose project normally resolves through ``compose port``.
761
+ if not configured_host_port:
762
+ raise
763
+ host_port = configured_host_port
764
+ profile = profile_for(service_name, image, target)
765
+ records.append(
766
+ {
767
+ "service": service_name,
768
+ "kind": profile.kind,
769
+ "protocol": profile.protocol,
770
+ "container_port": target,
771
+ "host_port": host_port,
772
+ "configured_host_port": configured_host_port,
773
+ "external_address": address(
774
+ profile.protocol, _published_host(), host_port
775
+ ),
776
+ "internal_address": address(profile.protocol, service_name, target),
777
+ "configuration_names": list(profile.configuration_names),
778
+ "readiness_path": profile.readiness_path,
779
+ }
780
+ )
781
+ return records
782
+
783
+
784
+ def _endpoint_ready(endpoint: dict[str, Any], host: str) -> bool:
785
+ """Probe the service protocol when known, falling back to a TCP connection."""
786
+ port = int(endpoint["host_port"])
787
+ protocol = str(endpoint.get("protocol") or "tcp")
788
+ try:
789
+ with socket.create_connection((host, port), timeout=0.75) as connection:
790
+ if protocol == "redis":
791
+ connection.sendall(b"*1\r\n$4\r\nPING\r\n")
792
+ response = connection.recv(128)
793
+ # An authentication challenge is a semantic Redis response and proves the
794
+ # server is ready. Credentials are validated by the submitted agent itself.
795
+ return response.startswith((b"+PONG", b"-NOAUTH"))
796
+ if protocol == "amqp":
797
+ # A listening RabbitMQ socket can appear before the AMQP application is ready.
798
+ # Require the server to answer the protocol header, not merely accept TCP.
799
+ connection.sendall(b"AMQP\x00\x00\x09\x01")
800
+ return bool(connection.recv(128))
801
+ if protocol == "nats":
802
+ # NATS begins every client session with an INFO line once the server can route
803
+ # traffic. This also distinguishes it from an unrelated process on the port.
804
+ return connection.recv(256).startswith(b"INFO ")
805
+ except OSError:
806
+ return False
807
+ readiness_path = str(endpoint.get("readiness_path") or "")
808
+ if readiness_path and protocol in {"clickhouse", "http", "mcp", "s3"}:
809
+ url = f"http://{host}:{port}/{readiness_path.lstrip('/')}"
810
+ try:
811
+ with urllib_request.build_opener(urllib_request.ProxyHandler({})).open(
812
+ url, timeout=1.0
813
+ ) as response:
814
+ response.read(256)
815
+ return 200 <= response.status < 400
816
+ except (OSError, urllib_error.URLError, urllib_error.HTTPError):
817
+ return False
818
+ return True
819
+
820
+
821
+ def _wait_for_endpoints(
822
+ endpoints: list[dict[str, Any]],
823
+ timeout: float = 60.0,
824
+ stability_seconds: float = 2.0,
825
+ *,
826
+ use_internal: bool = False,
827
+ ) -> None:
828
+ """Require stable protocol readiness, not merely a briefly-open container port."""
829
+ indexed: dict[tuple[str, int], dict[str, Any]] = {}
830
+ for item in endpoints:
831
+ probe = dict(item)
832
+ if use_internal:
833
+ probe["host_port"] = int(item["container_port"])
834
+ probe["probe_host"] = str(item["service"])
835
+ indexed[(str(probe["service"]), int(probe["host_port"]))] = probe
836
+ pending = set(indexed)
837
+ ready_since: dict[tuple[str, int], float] = {}
838
+ deadline = time.monotonic() + timeout
839
+ while pending and time.monotonic() < deadline:
840
+ for endpoint in list(pending):
841
+ probe = indexed[endpoint]
842
+ host = str(probe.get("probe_host") or _published_host())
843
+ if _endpoint_ready(probe, host):
844
+ first_ready = ready_since.setdefault(endpoint, time.monotonic())
845
+ # Protocol services can briefly answer while an init script is about to restart
846
+ # them. A short stability window prevents the first real tool call racing that
847
+ # transition. Unknown TCP services retain the fast generic behavior.
848
+ needs_stability = bool(indexed[endpoint].get("readiness_path")) or str(
849
+ indexed[endpoint].get("protocol") or ""
850
+ ) in {"amqp", "nats", "redis"}
851
+ if (
852
+ not needs_stability
853
+ or time.monotonic() - first_ready >= stability_seconds
854
+ ):
855
+ pending.remove(endpoint)
856
+ else:
857
+ ready_since.pop(endpoint, None)
858
+ if pending:
859
+ time.sleep(0.2)
860
+ if pending:
861
+ rendered = ", ".join(f"{service}:{port}" for service, port in sorted(pending))
862
+ raise ProvisionError(f"environment endpoints did not become ready: {rendered}")
863
+
864
+
865
+ def _wait_for_environment_endpoints(
866
+ environment: ProvisionedEnvironment,
867
+ timeout: float = 60.0,
868
+ stability_seconds: float = 2.0,
869
+ ) -> None:
870
+ """Probe over the runner's private network when the runner is containerized."""
871
+ internal = bool(os.environ.get("ALK_RUNNER_CONTAINER", "").strip())
872
+ if internal and environment.services:
873
+ _attach_runner_network(environment)
874
+ _wait_for_endpoints(
875
+ environment.service_endpoints,
876
+ timeout,
877
+ stability_seconds,
878
+ use_internal=True,
879
+ )
880
+ return
881
+ _wait_for_endpoints(environment.service_endpoints, timeout, stability_seconds)
882
+
883
+
884
+ def _overrides(
885
+ environment: ProvisionedEnvironment, config: dict[str, Any]
886
+ ) -> dict[str, str]:
887
+ """Map declared settings to the equivalent endpoint in this isolated project."""
888
+ endpoints = environment.service_endpoints or _service_endpoints(environment, config)
889
+ source = Path(environment.source)
890
+ names = _declared_configuration_names(source)
891
+ answers: dict[str, str] = {}
892
+ declared = _declared_port_targets(Path(environment.compose_file))
893
+ for variable, default in _url_settings(Path(environment.source)):
894
+ parsed = urlsplit(default)
895
+ try:
896
+ default_port = parsed.port or (443 if parsed.scheme == "https" else 80)
897
+ except ValueError:
898
+ # Cluster DSNs are commonly comma-separated URL lists. They require a topology-aware
899
+ # adapter; one malformed/specialized declaration must not abort environment creation.
900
+ continue
901
+ expected_target = declared.get(default_port)
902
+ match = next(
903
+ (
904
+ item
905
+ for item in endpoints
906
+ if item["host_port"] == default_port
907
+ or item["configured_host_port"] == default_port
908
+ or item["container_port"] == default_port
909
+ or (
910
+ expected_target is not None
911
+ and item["container_port"] == expected_target
912
+ )
913
+ ),
914
+ None,
915
+ )
916
+ if match is None:
917
+ continue
918
+ answers[variable] = _replace_endpoint(
919
+ default, _published_host(), int(match["host_port"])
920
+ )
921
+ for endpoint in endpoints:
922
+ kind = str(endpoint["kind"]).upper().replace("-", "_")
923
+ candidates = set(endpoint["configuration_names"]) | {
924
+ f"{kind}_HOST",
925
+ f"{kind}_PORT",
926
+ }
927
+ for variable in sorted(names & candidates):
928
+ if variable in answers:
929
+ continue
930
+ if variable.endswith("_HOST"):
931
+ value = _published_host()
932
+ elif variable.endswith("_PORT"):
933
+ value = str(endpoint["host_port"])
934
+ elif variable.endswith("BOOTSTRAP_SERVERS"):
935
+ value = f"{_published_host()}:{endpoint['host_port']}"
936
+ else:
937
+ value = str(endpoint["external_address"])
938
+ answers[variable] = value
939
+ return answers
940
+
941
+
942
+ def _internal_overrides(
943
+ environment: ProvisionedEnvironment, config: dict[str, Any]
944
+ ) -> dict[str, str]:
945
+ """The same inferred endpoints, addressed from another Compose container."""
946
+ endpoints = environment.service_endpoints or _service_endpoints(environment, config)
947
+ declared = _declared_port_targets(Path(environment.compose_file))
948
+ answers: dict[str, str] = {}
949
+ for variable, default in _url_settings(Path(environment.source)):
950
+ parsed = urlsplit(default)
951
+ try:
952
+ default_port = parsed.port or (443 if parsed.scheme == "https" else 80)
953
+ except ValueError:
954
+ continue
955
+ expected_target = declared.get(default_port)
956
+ match = next(
957
+ (
958
+ item
959
+ for item in endpoints
960
+ if item["host_port"] == default_port
961
+ or item["configured_host_port"] == default_port
962
+ or item["container_port"] == default_port
963
+ or (
964
+ expected_target is not None
965
+ and item["container_port"] == expected_target
966
+ )
967
+ ),
968
+ None,
969
+ )
970
+ if match is not None:
971
+ answers[variable] = _replace_endpoint(
972
+ default, str(match["service"]), int(match["container_port"])
973
+ )
974
+ external = _overrides(environment, config)
975
+ by_name: dict[str, dict[str, Any]] = {}
976
+ for endpoint in endpoints:
977
+ for name in endpoint["configuration_names"]:
978
+ by_name[name] = endpoint
979
+ kind = str(endpoint["kind"]).upper().replace("-", "_")
980
+ by_name[f"{kind}_HOST"] = endpoint
981
+ by_name[f"{kind}_PORT"] = endpoint
982
+ for variable in external:
983
+ if variable in answers or variable not in by_name:
984
+ continue
985
+ endpoint = by_name[variable]
986
+ if variable.endswith("_HOST"):
987
+ value = str(endpoint["service"])
988
+ elif variable.endswith("_PORT"):
989
+ value = str(endpoint["container_port"])
990
+ elif variable.endswith("BOOTSTRAP_SERVERS"):
991
+ value = f"{endpoint['service']}:{endpoint['container_port']}"
992
+ else:
993
+ value = str(endpoint["internal_address"])
994
+ answers[variable] = value
995
+ if environment.managed and len(environment.runtime_services) == 1:
996
+ # The harness-generated Compose adapter owns these values. Its runtime service carries
997
+ # the complete connector assembled by ``_managed_service`` (database, user and any
998
+ # run-local credentials), while endpoint inference above can only reconstruct a generic
999
+ # host/port URL. Prefer the generated connector so starting the submitted runtime cannot
1000
+ # accidentally turn e.g. ``mysql://harness:.../voice`` into ``mysql://mysql:3306``.
1001
+ runtime = config.get("services", {}).get(environment.runtime_services[0]) or {}
1002
+ generated = _environment_values(runtime)
1003
+ endpoint_names = {
1004
+ str(name)
1005
+ for endpoint in endpoints
1006
+ for name in endpoint.get("configuration_names", [])
1007
+ }
1008
+ for name in endpoint_names:
1009
+ value = generated.get(name, "").strip()
1010
+ if value:
1011
+ answers[name] = value
1012
+ return answers
1013
+
1014
+
1015
+ def _environment_values(service: dict[str, Any]) -> dict[str, str]:
1016
+ raw = service.get("environment") or {}
1017
+ if isinstance(raw, list):
1018
+ return dict(
1019
+ entry.split("=", 1) if "=" in entry else (entry, "") for entry in raw
1020
+ )
1021
+ return {str(key): str(value) for key, value in raw.items()}
1022
+
1023
+
1024
+ def postgres_dsn(destination: str | Path) -> str:
1025
+ """Resolve the submitted Compose project's Postgres endpoint without persisting its secret."""
1026
+ environment = ProvisionedEnvironment.load(Path(destination))
1027
+ if environment is None or not environment.running:
1028
+ raise ProvisionError(f"no running environment recorded at {destination}")
1029
+ config = _config(environment)
1030
+ candidates: list[tuple[str, dict[str, Any]]] = []
1031
+ for name in environment.services:
1032
+ service = config["services"].get(name) or {}
1033
+ image = str(service.get("image") or "").lower()
1034
+ targets = {int(port.get("target") or 0) for port in service.get("ports") or []}
1035
+ if image.startswith("postgres:") or 5432 in targets:
1036
+ candidates.append((name, service))
1037
+ if len(candidates) != 1:
1038
+ raise ProvisionError(
1039
+ "expected exactly one Postgres service in the submitted environment, found "
1040
+ + (", ".join(name for name, _ in candidates) or "none")
1041
+ )
1042
+ name, service = candidates[0]
1043
+ values = _environment_values(service)
1044
+ user = values.get("POSTGRES_USER") or "postgres"
1045
+ password = values.get("POSTGRES_PASSWORD") or ""
1046
+ database = values.get("POSTGRES_DB") or user
1047
+ port = _published_port(environment, name, 5432)
1048
+ return (
1049
+ f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}"
1050
+ f"@{_published_host()}:{port}/{quote(database, safe='')}"
1051
+ )
1052
+
1053
+
1054
+ def _published_host() -> str:
1055
+ """Host through which this process reaches ports published by Docker.
1056
+
1057
+ A host CLI reaches published ports on loopback. A sandbox talking to a
1058
+ remote daemon reaches the same ports through that daemon's gateway.
1059
+ Deployment owns the address; provisioning only applies it consistently to
1060
+ HTTP services and attached stores.
1061
+ """
1062
+ return os.environ.get("ALK_DOCKER_PUBLISHED_HOST", "").strip() or "127.0.0.1"
1063
+
1064
+
1065
+ def uses_private_runner_network(environment: ProvisionedEnvironment) -> bool:
1066
+ """Whether this process shares the provisioned environment's Compose network."""
1067
+ return bool(
1068
+ os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
1069
+ and environment.runner_network
1070
+ )
1071
+
1072
+
1073
+ def reachable_overrides(environment: ProvisionedEnvironment) -> dict[str, str]:
1074
+ """Return service addresses reachable from the process executing the harness.
1075
+
1076
+ A local CLI reaches Compose services through published host ports. A hosted worker is
1077
+ attached to the run's private Compose network and must use service DNS/container ports;
1078
+ Docker Desktop does not guarantee that a container can hairpin through its own published
1079
+ ``host.docker.internal`` port.
1080
+ """
1081
+ if uses_private_runner_network(environment) and environment.internal_overrides:
1082
+ return dict(environment.internal_overrides)
1083
+ return dict(environment.overrides)
1084
+
1085
+
1086
+ def _runner_service_address(
1087
+ environment: ProvisionedEnvironment, address: str, service: str
1088
+ ) -> str:
1089
+ """Use a run-unique DNS name when the runner shares many Compose networks.
1090
+
1091
+ A hosted runner is attached to the platform network and to one private network per active
1092
+ harness environment. Generic Compose aliases such as ``postgres`` can consequently resolve
1093
+ to the platform database instead of this run's database. Submitted containers stay on one
1094
+ network and may use service aliases; the multi-network runner must use the unique container
1095
+ name Docker registers on the same private network.
1096
+ """
1097
+ container_id = _run(environment, "ps", "--quiet", service, timeout=30).strip()
1098
+ if not container_id:
1099
+ raise ProvisionError(f"service {service!r} has no running container")
1100
+ container_name = (
1101
+ _docker("inspect", "--format", "{{.Name}}", container_id, timeout=30)
1102
+ .strip()
1103
+ .lstrip("/")
1104
+ )
1105
+ if not container_name:
1106
+ raise ProvisionError(f"service {service!r} has no resolvable container name")
1107
+ parsed = urlsplit(address)
1108
+ userinfo, separator, _host = parsed.netloc.rpartition("@")
1109
+ authority = f"{userinfo}{separator}" if separator else ""
1110
+ authority += container_name
1111
+ if parsed.port is not None:
1112
+ authority += f":{parsed.port}"
1113
+ return urlunsplit(parsed._replace(netloc=authority))
1114
+
1115
+
1116
+ def attached_postgres_store(destination: str | Path):
1117
+ """The standard world-store interface over the repository's running Postgres."""
1118
+ from .world.stores.postgres import AttachedPostgresStore
1119
+
1120
+ environment = ProvisionedEnvironment.load(Path(destination))
1121
+ if environment is None or not environment.running:
1122
+ raise ProvisionError(f"no running environment recorded at {destination}")
1123
+ if uses_private_runner_network(environment):
1124
+ candidates = {
1125
+ value
1126
+ for value in environment.internal_overrides.values()
1127
+ if urlsplit(value).scheme in {"postgres", "postgresql"}
1128
+ }
1129
+ if len(candidates) == 1:
1130
+ address = next(iter(candidates))
1131
+ services = {
1132
+ str(endpoint.get("service") or "")
1133
+ for endpoint in environment.service_endpoints
1134
+ if str(endpoint.get("kind") or "") == "postgres"
1135
+ }
1136
+ services.discard("")
1137
+ if len(services) == 1:
1138
+ address = _runner_service_address(
1139
+ environment, address, next(iter(services))
1140
+ )
1141
+ return AttachedPostgresStore(address)
1142
+ return AttachedPostgresStore(postgres_dsn(destination))
1143
+
1144
+
1145
+ def _configuration_name(value: str) -> str:
1146
+ """Extract an environment variable name from a contract's prose or config key."""
1147
+ candidates = _configuration_names(value)
1148
+ return candidates[0] if candidates else ""
1149
+
1150
+
1151
+ def _configuration_names(value: str) -> list[str]:
1152
+ """Extract every env name from fields that may contain comma/slash-separated prose."""
1153
+ raw = (value or "").strip()
1154
+ candidates = re.findall(r"\b[A-Z][A-Z0-9_]{2,}\b", raw)
1155
+ names = [candidate for candidate in candidates if "_" in candidate]
1156
+ if not names and re.fullmatch(r"[A-Z][A-Z0-9_]{2,}", raw):
1157
+ names = [raw]
1158
+ return list(dict.fromkeys(names))
1159
+
1160
+
1161
+ def _contract_runtime_configuration_names(contract: Any | None) -> list[str]:
1162
+ names: set[str] = set()
1163
+ store = getattr(contract, "data_store", None)
1164
+ if store is not None:
1165
+ for field_name in ("configured_by", "config_key", "password_from"):
1166
+ names.update(
1167
+ _configuration_names(str(getattr(store, field_name, "") or ""))
1168
+ )
1169
+ dependencies = [
1170
+ *list(getattr(contract, "dependencies", None) or []),
1171
+ *list(getattr(contract, "runtime_dependencies", None) or []),
1172
+ ]
1173
+ for dependency in dependencies:
1174
+ reached = getattr(dependency, "reached", None)
1175
+ if reached is None:
1176
+ continue
1177
+ for field_name in ("dsn_env", "config_key", "user", "password_from"):
1178
+ names.update(
1179
+ _configuration_names(str(getattr(reached, field_name, "") or ""))
1180
+ )
1181
+ forbidden = {
1182
+ "FI_API_KEY",
1183
+ "FI_SECRET_KEY",
1184
+ "HARNESS_PLATFORM_API_KEY",
1185
+ "HARNESS_PLATFORM_SECRET_KEY",
1186
+ }
1187
+ supplied = os.environ.get("ALK_RUNTIME_CONFIGURATION_NAMES", "")
1188
+ names.update(
1189
+ name
1190
+ for name in supplied.split(",")
1191
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)
1192
+ )
1193
+ return sorted(
1194
+ name for name in names if name not in forbidden and not name.startswith("ALK_")
1195
+ )
1196
+
1197
+
1198
+ def _saved_runtime_configuration_names(destination: Path) -> list[str]:
1199
+ """Recover authorized configuration *names* without reading or persisting their values."""
1200
+ names: set[str] = set()
1201
+ job_path = destination / "job.json"
1202
+ if job_path.is_file():
1203
+ try:
1204
+ job = json.loads(job_path.read_text(encoding="utf-8"))
1205
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
1206
+ job = {}
1207
+ agent = job.get("agent") if isinstance(job, dict) else None
1208
+ references = agent.get("secret_refs") if isinstance(agent, dict) else None
1209
+ if isinstance(references, dict):
1210
+ names.update(str(name) for name in references)
1211
+ metadata = job.get("metadata") if isinstance(job, dict) else None
1212
+ declared = (
1213
+ metadata.get("environment_value_names")
1214
+ if isinstance(metadata, dict)
1215
+ else None
1216
+ )
1217
+ if isinstance(declared, list):
1218
+ names.update(str(name) for name in declared)
1219
+
1220
+ previous = ProvisionedEnvironment.load(destination)
1221
+ if previous is not None:
1222
+ names.update(previous.runtime_configuration_names)
1223
+
1224
+ forbidden = {
1225
+ "FI_API_KEY",
1226
+ "FI_SECRET_KEY",
1227
+ "HARNESS_PLATFORM_API_KEY",
1228
+ "HARNESS_PLATFORM_SECRET_KEY",
1229
+ }
1230
+ return sorted(
1231
+ name
1232
+ for name in names
1233
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)
1234
+ and name not in forbidden
1235
+ and not name.startswith("ALK_")
1236
+ )
1237
+
1238
+
1239
+ def _managed_service(
1240
+ engine: str,
1241
+ *,
1242
+ version: str,
1243
+ database: str,
1244
+ user: str,
1245
+ init_mounts: list[str],
1246
+ ) -> tuple[dict[str, Any], str, str]:
1247
+ """A real infrastructure service, its internal connector and default config name."""
1248
+ if engine == "clickhouse":
1249
+ return (
1250
+ {
1251
+ "image": f"clickhouse/clickhouse-server:{version or '24.8'}",
1252
+ "environment": {
1253
+ "CLICKHOUSE_DB": database,
1254
+ "CLICKHOUSE_USER": user,
1255
+ "CLICKHOUSE_PASSWORD": "",
1256
+ "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
1257
+ },
1258
+ "ports": [
1259
+ "${HARNESS_CLICKHOUSE_HTTP_PORT:-58123}:8123",
1260
+ "${HARNESS_CLICKHOUSE_NATIVE_PORT:-59000}:9000",
1261
+ ],
1262
+ "healthcheck": {
1263
+ "test": [
1264
+ "CMD",
1265
+ "wget",
1266
+ "--spider",
1267
+ "-q",
1268
+ "http://127.0.0.1:8123/ping",
1269
+ ],
1270
+ "interval": "1s",
1271
+ "timeout": "3s",
1272
+ "retries": 60,
1273
+ },
1274
+ "volumes": init_mounts,
1275
+ },
1276
+ f"http://{quote(user, safe='')}@clickhouse:8123/{quote(database, safe='')}",
1277
+ "CLICKHOUSE_URL",
1278
+ )
1279
+ if engine == "postgres":
1280
+ return (
1281
+ {
1282
+ "image": f"postgres:{version or '16'}",
1283
+ "environment": {
1284
+ "POSTGRES_DB": database,
1285
+ "POSTGRES_USER": user,
1286
+ "POSTGRES_HOST_AUTH_METHOD": "trust",
1287
+ },
1288
+ "ports": ["${HARNESS_POSTGRES_PORT:-55432}:5432"],
1289
+ "healthcheck": {
1290
+ "test": ["CMD-SHELL", f"pg_isready -U {user} -d {database}"],
1291
+ "interval": "1s",
1292
+ "timeout": "3s",
1293
+ "retries": 30,
1294
+ },
1295
+ "volumes": init_mounts,
1296
+ },
1297
+ f"postgresql://{user}@postgres:5432/{database}",
1298
+ "DATABASE_URL",
1299
+ )
1300
+ if engine == "mysql":
1301
+ return (
1302
+ {
1303
+ "image": f"mysql:{version or '8.4'}",
1304
+ "environment": {
1305
+ "MYSQL_DATABASE": database,
1306
+ "MYSQL_USER": user,
1307
+ "MYSQL_PASSWORD": "harness-local",
1308
+ "MYSQL_ROOT_PASSWORD": "harness-root-local",
1309
+ },
1310
+ "ports": ["${HARNESS_MYSQL_PORT:-53306}:3306"],
1311
+ "healthcheck": {
1312
+ "test": [
1313
+ "CMD-SHELL",
1314
+ "mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER "
1315
+ "-p$$MYSQL_PASSWORD --silent",
1316
+ ],
1317
+ "interval": "1s",
1318
+ "timeout": "5s",
1319
+ "retries": 60,
1320
+ },
1321
+ "volumes": init_mounts,
1322
+ },
1323
+ f"mysql://{quote(user, safe='')}:harness-local@mysql:3306/"
1324
+ f"{quote(database, safe='')}",
1325
+ "DATABASE_URL",
1326
+ )
1327
+ if engine == "redis":
1328
+ return (
1329
+ {
1330
+ "image": f"redis:{version or '7-alpine'}",
1331
+ "ports": ["${HARNESS_REDIS_PORT:-56379}:6379"],
1332
+ "healthcheck": {
1333
+ "test": ["CMD", "redis-cli", "ping"],
1334
+ "interval": "1s",
1335
+ "timeout": "3s",
1336
+ "retries": 30,
1337
+ },
1338
+ },
1339
+ "redis://redis:6379",
1340
+ "REDIS_URL",
1341
+ )
1342
+ if engine == "mongodb":
1343
+ return (
1344
+ {
1345
+ "image": f"mongo:{version or '7'}",
1346
+ "ports": ["${HARNESS_MONGODB_PORT:-57017}:27017"],
1347
+ "healthcheck": {
1348
+ "test": [
1349
+ "CMD",
1350
+ "mongosh",
1351
+ "--quiet",
1352
+ "--eval",
1353
+ "db.adminCommand('ping').ok",
1354
+ ],
1355
+ "interval": "1s",
1356
+ "timeout": "5s",
1357
+ "retries": 60,
1358
+ },
1359
+ },
1360
+ f"mongodb://mongodb:27017/{quote(database, safe='')}",
1361
+ "MONGODB_URL",
1362
+ )
1363
+ if engine == "qdrant":
1364
+ return (
1365
+ {
1366
+ "image": f"qdrant/qdrant:{version or 'v1.13.6'}",
1367
+ "ports": ["${HARNESS_QDRANT_HTTP_PORT:-56333}:6333"],
1368
+ },
1369
+ "http://qdrant:6333",
1370
+ "QDRANT_URL",
1371
+ )
1372
+ if engine == "rabbitmq":
1373
+ return (
1374
+ {
1375
+ # The management plugin is not needed for AMQP workloads and materially raises
1376
+ # memory/boot pressure when several isolated jobs start together. Customers can
1377
+ # still request a management tag explicitly in their contract.
1378
+ "image": f"rabbitmq:{version or '3.13-alpine'}",
1379
+ # The image declares /var/lib/rabbitmq as an anonymous volume. On Docker Desktop
1380
+ # the initial cookie can be materialized as uid 0 even though the broker runs as
1381
+ # uid 100, and the stock entrypoint does not repair that particular file. Seed
1382
+ # the run-local cookie with the correct ownership before delegating back to the
1383
+ # image entrypoint. ``$$`` is Compose escaping, not host interpolation.
1384
+ "entrypoint": [
1385
+ "sh",
1386
+ "-c",
1387
+ 'printf %s "$$RABBITMQ_ERLANG_COOKIE" > '
1388
+ "/var/lib/rabbitmq/.erlang.cookie && "
1389
+ "chown rabbitmq:rabbitmq /var/lib/rabbitmq/.erlang.cookie && "
1390
+ "chmod 400 /var/lib/rabbitmq/.erlang.cookie && "
1391
+ 'exec docker-entrypoint.sh "$$@"',
1392
+ "--",
1393
+ ],
1394
+ # Compose does not reliably retain an image's CMD once an exec-form entrypoint is
1395
+ # supplied (notably with JSON Compose input on Docker Desktop). Pass the stock
1396
+ # broker command explicitly so ``docker-entrypoint.sh`` always receives ``$1``.
1397
+ "command": ["rabbitmq-server"],
1398
+ "environment": {
1399
+ "RABBITMQ_DEFAULT_USER": "harness",
1400
+ "RABBITMQ_DEFAULT_PASS": "harness-local",
1401
+ # RabbitMQ 3.13 on Docker Desktop can create the image-declared anonymous
1402
+ # data volume's cookie as root before the broker drops to uid 100. Erlang
1403
+ # then aborts with EACCES. Supplying an environment-local cookie makes the
1404
+ # broker use the injected value and avoids depending on that image-volume
1405
+ # ownership race. Each Compose project has a private network, so this is an
1406
+ # internal bootstrap credential, never a customer or platform secret.
1407
+ "RABBITMQ_ERLANG_COOKIE": "harness-local-cookie",
1408
+ # Erlang otherwise sizes scheduler/dirty-scheduler pools from the host CPU
1409
+ # count, not the job's practical sandbox share. Concurrent environments can
1410
+ # then exhaust Docker's thread budget during boot even though each broker is
1411
+ # small. Keep the harness-owned broker deterministic and resource-bounded.
1412
+ "RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS": "+S 2:2 +SDcpu 1 +SDio 1",
1413
+ },
1414
+ "ports": ["${HARNESS_RABBITMQ_PORT:-55672}:5672"],
1415
+ "healthcheck": {
1416
+ "test": ["CMD", "rabbitmq-diagnostics", "-q", "ping"],
1417
+ "interval": "1s",
1418
+ "timeout": "5s",
1419
+ "retries": 90,
1420
+ },
1421
+ },
1422
+ "amqp://harness:harness-local@rabbitmq:5672/%2F",
1423
+ "AMQP_URL",
1424
+ )
1425
+ if engine == "nats":
1426
+ return (
1427
+ {
1428
+ "image": f"nats:{version or '2.10-alpine'}",
1429
+ "command": ["-js", "-m", "8222"],
1430
+ "ports": [
1431
+ "${HARNESS_NATS_PORT:-54222}:4222",
1432
+ "${HARNESS_NATS_MONITORING_PORT:-58222}:8222",
1433
+ ],
1434
+ "healthcheck": {
1435
+ "test": [
1436
+ "CMD",
1437
+ "wget",
1438
+ "--spider",
1439
+ "-q",
1440
+ "http://127.0.0.1:8222/healthz",
1441
+ ],
1442
+ "interval": "1s",
1443
+ "timeout": "5s",
1444
+ "retries": 60,
1445
+ },
1446
+ },
1447
+ "nats://nats:4222",
1448
+ "NATS_URL",
1449
+ )
1450
+ if engine == "minio":
1451
+ return (
1452
+ {
1453
+ "image": f"minio/minio:{version or 'RELEASE.2025-04-22T22-12-26Z'}",
1454
+ "command": ["server", "/data", "--console-address", ":9001"],
1455
+ "environment": {
1456
+ "MINIO_ROOT_USER": "harness",
1457
+ "MINIO_ROOT_PASSWORD": "harness-local-secret",
1458
+ },
1459
+ "ports": [
1460
+ "${HARNESS_MINIO_PORT:-59010}:9000",
1461
+ "${HARNESS_MINIO_CONSOLE_PORT:-59011}:9001",
1462
+ ],
1463
+ "healthcheck": {
1464
+ "test": [
1465
+ "CMD",
1466
+ "curl",
1467
+ "-f",
1468
+ "http://127.0.0.1:9000/minio/health/ready",
1469
+ ],
1470
+ "interval": "1s",
1471
+ "timeout": "5s",
1472
+ "retries": 60,
1473
+ },
1474
+ },
1475
+ "http://minio:9000",
1476
+ "S3_ENDPOINT_URL",
1477
+ )
1478
+ raise ProvisionError(f"managed_dependency_unsupported: {engine}")
1479
+
1480
+
1481
+ def _managed_compose(
1482
+ source: Path,
1483
+ destination: Path,
1484
+ contract: Any,
1485
+ generated_runtime: GeneratedRuntimePlan | None = None,
1486
+ ) -> Path | None:
1487
+ """Write a harness-owned adapter for a supported source-declared data store.
1488
+
1489
+ This is deliberately registry-like and explicit. A recognized engine gets its real service;
1490
+ an unknown engine returns no adapter and provisioning fails clearly rather than substituting
1491
+ Postgres or an in-memory fake with different behavior.
1492
+ """
1493
+ store = getattr(contract, "data_store", None)
1494
+ kind = str(getattr(store, "kind", "") or "").lower()
1495
+ dependencies = list(getattr(contract, "dependencies", None) or [])
1496
+ supported = (
1497
+ "clickhouse",
1498
+ "postgres",
1499
+ "mysql",
1500
+ "redis",
1501
+ "mongodb",
1502
+ "qdrant",
1503
+ "rabbitmq",
1504
+ "nats",
1505
+ "minio",
1506
+ )
1507
+ primary_engine = next((one for one in supported if one in kind), "")
1508
+ requested: list[tuple[str, Any | None]] = []
1509
+ embedded_store = any(
1510
+ marker in kind.replace("-", "_").replace(" ", "_")
1511
+ for marker in (
1512
+ "in_process",
1513
+ "in_memory",
1514
+ "memory",
1515
+ "sqlite",
1516
+ "filesystem",
1517
+ "file_store",
1518
+ "local_state",
1519
+ )
1520
+ )
1521
+ unsupported_declared = bool(kind) and not primary_engine and not embedded_store
1522
+ dependency_manifest = ""
1523
+ if generated_runtime is not None:
1524
+ manifest = (
1525
+ source / generated_runtime.component / generated_runtime.dependency_file
1526
+ )
1527
+ if manifest.is_file():
1528
+ dependency_manifest = (
1529
+ manifest.read_text(encoding="utf-8", errors="replace")
1530
+ .lower()
1531
+ .replace("_", "-")
1532
+ )
1533
+ if primary_engine:
1534
+ requested.append((primary_engine, None))
1535
+ for dependency in dependencies:
1536
+ description = (
1537
+ f"{getattr(dependency, 'name', '')} {getattr(dependency, 'engine', '')} "
1538
+ f"{getattr(dependency, 'kind', '')} {getattr(dependency, 'what', '')}"
1539
+ ).lower()
1540
+ engine = next((one for one in supported if one in description), "")
1541
+ declared_engine = str(getattr(dependency, "engine", "") or "").strip()
1542
+ reached = getattr(dependency, "reached", None)
1543
+ dsn_env = str(getattr(reached, "dsn_env", "") or "").strip()
1544
+ config_key = str(getattr(reached, "config_key", "") or "").strip()
1545
+ password_from = str(getattr(reached, "password_from", "") or "").strip()
1546
+ normalized_description = description.replace("-", "_").replace(" ", "_")
1547
+ reached_host = str(getattr(reached, "host", "") or "").strip()
1548
+ normalized_reached_host = reached_host.replace("-", "_").replace(" ", "_")
1549
+ embedded_engine = any(
1550
+ marker in normalized_description
1551
+ for marker in ("sqlite", "in_process", "in_memory", "filesystem")
1552
+ )
1553
+ embedded_dependency = bool(
1554
+ reached
1555
+ and (
1556
+ str(getattr(reached, "loader_module", "") or "").strip()
1557
+ or str(getattr(reached, "loader_function", "") or "").strip()
1558
+ )
1559
+ and not dsn_env
1560
+ and not str(getattr(reached, "config_key", "") or "").strip()
1561
+ and not str(getattr(reached, "password_from", "") or "").strip()
1562
+ # SQLite is process-local even when source understanding describes the host in
1563
+ # prose (for example ``in-memory :memory:``) instead of using the literal sentinel.
1564
+ # It is embedded state, not an external service ALK must supply.
1565
+ and (embedded_engine or reached_host in {"", ":memory:"})
1566
+ and not getattr(reached, "port", None)
1567
+ and not str(getattr(reached, "database", "") or "").strip()
1568
+ )
1569
+ package_name = re.split(r"[<>=!~\s\[]", declared_engine, maxsplit=1)[0]
1570
+ packaged_dependency = bool(
1571
+ generated_runtime is not None
1572
+ and package_name
1573
+ and package_name.lower().replace("_", "-") in dependency_manifest
1574
+ and not dsn_env
1575
+ and not config_key
1576
+ and not password_from
1577
+ and not str(getattr(reached, "database", "") or "").strip()
1578
+ and not (reached_host not in {"", ":memory:"})
1579
+ and not getattr(reached, "port", None)
1580
+ )
1581
+ external_provider = bool(
1582
+ reached
1583
+ and (password_from or dsn_env or config_key)
1584
+ and not str(getattr(reached, "database", "") or "").strip()
1585
+ )
1586
+ # Hosted model, speech and media providers are reached with customer credentials; they
1587
+ # are not infrastructure ALK can or should synthesize. Source understanding occasionally
1588
+ # records the provider accurately but omits its well-known credential variable. Keep that
1589
+ # contract omission in preflight/credential UX instead of misclassifying Gemini or
1590
+ # Deepgram as an unsupported Docker service.
1591
+ hosted_provider = any(
1592
+ marker in normalized_description
1593
+ for marker in (
1594
+ "openai",
1595
+ "anthropic",
1596
+ "gemini",
1597
+ "vertex_ai",
1598
+ "deepgram",
1599
+ "cartesia",
1600
+ "elevenlabs",
1601
+ "livekit",
1602
+ "retell",
1603
+ "vapi",
1604
+ "daily",
1605
+ )
1606
+ )
1607
+ # A submitted image can own process-local runtime dependencies such as an ONNX VAD
1608
+ # model downloaded by its Dockerfile. They are not infrastructure ALK must synthesize.
1609
+ # Keep this deliberately structural and narrow: an unknown customer service with no
1610
+ # explicit local-runtime reachability remains unsupported, and any network/database or
1611
+ # credential seam prevents this classification.
1612
+ local_runtime_dependency = bool(
1613
+ reached
1614
+ and any(
1615
+ marker in normalized_reached_host
1616
+ for marker in (
1617
+ "in_process",
1618
+ "in_memory",
1619
+ "local_model",
1620
+ "model_file",
1621
+ "downloaded_at_build",
1622
+ "on_device",
1623
+ "embedded_runtime",
1624
+ )
1625
+ )
1626
+ and not dsn_env
1627
+ and not config_key
1628
+ and not password_from
1629
+ and not getattr(reached, "port", None)
1630
+ and not str(getattr(reached, "database", "") or "").strip()
1631
+ )
1632
+ if (
1633
+ not engine
1634
+ and declared_engine
1635
+ and not external_provider
1636
+ and not hosted_provider
1637
+ and not embedded_dependency
1638
+ and not packaged_dependency
1639
+ and not local_runtime_dependency
1640
+ ):
1641
+ unsupported_declared = True
1642
+ if engine and engine not in {name for name, _ in requested}:
1643
+ requested.append((engine, dependency))
1644
+ # A standalone Dockerfile needs no service adapter. A Dockerfile whose contract names a
1645
+ # service we cannot supply is different: do not silently omit that dependency and pretend
1646
+ # the environment is complete.
1647
+ if unsupported_declared:
1648
+ return None
1649
+ runtime = getattr(contract, "runtime", None)
1650
+ dockerfile_value = str(getattr(runtime, "dockerfile", "") or "Dockerfile")
1651
+ dockerfile = source / dockerfile_value
1652
+ if generated_runtime is None and not dockerfile.is_file():
1653
+ if not requested:
1654
+ return None
1655
+ raise ProvisionError(
1656
+ "the agent requires "
1657
+ + ", ".join(engine for engine, _ in requested)
1658
+ + " but ships neither Compose nor a Dockerfile; "
1659
+ "the harness can provision dependencies only when it can run the submitted code"
1660
+ )
1661
+
1662
+ init_mounts: list[str] = []
1663
+ schema_value = str(getattr(store, "schema_from", "") or "").strip()
1664
+ if schema_value:
1665
+ # ``schema_from`` is evidence extracted from source, not guaranteed to be a path. Models
1666
+ # may accurately describe an inline DDL constant here (including table names), and feeding
1667
+ # that prose to ``stat`` can raise ENAMETOOLONG before a submitted Dockerfile is built.
1668
+ # Only a real path contained by the submitted checkout is eligible for an init mount.
1669
+ try:
1670
+ schema = (source / schema_value).resolve()
1671
+ source_root = source.resolve()
1672
+ schema_is_source_path = (
1673
+ schema.is_relative_to(source_root) and schema.exists()
1674
+ )
1675
+ except (OSError, RuntimeError, ValueError):
1676
+ schema_is_source_path = False
1677
+ if schema_is_source_path and (
1678
+ schema.is_dir() or schema.suffix.lower() == ".sql"
1679
+ ):
1680
+ target = (
1681
+ "/docker-entrypoint-initdb.d/source"
1682
+ if schema.is_dir()
1683
+ else "/docker-entrypoint-initdb.d/001-source.sql"
1684
+ )
1685
+ init_mounts.append(f"{schema}:{target}:ro")
1686
+
1687
+ database = str(getattr(store, "database", "") or "harness")
1688
+ user = str(getattr(store, "user", "") or "harness")
1689
+ services: dict[str, Any] = {}
1690
+ runtime_environment: dict[str, str] = {}
1691
+ depends_on: dict[str, Any] = {}
1692
+ for engine, declared_dependency in requested:
1693
+ version = str(
1694
+ getattr(declared_dependency, "version", "")
1695
+ or (getattr(store, "version", "") if engine == primary_engine else "")
1696
+ or ""
1697
+ )
1698
+ service, internal_dsn, default_variable = _managed_service(
1699
+ engine,
1700
+ version=version,
1701
+ database=database,
1702
+ user=user,
1703
+ init_mounts=init_mounts if engine == primary_engine else [],
1704
+ )
1705
+ variable = ""
1706
+ if engine == primary_engine:
1707
+ variable = _configuration_name(
1708
+ str(getattr(store, "config_key", "") or "")
1709
+ or str(getattr(store, "configured_by", "") or "")
1710
+ )
1711
+ if declared_dependency is not None:
1712
+ reached = getattr(declared_dependency, "reached", None)
1713
+ variable = variable or _configuration_name(
1714
+ str(getattr(reached, "dsn_env", "") or "")
1715
+ or str(getattr(reached, "config_key", "") or "")
1716
+ )
1717
+ runtime_environment[variable or default_variable] = internal_dsn
1718
+ if engine == "minio":
1719
+ # These are harness-owned, run-local credentials for the isolated MinIO service,
1720
+ # not customer credentials. They never enter HarnessJob or SecretRef payloads.
1721
+ runtime_environment.update(
1722
+ {
1723
+ "AWS_ACCESS_KEY_ID": "harness",
1724
+ "AWS_SECRET_ACCESS_KEY": "harness-local-secret",
1725
+ "AWS_DEFAULT_REGION": "us-east-1",
1726
+ }
1727
+ )
1728
+ services[engine] = service
1729
+ depends_on[engine] = {"condition": "service_healthy"}
1730
+ platform_value = str(getattr(runtime, "platform", "") or "")
1731
+ if not platform_value:
1732
+ declared_platforms: set[str] = set()
1733
+ for name in COMPOSE_FILES:
1734
+ compose = source / name
1735
+ if not compose.is_file():
1736
+ continue
1737
+ declared_platforms.update(
1738
+ match.group(1)
1739
+ for match in re.finditer(
1740
+ r"(?m)^\s*platform\s*:\s*['\"]?([^'\"\s#]+)",
1741
+ compose.read_text(encoding="utf-8", errors="replace"),
1742
+ )
1743
+ )
1744
+ if len(declared_platforms) == 1:
1745
+ platform_value = declared_platforms.pop()
1746
+ build_context = str(source)
1747
+ if generated_runtime is not None:
1748
+ build_context = generated_runtime.context_directory
1749
+ dockerfile_value = GENERATED_DOCKERFILE
1750
+ runtime_service: dict[str, Any] = {
1751
+ "build": {"context": build_context, "dockerfile": dockerfile_value},
1752
+ "profiles": ["harness-runtime"],
1753
+ "environment": runtime_environment,
1754
+ "depends_on": depends_on,
1755
+ }
1756
+ if generated_runtime is not None:
1757
+ runtime_service["command"] = list(generated_runtime.command)
1758
+ if platform_value:
1759
+ runtime_service["platform"] = platform_value
1760
+ services["agent-runtime"] = runtime_service
1761
+ document = {"services": services}
1762
+ destination.mkdir(parents=True, exist_ok=True)
1763
+ target = destination / "managed-compose.json"
1764
+ target.write_text(json.dumps(document, indent=2), encoding="utf-8")
1765
+ return target
1766
+
1767
+
1768
+ def provision(
1769
+ source: str | Path,
1770
+ destination: str | Path,
1771
+ contract: Any | None = None,
1772
+ ) -> ProvisionedEnvironment:
1773
+ """Start and record the environment described by one source repository."""
1774
+ source_root = Path(source).expanduser().resolve()
1775
+ destination = Path(destination)
1776
+ packaging = inspect_packaging(source_root)
1777
+ generated_runtime: GeneratedRuntimePlan | None = None
1778
+ explicit_compose = str(
1779
+ getattr(getattr(contract, "runtime", None), "compose_file", "") or ""
1780
+ )
1781
+ explicit_dockerfile = str(
1782
+ getattr(getattr(contract, "runtime", None), "dockerfile", "") or ""
1783
+ )
1784
+ # A Compose service commonly builds from the repository's Dockerfile. When understanding
1785
+ # records both, Compose remains the authoritative full-stack runtime; the Dockerfile is not a
1786
+ # competing selection. Ambiguous repositories are rejected earlier by packaging admission.
1787
+ if explicit_compose:
1788
+ selected_compose = (source_root / explicit_compose).resolve()
1789
+ try:
1790
+ selected_compose.relative_to(source_root)
1791
+ except ValueError as exc:
1792
+ raise ProvisionError(
1793
+ "runtime Compose file escapes the submitted repository"
1794
+ ) from exc
1795
+ if not selected_compose.is_file():
1796
+ raise ProvisionError(
1797
+ f"runtime Compose file does not exist: {explicit_compose}"
1798
+ )
1799
+ explicit_candidate = next(
1800
+ (
1801
+ item
1802
+ for item in packaging.candidates
1803
+ if item.kind is PackagingKind.COMPOSE
1804
+ and item.path == Path(explicit_compose).as_posix()
1805
+ ),
1806
+ None,
1807
+ )
1808
+ if explicit_candidate is None:
1809
+ raise ProvisionError(
1810
+ "runtime Compose file is not a supported submitted Compose file: "
1811
+ f"{explicit_compose}"
1812
+ )
1813
+ blocking = [
1814
+ finding.message
1815
+ for finding in explicit_candidate.findings
1816
+ if finding.blocking
1817
+ and not (
1818
+ finding.code == "compose_env_file_missing"
1819
+ # The explicit runtime came from the saved contract. ALK renders a private
1820
+ # override below that removes the missing env_file, while start_runtime injects
1821
+ # the resolved job-scoped values. Requiring a worker-only process variable here
1822
+ # made the same bundle resumable from hosted execution but not from the CLI.
1823
+ and contract is not None
1824
+ )
1825
+ ]
1826
+ if blocking:
1827
+ raise ProvisionError("packaging preflight failed: " + "; ".join(blocking))
1828
+ compose = selected_compose
1829
+ elif explicit_dockerfile:
1830
+ selected_dockerfile = (source_root / explicit_dockerfile).resolve()
1831
+ try:
1832
+ selected_dockerfile.relative_to(source_root)
1833
+ except ValueError as exc:
1834
+ raise ProvisionError(
1835
+ "runtime Dockerfile escapes the submitted repository"
1836
+ ) from exc
1837
+ if not selected_dockerfile.is_file():
1838
+ raise ProvisionError(
1839
+ f"runtime Dockerfile does not exist: {explicit_dockerfile}"
1840
+ )
1841
+ explicit_candidate = next(
1842
+ (
1843
+ item
1844
+ for item in packaging.candidates
1845
+ if item.kind is PackagingKind.DOCKERFILE
1846
+ and item.path == Path(explicit_dockerfile).as_posix()
1847
+ ),
1848
+ None,
1849
+ )
1850
+ blocking = [
1851
+ finding.message
1852
+ for finding in (explicit_candidate.findings if explicit_candidate else [])
1853
+ if finding.blocking
1854
+ ]
1855
+ if blocking:
1856
+ raise ProvisionError("packaging preflight failed: " + "; ".join(blocking))
1857
+ compose = None
1858
+ elif packaging.ready and packaging.selected_kind is PackagingKind.COMPOSE:
1859
+ assert packaging.selected_path is not None
1860
+ compose = source_root / packaging.selected_path
1861
+ elif packaging.ready and packaging.selected_kind is PackagingKind.DOCKERFILE:
1862
+ compose = None
1863
+ elif packaging.candidates:
1864
+ details = list(packaging.notes)
1865
+ details.extend(
1866
+ finding.message
1867
+ for candidate in packaging.candidates
1868
+ for finding in candidate.findings
1869
+ if finding.blocking
1870
+ )
1871
+ raise ProvisionError("packaging preflight failed: " + "; ".join(details))
1872
+ else:
1873
+ compose = None
1874
+ managed = False
1875
+ if compose is None and contract is not None:
1876
+ if not packaging.candidates and not (source_root / "Dockerfile").is_file():
1877
+ try:
1878
+ generated_runtime = prepare_generated_runtime(
1879
+ source_root,
1880
+ destination,
1881
+ getattr(contract, "runtime", None),
1882
+ )
1883
+ except GeneratedRuntimeError as exc:
1884
+ dependencies = list(getattr(contract, "dependencies", None) or [])
1885
+ required = [
1886
+ str(getattr(item, "engine", "") or getattr(item, "name", ""))
1887
+ for item in dependencies
1888
+ ]
1889
+ prefix = (
1890
+ "the agent requires "
1891
+ + ", ".join(item for item in required if item)
1892
+ + " but ships neither Compose nor a Dockerfile; "
1893
+ if required
1894
+ else ""
1895
+ )
1896
+ raise ProvisionError(prefix + str(exc)) from exc
1897
+ compose = _managed_compose(
1898
+ source_root, destination, contract, generated_runtime=generated_runtime
1899
+ )
1900
+ managed = compose is not None
1901
+ if compose is None:
1902
+ raise ProvisionError(
1903
+ f"{source_root} does not ship a Compose file; a non-Compose runtime adapter is required"
1904
+ )
1905
+ fingerprint = source_fingerprint(source_root)
1906
+ runtime_fingerprint = generated_runtime.fingerprint if generated_runtime else ""
1907
+ runtime_configuration_names = sorted(
1908
+ {
1909
+ *_contract_runtime_configuration_names(contract),
1910
+ *_saved_runtime_configuration_names(destination),
1911
+ }
1912
+ )
1913
+ existing = ProvisionedEnvironment.load(destination)
1914
+ if (
1915
+ existing
1916
+ and Path(existing.source) == source_root
1917
+ and existing.source_fingerprint == fingerprint
1918
+ and existing.runtime_fingerprint == runtime_fingerprint
1919
+ and existing.running
1920
+ ):
1921
+ # Verify rather than trusting a stale file left by a killed process.
1922
+ if not existing.services or _run(
1923
+ existing, "ps", "--status", "running", "--quiet", check=False, timeout=30
1924
+ ):
1925
+ config = _config(existing)
1926
+ _validate_compose_security(config)
1927
+ existing.runtime_services = _runtime_services(config, contract)
1928
+ existing.services = _started_services(config, existing.runtime_services)
1929
+ existing.service_endpoints = _service_endpoints(existing, config)
1930
+ _wait_for_environment_endpoints(existing)
1931
+ existing.overrides = _overrides(existing, config)
1932
+ existing.internal_overrides = _internal_overrides(existing, config)
1933
+ existing.runtime_configuration_names = runtime_configuration_names
1934
+ existing.save(destination)
1935
+ return existing
1936
+
1937
+ # A recorded project belongs only to this session. If its source changed or its process died,
1938
+ # remove its test volumes before replacing it so stale rows and orphan containers cannot be
1939
+ # mistaken for the newly submitted environment.
1940
+ if existing is not None and existing.running:
1941
+ _detach_runner_network(existing)
1942
+ _run(
1943
+ existing,
1944
+ "down",
1945
+ "--volumes",
1946
+ "--remove-orphans",
1947
+ check=False,
1948
+ timeout=120,
1949
+ )
1950
+
1951
+ project = "fagi-harness-" + secrets.token_hex(4)
1952
+ environment = ProvisionedEnvironment(
1953
+ source=str(source_root),
1954
+ compose_file=str(compose),
1955
+ project=project,
1956
+ port_variables=port_variables(compose),
1957
+ source_fingerprint=fingerprint,
1958
+ managed=managed,
1959
+ generated_runtime_plan=(
1960
+ str(destination / "generated-runtime.json") if generated_runtime else ""
1961
+ ),
1962
+ runtime_fingerprint=runtime_fingerprint,
1963
+ runtime_configuration_names=runtime_configuration_names,
1964
+ )
1965
+ _write_initial_env_file_override(destination, environment)
1966
+ config = _config(environment)
1967
+ _validate_compose_security(config)
1968
+ _write_port_override(destination, environment, config)
1969
+ if environment.compose_override_file:
1970
+ config = _config(environment)
1971
+ _validate_compose_security(config)
1972
+ environment.runtime_services = _runtime_services(config, contract)
1973
+ environment.services = _started_services(config, environment.runtime_services)
1974
+ if not environment.services and len(environment.runtime_services) != 1:
1975
+ raise ProvisionError(
1976
+ "the Compose file has neither default infrastructure services nor exactly one "
1977
+ "opt-in agent runtime"
1978
+ )
1979
+ try:
1980
+ started = time.monotonic()
1981
+ # Build the submitted runtime during provisioning so packaging failures surface before a
1982
+ # simulation is accepted. A Dockerfile-only agent may legitimately have no infrastructure
1983
+ # containers to start; its environment is the isolated, validated, built runtime itself.
1984
+ if environment.runtime_services:
1985
+ _build_runtime(environment)
1986
+ if environment.services:
1987
+ _start_managed_services(environment, environment.services)
1988
+ environment.service_endpoints = _service_endpoints(environment, config)
1989
+ try:
1990
+ _wait_for_environment_endpoints(environment)
1991
+ except ProvisionError as exc:
1992
+ # A submitted stack can be healthy internally while Docker Desktop/the hosted
1993
+ # provider has not made its newly published ports reachable from the runner yet.
1994
+ # Recreate this run's services and volumes once. This is infrastructure recovery,
1995
+ # before any scenario executes; it neither retries nor hides an agent outcome.
1996
+ if not environment.services or not str(exc).startswith(
1997
+ "environment endpoints did not become ready:"
1998
+ ):
1999
+ raise
2000
+ _detach_runner_network(environment)
2001
+ _run(
2002
+ environment,
2003
+ "down",
2004
+ "--volumes",
2005
+ "--remove-orphans",
2006
+ check=False,
2007
+ timeout=120,
2008
+ )
2009
+ # Reusing the same published port cannot recover a Docker/host forwarding failure.
2010
+ # Re-render both interpolated and fixed port mappings before the second boot.
2011
+ config = _refresh_compose_ports(destination, environment)
2012
+ environment.runtime_services = _runtime_services(config, contract)
2013
+ environment.services = _started_services(
2014
+ config, environment.runtime_services
2015
+ )
2016
+ _start_managed_services(environment, environment.services, build=False)
2017
+ config = _config(environment)
2018
+ environment.runtime_services = _runtime_services(config, contract)
2019
+ environment.services = _started_services(
2020
+ config, environment.runtime_services
2021
+ )
2022
+ environment.service_endpoints = _service_endpoints(environment, config)
2023
+ _wait_for_environment_endpoints(environment)
2024
+ environment.overrides = _overrides(environment, config)
2025
+ environment.internal_overrides = _internal_overrides(environment, config)
2026
+ environment.running = True
2027
+ environment.provision_seconds = round(time.monotonic() - started, 3)
2028
+ environment.save(destination)
2029
+ return environment
2030
+ except Exception:
2031
+ _detach_runner_network(environment)
2032
+ _run(
2033
+ environment,
2034
+ "down",
2035
+ "--volumes",
2036
+ "--remove-orphans",
2037
+ check=False,
2038
+ timeout=120,
2039
+ )
2040
+ raise
2041
+
2042
+
2043
+ def provision_if_present(
2044
+ source: str | Path, destination: str | Path, contract: Any | None = None
2045
+ ) -> ProvisionedEnvironment | None:
2046
+ """Provision a repository's Compose environment, or do nothing for in-process agents."""
2047
+ if compose_file(source) is None and contract is None:
2048
+ return None
2049
+ if compose_file(source) is None:
2050
+ store = getattr(contract, "data_store", None)
2051
+ dependencies = list(getattr(contract, "dependencies", None) or [])
2052
+ runtime = getattr(contract, "runtime", None)
2053
+ compose_value = str(getattr(runtime, "compose_file", "") or "")
2054
+ dockerfile_value = str(getattr(runtime, "dockerfile", "") or "Dockerfile")
2055
+ source_root = Path(source).expanduser().resolve()
2056
+ has_runtime = bool(compose_value) or (source_root / dockerfile_value).is_file()
2057
+ generated = can_generate_runtime(source, runtime)
2058
+ if store is None and not dependencies and not has_runtime and not generated:
2059
+ return None
2060
+ return provision(source, destination, contract)
2061
+
2062
+
2063
+ def reset(destination: str | Path) -> ProvisionedEnvironment:
2064
+ """Return a provisioned environment to the repository's declared seed state.
2065
+
2066
+ This generic reset is deliberately lifecycle-based: removing only this Compose project's
2067
+ volumes and recreating its already-built default services works for databases, queues and
2068
+ filesystems without guessing their internal reset protocol. Engine-specific snapshot
2069
+ adapters may optimise this later, but they must preserve the same observable result.
2070
+ """
2071
+ destination = Path(destination)
2072
+ environment = ProvisionedEnvironment.load(destination)
2073
+ if environment is None:
2074
+ raise ProvisionError(f"no environment recorded at {destination}")
2075
+ if environment.runtime_container:
2076
+ stop_runtime(destination)
2077
+ environment = ProvisionedEnvironment.load(destination) or environment
2078
+ _detach_runner_network(environment)
2079
+ _run(
2080
+ environment,
2081
+ "down",
2082
+ "--volumes",
2083
+ "--remove-orphans",
2084
+ check=False,
2085
+ timeout=120,
2086
+ )
2087
+ started = time.monotonic()
2088
+ if environment.services:
2089
+ _start_managed_services(environment, environment.services, build=False)
2090
+ config = _config(environment)
2091
+ _validate_compose_security(config)
2092
+ recorded_runtime = [
2093
+ name for name in environment.runtime_services if name in config["services"]
2094
+ ]
2095
+ environment.runtime_services = recorded_runtime or _runtime_services(config)
2096
+ environment.services = _started_services(config, environment.runtime_services)
2097
+ environment.service_endpoints = _service_endpoints(environment, config)
2098
+ _wait_for_environment_endpoints(environment)
2099
+ environment.overrides = _overrides(environment, config)
2100
+ environment.internal_overrides = _internal_overrides(environment, config)
2101
+ environment.running = True
2102
+ environment.provision_seconds = round(time.monotonic() - started, 3)
2103
+ environment.save(destination)
2104
+ return environment
2105
+
2106
+
2107
+ def healthy(destination: str | Path) -> bool:
2108
+ """Whether the recorded project still has all of its expected services running.
2109
+
2110
+ Runtime providers use this public probe rather than reaching into Compose mechanics. A
2111
+ stale manifest therefore becomes an unhealthy runtime instead of being trusted as ready.
2112
+ """
2113
+ environment = ProvisionedEnvironment.load(Path(destination))
2114
+ if environment is None or not environment.running:
2115
+ return False
2116
+ if not environment.services:
2117
+ if not environment.runtime_services:
2118
+ return False
2119
+ if not environment.runtime_container:
2120
+ return True
2121
+ return bool(
2122
+ _docker(
2123
+ "inspect",
2124
+ "--format",
2125
+ "{{.State.Running}}",
2126
+ environment.runtime_container,
2127
+ check=False,
2128
+ timeout=30,
2129
+ ).strip()
2130
+ == "true"
2131
+ )
2132
+ running = _run(
2133
+ environment,
2134
+ "ps",
2135
+ "--status",
2136
+ "running",
2137
+ "--services",
2138
+ check=False,
2139
+ timeout=30,
2140
+ )
2141
+ found = {line.strip() for line in running.splitlines() if line.strip()}
2142
+ if not set(environment.services).issubset(found):
2143
+ return False
2144
+ try:
2145
+ # Provision/reset require a stability window; this is a point-in-time liveness probe.
2146
+ _wait_for_environment_endpoints(environment, 1.0, 0.0)
2147
+ except ProvisionError:
2148
+ return False
2149
+ return True
2150
+
2151
+
2152
+ def _docker(*arguments: str, check: bool = True, timeout: int = 120) -> str:
2153
+ try:
2154
+ completed = subprocess.run(
2155
+ ["docker", *arguments],
2156
+ capture_output=True,
2157
+ text=True,
2158
+ check=False,
2159
+ timeout=timeout,
2160
+ )
2161
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
2162
+ raise ProvisionError(
2163
+ f"docker {' '.join(arguments)} could not run: {exc}"
2164
+ ) from exc
2165
+ output = ((completed.stdout or "") + (completed.stderr or "")).strip()
2166
+ if check and completed.returncode:
2167
+ raise ProvisionError(
2168
+ f"docker {' '.join(arguments)} failed ({completed.returncode}): "
2169
+ f"{output or 'no output'}"
2170
+ )
2171
+ return output
2172
+
2173
+
2174
+ def _remove_unused_harness_networks(*, exclude_project: str = "") -> int:
2175
+ """Remove empty networks owned by terminated ALK Compose projects."""
2176
+ listing = _docker(
2177
+ "network",
2178
+ "ls",
2179
+ "--filter",
2180
+ "label=com.docker.compose.project",
2181
+ "--format",
2182
+ "{{.Name}}",
2183
+ check=False,
2184
+ timeout=30,
2185
+ )
2186
+ removed = 0
2187
+ for name in sorted(set(listing.splitlines())):
2188
+ if not name.startswith("fagi-harness-") or name == f"{exclude_project}_default":
2189
+ continue
2190
+ containers = _docker(
2191
+ "network",
2192
+ "inspect",
2193
+ "--format",
2194
+ "{{json .Containers}}",
2195
+ name,
2196
+ check=False,
2197
+ timeout=30,
2198
+ ).strip()
2199
+ try:
2200
+ attached = json.loads(containers or "null")
2201
+ except json.JSONDecodeError:
2202
+ continue
2203
+ if attached != {}:
2204
+ continue
2205
+ result = _docker("network", "rm", name, check=False, timeout=30)
2206
+ if "error" not in result.lower():
2207
+ removed += 1
2208
+ return removed
2209
+
2210
+
2211
+ def _remove_empty_project_default_network(project: str) -> bool:
2212
+ """Remove only this run's empty Compose default network after teardown.
2213
+
2214
+ Docker Compose can return successfully from ``down`` while leaving its default
2215
+ network behind when the containerized runner was detached immediately before
2216
+ teardown. Repeated hosted runs then exhaust Docker's address pools. The network
2217
+ name is deterministic and ALK-owned; still require zero attachments before
2218
+ removing it, so cleanup can never disconnect another process.
2219
+ """
2220
+ if not project.startswith("fagi-harness-"):
2221
+ return False
2222
+ name = f"{project}_default"
2223
+ containers = _docker(
2224
+ "network",
2225
+ "inspect",
2226
+ "--format",
2227
+ "{{json .Containers}}",
2228
+ name,
2229
+ check=False,
2230
+ timeout=30,
2231
+ ).strip()
2232
+ try:
2233
+ attached = json.loads(containers or "null")
2234
+ except json.JSONDecodeError:
2235
+ return False
2236
+ if attached != {}:
2237
+ return False
2238
+ result = _docker("network", "rm", name, check=False, timeout=30)
2239
+ return "error" not in result.lower()
2240
+
2241
+
2242
+ def _valid_google_credentials(path: Path) -> bool:
2243
+ """Google's SDK accepts a path long before it discovers malformed JSON.
2244
+
2245
+ A Compose placeholder such as ``/dev/null`` therefore looks configured during discovery but
2246
+ fails only after a paid voice call has started. Validate the actual host file before mounting
2247
+ it into a submitted worker.
2248
+ """
2249
+ if not path.is_file():
2250
+ return False
2251
+ try:
2252
+ value = json.loads(path.read_text(encoding="utf-8"))
2253
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
2254
+ return False
2255
+ return isinstance(value, dict) and bool(value)
2256
+
2257
+
2258
+ def _runtime_credential_mounts(
2259
+ service_environment: dict[str, str], service_config: dict[str, Any]
2260
+ ) -> list[tuple[str, Path, str]]:
2261
+ """Resolve credential variables to real host files and their container targets.
2262
+
2263
+ Repository Compose files commonly expose a stable in-container credential path backed by a
2264
+ harmless placeholder when run without platform secrets. The harness replaces that placeholder
2265
+ with its platform-owned credential; repository users should not have to copy secrets into the
2266
+ submitted source tree.
2267
+ """
2268
+ declared_volumes = {
2269
+ str(volume.get("target", "")): Path(str(volume.get("source", ""))).expanduser()
2270
+ for volume in service_config.get("volumes", [])
2271
+ if isinstance(volume, dict) and volume.get("target") and volume.get("source")
2272
+ }
2273
+ mounts: list[tuple[str, Path, str]] = []
2274
+ hosted_execution = os.environ.get("ALK_HOSTED_EXECUTION", "").strip() == "1"
2275
+ approved_secret_files = {
2276
+ name.strip()
2277
+ for name in os.environ.get("ALK_RUNTIME_SECRET_FILE_NAMES", "").split(",")
2278
+ if name.strip()
2279
+ }
2280
+ for name, value in service_environment.items():
2281
+ if not any(marker in name.upper() for marker in ("CREDENTIAL", "KEY_FILE")):
2282
+ continue
2283
+ configured = Path(value).expanduser()
2284
+ target = f"/run/harness-secrets/{configured.name}"
2285
+ source = (
2286
+ configured if configured.is_absolute() and configured.is_file() else None
2287
+ )
2288
+ if hosted_execution and name not in approved_secret_files:
2289
+ # A raw path supplied by an untrusted hosted job must never become a host bind mount.
2290
+ # Only the provider's opaque secret-file upload flow may authorize file mounts.
2291
+ source = None
2292
+ if configured.is_absolute() and value in declared_volumes:
2293
+ target = value
2294
+ declared = declared_volumes[value]
2295
+ source = declared if declared.is_file() else None
2296
+ if name.upper() == "GOOGLE_APPLICATION_CREDENTIALS":
2297
+ if source is None or not _valid_google_credentials(source):
2298
+ runtime_value = runtime_configuration_value(name).strip()
2299
+ runtime = Path(runtime_value).expanduser() if runtime_value else None
2300
+ if (
2301
+ runtime is not None
2302
+ and _valid_google_credentials(runtime)
2303
+ and (not hosted_execution or name in approved_secret_files)
2304
+ ):
2305
+ source = runtime
2306
+ # Do not reuse a repository-owned destination when its declared mount is a
2307
+ # placeholder (commonly /dev/null). Compose may retain that service mount
2308
+ # even when ``docker compose run --volume`` supplies a replacement, causing
2309
+ # the worker to see the placeholder. Platform credentials always get an
2310
+ # ALK-owned destination that cannot collide with submitted mounts.
2311
+ target = f"/run/harness-secrets/{runtime.name}"
2312
+ if source is None or not _valid_google_credentials(source):
2313
+ raise ProvisionError(
2314
+ "the submitted runtime needs GOOGLE_APPLICATION_CREDENTIALS, but no valid "
2315
+ "JSON credential file was mounted for this job; upload the service-account JSON "
2316
+ "through the credential-file control"
2317
+ )
2318
+ if source is not None:
2319
+ mounts.append((name, source.resolve(), target))
2320
+ return mounts
2321
+
2322
+
2323
+ def start_runtime(
2324
+ destination: str | Path,
2325
+ *,
2326
+ overrides: dict[str, str] | None = None,
2327
+ trace_path: str | Path | None = None,
2328
+ publish_ports: list[int] | None = None,
2329
+ stable_seconds: float | None = None,
2330
+ ) -> ProvisionedEnvironment:
2331
+ """Start the submitted agent/worker service with only test endpoint substitutions."""
2332
+ destination = Path(destination)
2333
+ environment = ProvisionedEnvironment.load(destination)
2334
+ if environment is None or not environment.running:
2335
+ raise ProvisionError(f"no running environment recorded at {destination}")
2336
+ if len(environment.runtime_services) != 1:
2337
+ raise ProvisionError(
2338
+ "expected exactly one opt-in agent runtime service, found "
2339
+ + (", ".join(environment.runtime_services) or "none")
2340
+ )
2341
+ service = environment.runtime_services[0]
2342
+ container = f"{environment.project}-runtime"
2343
+ # The manifest write happens after readiness. A killed harness can therefore leave the
2344
+ # session-owned container behind without recording it; always reconcile the deterministic
2345
+ # name before starting instead of trusting bookkeeping from a process that may have died.
2346
+ _docker("rm", "--force", container, check=False)
2347
+ config = _config(environment)
2348
+ service_config = config["services"][service]
2349
+ service_environment = _environment_values(service_config)
2350
+ endpoint_overrides = environment.internal_overrides
2351
+ # Endpoints generated for this isolated environment are authoritative. A submitted image may
2352
+ # declare a development default such as ``http://harness:8787`` or ``localhost``; retaining
2353
+ # it because the variable is present in the service environment disconnects the unchanged
2354
+ # agent from the services ALK just provisioned. ``internal_overrides`` already preserves
2355
+ # complete managed DSNs (including generated credentials), so it is both safer and more
2356
+ # accurate than any submitted default.
2357
+ injected = {
2358
+ **endpoint_overrides,
2359
+ "HARNESS_MODE": "1",
2360
+ # The SDK recorder is also a remote LiveKit participant. Agents that
2361
+ # wait for an arbitrary participant can otherwise bind their audio
2362
+ # input to the silent recorder and stall after one turn. This stable
2363
+ # prefix identifies the actual simulated caller across scenarios.
2364
+ "HARNESS_CALLER_IDENTITY_PREFIX": "fagi-simulator",
2365
+ **(overrides or {}),
2366
+ }
2367
+ for name in environment.runtime_configuration_names:
2368
+ value = runtime_configuration_value(name).strip()
2369
+ if value:
2370
+ if name == "LIVEKIT_URL":
2371
+ parsed = urlsplit(value)
2372
+ if (parsed.hostname or "").lower() in {"127.0.0.1", "localhost", "::1"}:
2373
+ port = f":{parsed.port}" if parsed.port else ""
2374
+ value = urlunsplit(
2375
+ (
2376
+ parsed.scheme,
2377
+ f"host.docker.internal{port}",
2378
+ parsed.path,
2379
+ parsed.query,
2380
+ parsed.fragment,
2381
+ )
2382
+ )
2383
+ injected.setdefault(name, value)
2384
+ # LiveKit's production worker defaults to INFO, while its framework-owned tool lifecycle
2385
+ # events are DEBUG. Those events are the only generic evidence available when a submitted
2386
+ # agent executes tools entirely in-process and does not implement the optional trace seam.
2387
+ # This changes logging only, never agent behavior, and raw logs are not retained.
2388
+ injected.setdefault(
2389
+ "LIVEKIT_LOG_LEVEL",
2390
+ os.environ.get("HARNESS_RUNTIME_LOG_LEVEL", "DEBUG").strip() or "DEBUG",
2391
+ )
2392
+ arguments = [
2393
+ "run",
2394
+ "--detach",
2395
+ "--no-deps",
2396
+ "--name",
2397
+ container,
2398
+ ]
2399
+ requested_ports = sorted({int(port) for port in publish_ports or []})
2400
+ runner_is_containerized = bool(os.environ.get("ALK_RUNNER_CONTAINER", "").strip())
2401
+ for port in requested_ports:
2402
+ if port < 1 or port > 65535:
2403
+ raise ProvisionError(f"runtime_publish_port_invalid: {port}")
2404
+ # Bind only loopback and let Docker allocate the host port. Fixed submitted ports must
2405
+ # not collide when two jobs run together. A containerized hosted runner joins the
2406
+ # private project network instead and never publishes the ingress on the Docker host.
2407
+ if not runner_is_containerized:
2408
+ arguments.extend(("--publish", f"127.0.0.1::{port}"))
2409
+ trace_volume = ""
2410
+ trace_destination = ""
2411
+ if trace_path is not None:
2412
+ trace = Path(trace_path).expanduser().resolve()
2413
+ trace.parent.mkdir(parents=True, exist_ok=True)
2414
+ # The submitted runtime commonly runs as a non-root UID that is unrelated to the
2415
+ # sandbox runner's UID. Mounting a root-owned result directory and asking that worker to
2416
+ # create the trace file makes otherwise-successful tools fail with PermissionError in
2417
+ # agents that trace synchronously. Pre-create only the job-owned file and make that file
2418
+ # writable across the container boundary; the surrounding artifact tree stays private.
2419
+ trace.touch(exist_ok=True)
2420
+ trace.chmod(0o666)
2421
+ # Mount only this scenario's result folder. Agents that support the generic harness trace
2422
+ # seam write semantic/model-facing tool events here; agents that do not simply ignore the
2423
+ # variable and the backend proxy remains the fallback evidence source.
2424
+ trace_arguments, trace_volume = _runtime_trace_mount(trace)
2425
+ arguments.extend(trace_arguments)
2426
+ trace_destination = str(trace)
2427
+ container_trace = f"/run/harness-trace/{trace.name}"
2428
+ # HARNESS_AGENT_TOOL_TRACE is the runtime-level contract. Keep the shorter historical
2429
+ # name as a compatibility alias for submitted agents that already adopted it; both point
2430
+ # at the same mounted file, so evidence is still collected exactly once.
2431
+ injected["HARNESS_AGENT_TOOL_TRACE"] = container_trace
2432
+ injected["HARNESS_TOOL_TRACE"] = container_trace
2433
+ # Credential paths in a repository env file name host files. Mount them into a stable,
2434
+ # read-only container location and replace only the path value; never copy or persist the
2435
+ # credential contents in harness artifacts.
2436
+ mounted_credentials: set[str] = set()
2437
+ for name, source, target in _runtime_credential_mounts(
2438
+ service_environment, service_config
2439
+ ):
2440
+ arguments.extend(("--volume", f"{source}:{target}:ro"))
2441
+ injected[name] = target
2442
+ mounted_credentials.add(name)
2443
+ # Dockerfile/generated runtimes may not declare credential variables in their image or
2444
+ # generated Compose model. Provider-authorized file references are still mounted explicitly;
2445
+ # discovery controls the env name while the provider controls the source path.
2446
+ for name in sorted(
2447
+ {
2448
+ item.strip()
2449
+ for item in os.environ.get("ALK_RUNTIME_SECRET_FILE_NAMES", "").split(",")
2450
+ if item.strip()
2451
+ }
2452
+ - mounted_credentials
2453
+ ):
2454
+ runtime_path = runtime_configuration_value(name).strip()
2455
+ source = Path(runtime_path).expanduser() if runtime_path else None
2456
+ valid = source is not None and source.is_file()
2457
+ if name == "GOOGLE_APPLICATION_CREDENTIALS":
2458
+ valid = bool(source is not None and _valid_google_credentials(source))
2459
+ if not valid or source is None:
2460
+ raise ProvisionError(f"job-scoped credential file unavailable: {name}")
2461
+ target = f"/run/harness-secrets/{name.lower()}"
2462
+ arguments.extend(("--volume", f"{source.resolve()}:{target}:ro"))
2463
+ injected[name] = target
2464
+ mounted_credentials.add(name)
2465
+ google_path = injected.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip()
2466
+ if google_path and "GOOGLE_APPLICATION_CREDENTIALS" not in mounted_credentials:
2467
+ if os.environ.get(
2468
+ "ALK_HOSTED_EXECUTION", ""
2469
+ ).strip() == "1" and "GOOGLE_APPLICATION_CREDENTIALS" not in {
2470
+ name.strip()
2471
+ for name in os.environ.get("ALK_RUNTIME_SECRET_FILE_NAMES", "").split(",")
2472
+ if name.strip()
2473
+ }:
2474
+ raise ProvisionError(
2475
+ "hosted credential file path rejected; upload "
2476
+ "GOOGLE_APPLICATION_CREDENTIALS through the credential-file control"
2477
+ )
2478
+ google_source = Path(google_path).expanduser()
2479
+ if not _valid_google_credentials(google_source):
2480
+ raise ProvisionError(
2481
+ "GOOGLE_APPLICATION_CREDENTIALS for the generated runtime is not a valid "
2482
+ "readable JSON credential file"
2483
+ )
2484
+ google_target = f"/run/harness-secrets/{google_source.name}"
2485
+ arguments.extend(("--volume", f"{google_source.resolve()}:{google_target}:ro"))
2486
+ injected["GOOGLE_APPLICATION_CREDENTIALS"] = google_target
2487
+ # Compose does not consistently replace a service-declared environment value when ``run -e
2488
+ # NAME`` only inherits it from the client process. Pin ALK-owned, non-secret routing values
2489
+ # explicitly; credentials and potentially credential-bearing DSNs remain env-only so they
2490
+ # never enter argv, logs, or exception text.
2491
+ explicit_non_secret = {
2492
+ "DEMO_CALLER_ANI",
2493
+ "HARNESS_AGENT_TOOL_TRACE",
2494
+ "HARNESS_CALLER_IDENTITY_PREFIX",
2495
+ "HARNESS_MODE",
2496
+ "HARNESS_TOOL_TRACE",
2497
+ "LIVEKIT_AGENT_NAME",
2498
+ "LIVEKIT_LOG_LEVEL",
2499
+ "TOOLS_API_URL",
2500
+ }
2501
+ for name in sorted(injected):
2502
+ value = f"{name}={injected[name]}" if name in explicit_non_secret else name
2503
+ arguments.extend(("--env", value))
2504
+ arguments.append(service)
2505
+ _run(environment, *arguments, timeout=900, process_overrides=injected)
2506
+ deadline = time.monotonic() + 60
2507
+ # LiveKit workers commonly stay alive while warming VAD/STT/TTS processes and only register
2508
+ # for dispatch afterwards. Starting a room after five seconds races that registration: the
2509
+ # container looks healthy, but LiveKit has nobody to dispatch to. Fifteen seconds covers the
2510
+ # observed plugin warm-up while remaining configurable for unusually small or large workers.
2511
+ stable_seconds = max(
2512
+ 0.25,
2513
+ float(
2514
+ stable_seconds
2515
+ if stable_seconds is not None
2516
+ else os.environ.get("HARNESS_RUNTIME_STABLE_SECONDS", "15")
2517
+ ),
2518
+ )
2519
+ stable_since: float | None = None
2520
+ while time.monotonic() < deadline:
2521
+ status = _docker(
2522
+ "inspect",
2523
+ "--format",
2524
+ "{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}",
2525
+ container,
2526
+ check=False,
2527
+ timeout=10,
2528
+ ).strip()
2529
+ if status.startswith("running healthy"):
2530
+ break
2531
+ if status.startswith("running"):
2532
+ stable_since = stable_since or time.monotonic()
2533
+ # A worker often has no healthcheck. Remaining alive through startup is the strongest
2534
+ # generic signal available; an immediate import/configuration crash is still caught.
2535
+ # This window must include worker registration, not only process creation.
2536
+ if time.monotonic() - stable_since >= stable_seconds:
2537
+ break
2538
+ else:
2539
+ stable_since = None
2540
+ if status.startswith(("exited", "dead")):
2541
+ logs = _docker(
2542
+ "logs", "--tail", "40", container, check=False, timeout=10
2543
+ )
2544
+ raise ProvisionError(
2545
+ f"submitted runtime {service!r} exited during startup:\n{logs}"
2546
+ )
2547
+ time.sleep(0.25)
2548
+ else:
2549
+ raise ProvisionError(
2550
+ f"submitted runtime {service!r} did not become ready within 60s"
2551
+ )
2552
+ environment.runtime_container = container
2553
+ environment.runtime_trace_volume = trace_volume
2554
+ environment.runtime_trace_path = trace_destination
2555
+ environment.runtime_endpoints = {
2556
+ str(port): _runtime_address(container, port) for port in requested_ports
2557
+ }
2558
+ environment.save(destination)
2559
+ return environment
2560
+
2561
+
2562
+ def _runtime_address(container: str, port: int) -> str:
2563
+ """Return one submitted-runtime port as an address this runner can reach."""
2564
+ if os.environ.get("ALK_RUNNER_CONTAINER", "").strip():
2565
+ # The runner joins the project network before using this address. Container names are
2566
+ # stable, job-scoped and avoid publishing a customer service on the Docker host.
2567
+ return f"{container}:{port}"
2568
+ rendered = _docker("port", container, f"{port}/tcp", timeout=30).strip()
2569
+ first = next((line.strip() for line in rendered.splitlines() if line.strip()), "")
2570
+ if not first:
2571
+ raise ProvisionError(f"runtime port {port} was not published by {container}")
2572
+ # Docker may render 0.0.0.0 or :: even though the requested binding is loopback. Callers
2573
+ # always use local loopback; returning a wildcard host is not a usable endpoint.
2574
+ _, separator, host_port = first.rpartition(":")
2575
+ if not separator or not host_port.isdigit():
2576
+ raise ProvisionError(f"could not parse published runtime address: {first}")
2577
+ return f"127.0.0.1:{host_port}"
2578
+
2579
+
2580
+ def runtime_endpoint(
2581
+ destination: str | Path, port: int, *, scheme: str = "http"
2582
+ ) -> str:
2583
+ """Resolve a declared container port after ``start_runtime`` published it."""
2584
+ environment = ProvisionedEnvironment.load(Path(destination))
2585
+ if environment is None or not environment.runtime_container:
2586
+ raise ProvisionError(f"no running submitted runtime recorded at {destination}")
2587
+ address = environment.runtime_endpoints.get(str(port), "")
2588
+ if not address:
2589
+ raise ProvisionError(f"submitted runtime did not publish container port {port}")
2590
+ return f"{scheme}://{address}"
2591
+
2592
+
2593
+ def runtime_logs(destination: str | Path, *, tail: int = 4000) -> str:
2594
+ """Read the active submitted runtime's bounded stdout/stderr.
2595
+
2596
+ This is an evidence fallback for frameworks whose tools execute entirely inside the
2597
+ customer process and therefore never cross the harness webhook. The caller must normalize
2598
+ framework-specific structured events immediately; raw customer logs are deliberately not
2599
+ persisted as artifacts because they can contain credentials or unrelated PII.
2600
+ """
2601
+ environment = ProvisionedEnvironment.load(Path(destination))
2602
+ if environment is None or not environment.runtime_container:
2603
+ return ""
2604
+ bounded_tail = max(1, min(int(tail), 20_000))
2605
+ return _docker(
2606
+ "logs",
2607
+ "--tail",
2608
+ str(bounded_tail),
2609
+ environment.runtime_container,
2610
+ check=False,
2611
+ timeout=30,
2612
+ )
2613
+
2614
+
2615
+ def _runtime_trace_mount(trace: Path) -> tuple[list[str], str]:
2616
+ """Give a sibling runtime one writable trace target without exposing other jobs.
2617
+
2618
+ Docker resolves bind sources on the daemon host, not inside the runner container. A path
2619
+ such as ``/var/lib/alk-sandbox`` can therefore name a Docker volume in the runner while
2620
+ accidentally naming an unrelated host directory for ``docker run --volume``. Resolve bind
2621
+ mounts to their host source; for named-volume runners, use a one-call exchange volume that is
2622
+ copied back on cleanup so a submitted runtime cannot inspect artifacts from another job.
2623
+ """
2624
+ configured = os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
2625
+ if not configured:
2626
+ return ["--volume", f"{trace.parent}:/run/harness-trace"], ""
2627
+ runner = socket.gethostname() if configured == "self" else configured
2628
+ mounts = json.loads(
2629
+ _docker(
2630
+ "inspect",
2631
+ "--format",
2632
+ "{{json .Mounts}}",
2633
+ runner,
2634
+ timeout=30,
2635
+ )
2636
+ or "[]"
2637
+ )
2638
+ for mount in mounts:
2639
+ destination = Path(str(mount.get("Destination") or ""))
2640
+ if not destination.is_absolute():
2641
+ continue
2642
+ try:
2643
+ relative = trace.parent.relative_to(destination)
2644
+ except ValueError:
2645
+ continue
2646
+ kind = str(mount.get("Type") or "")
2647
+ if kind == "volume" and mount.get("Name"):
2648
+ # ``docker compose run`` does not accept ``--mount`` and ``--volume`` cannot select
2649
+ # a subdirectory of another volume. Use a one-call exchange volume instead: the
2650
+ # runtime writes there, then stop_runtime copies the one file back into the runner's
2651
+ # job volume before deleting the exchange volume.
2652
+ exchange = f"alk-trace-{uuid.uuid4().hex[:12]}"
2653
+ _docker("volume", "create", exchange, timeout=30)
2654
+ _docker(
2655
+ "run",
2656
+ "--rm",
2657
+ "--volume",
2658
+ f"{exchange}:/trace",
2659
+ "--entrypoint",
2660
+ "touch",
2661
+ "docker:27-cli",
2662
+ f"/trace/{trace.name}",
2663
+ timeout=30,
2664
+ )
2665
+ _docker(
2666
+ "run",
2667
+ "--rm",
2668
+ "--volume",
2669
+ f"{exchange}:/trace",
2670
+ "--entrypoint",
2671
+ "chmod",
2672
+ "docker:27-cli",
2673
+ "0666",
2674
+ f"/trace/{trace.name}",
2675
+ timeout=30,
2676
+ )
2677
+ return ["--volume", f"{exchange}:/run/harness-trace"], exchange
2678
+ if kind == "bind" and mount.get("Source"):
2679
+ source = Path(str(mount["Source"])) / relative
2680
+ return ["--volume", f"{source}:/run/harness-trace"], ""
2681
+ # Local Docker execution has no outer runner mount to translate.
2682
+ return ["--volume", f"{trace.parent}:/run/harness-trace"], ""
2683
+
2684
+
2685
+ def _attach_runner_network(
2686
+ environment: ProvisionedEnvironment, *, alias: str = "alk-harness-runner"
2687
+ ) -> str:
2688
+ """Join a containerized runner to an in-memory environment during provisioning."""
2689
+ configured = os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
2690
+ if not configured:
2691
+ return ""
2692
+ runner = socket.gethostname() if configured == "self" else configured
2693
+ service_container = ""
2694
+ if environment.services:
2695
+ service_container = _run(
2696
+ environment,
2697
+ "ps",
2698
+ "--quiet",
2699
+ environment.services[0],
2700
+ timeout=30,
2701
+ ).strip()
2702
+ elif environment.runtime_container:
2703
+ service_container = environment.runtime_container
2704
+ else:
2705
+ # A runtime-only Compose project creates its network when ``compose run`` starts the
2706
+ # worker. Return the stable alias now; the caller invokes this again immediately after
2707
+ # startup to make that alias resolvable from the submitted container.
2708
+ return alias
2709
+ if not service_container:
2710
+ raise ProvisionError("could not identify a container in the source environment")
2711
+ networks = json.loads(
2712
+ _docker(
2713
+ "inspect",
2714
+ "--format",
2715
+ "{{json .NetworkSettings.Networks}}",
2716
+ service_container,
2717
+ timeout=30,
2718
+ )
2719
+ or "{}"
2720
+ )
2721
+ if len(networks) != 1:
2722
+ raise ProvisionError(
2723
+ "expected the source environment on one private network, found "
2724
+ + (", ".join(networks) or "none")
2725
+ )
2726
+ network = next(iter(networks))
2727
+ runner_networks = json.loads(
2728
+ _docker(
2729
+ "inspect",
2730
+ "--format",
2731
+ "{{json .NetworkSettings.Networks}}",
2732
+ runner,
2733
+ timeout=30,
2734
+ )
2735
+ or "{}"
2736
+ )
2737
+ if network not in runner_networks:
2738
+ _docker("network", "connect", "--alias", alias, network, runner, timeout=30)
2739
+ environment.runner_network = network
2740
+ return alias
2741
+
2742
+
2743
+ def _detach_runner_network(environment: ProvisionedEnvironment) -> None:
2744
+ configured = os.environ.get("ALK_RUNNER_CONTAINER", "").strip()
2745
+ if not configured or not environment.runner_network:
2746
+ return
2747
+ runner = socket.gethostname() if configured == "self" else configured
2748
+ _docker(
2749
+ "network",
2750
+ "disconnect",
2751
+ environment.runner_network,
2752
+ runner,
2753
+ check=False,
2754
+ timeout=30,
2755
+ )
2756
+ environment.runner_network = ""
2757
+
2758
+
2759
+ def connect_runner_network(
2760
+ destination: str | Path, *, alias: str = "alk-harness-runner"
2761
+ ) -> str:
2762
+ """Join this runner to the submitted environment's private network.
2763
+
2764
+ A per-call webhook lives in the runner process. The submitted agent runs in
2765
+ the repository's Compose network, so a hosted/containerized runner must
2766
+ join that network explicitly; host loopback and host-published ports are
2767
+ neither private nor reliably reachable from sibling containers.
2768
+ """
2769
+ destination = Path(destination)
2770
+ environment = ProvisionedEnvironment.load(destination)
2771
+ if environment is None or not environment.running:
2772
+ raise ProvisionError(f"no running environment recorded at {destination}")
2773
+ connected = _attach_runner_network(environment, alias=alias)
2774
+ environment.save(destination)
2775
+ return connected
2776
+
2777
+
2778
+ def runtime_environment(destination: str | Path) -> dict[str, str]:
2779
+ """Return the submitted worker's rendered environment without writing it to artifacts."""
2780
+ environment = ProvisionedEnvironment.load(Path(destination))
2781
+ if environment is None or not environment.running:
2782
+ raise ProvisionError(f"no running environment recorded at {destination}")
2783
+ if len(environment.runtime_services) != 1:
2784
+ raise ProvisionError(
2785
+ "expected exactly one opt-in agent runtime service, found "
2786
+ + (", ".join(environment.runtime_services) or "none")
2787
+ )
2788
+ config = _config(environment)
2789
+ values = _environment_values(config["services"][environment.runtime_services[0]])
2790
+ for name in environment.runtime_configuration_names:
2791
+ value = runtime_configuration_value(name).strip()
2792
+ if value:
2793
+ values[name] = value
2794
+ return values
2795
+
2796
+
2797
+ def infer_livekit_agent_name(destination: str | Path) -> str:
2798
+ """Read the worker's configured or source-default LiveKit dispatch name."""
2799
+ configured = runtime_environment(destination).get("LIVEKIT_AGENT_NAME", "").strip()
2800
+ if configured:
2801
+ return configured
2802
+ environment = ProvisionedEnvironment.load(Path(destination))
2803
+ if environment is None:
2804
+ return ""
2805
+ return infer_livekit_agent_name_from_source(
2806
+ Path(environment.source), ignored_parts=_FINGERPRINT_IGNORED
2807
+ )
2808
+
2809
+
2810
+ def activate_voice_environment(
2811
+ destination: str | Path, *, system_prompt: str = ""
2812
+ ) -> dict[str, str]:
2813
+ """Make source-owned voice credentials available to the in-process simulator.
2814
+
2815
+ Compose has already resolved the repository's env files. Values are copied only into this
2816
+ process for the duration of the harness process and are never added to environment.json or
2817
+ run artifacts. Existing platform-wide simulator settings always win.
2818
+ """
2819
+ values = runtime_environment(destination)
2820
+ allowed = {
2821
+ "LIVEKIT_URL",
2822
+ "LIVEKIT_API_KEY",
2823
+ "LIVEKIT_API_SECRET",
2824
+ "DEEPGRAM_API_KEY",
2825
+ "GOOGLE_APPLICATION_CREDENTIALS",
2826
+ "GOOGLE_CLOUD_PROJECT",
2827
+ "GOOGLE_CLOUD_LOCATION",
2828
+ "GEMINI_API_KEY",
2829
+ "GOOGLE_API_KEY",
2830
+ "OPENAI_API_KEY",
2831
+ "CARTESIA_API_KEY",
2832
+ "ELEVEN_API_KEY",
2833
+ "ELEVENLABS_API_KEY",
2834
+ }
2835
+ activated: dict[str, str] = {}
2836
+ for name in allowed:
2837
+ value = values.get(name, "").strip()
2838
+ if value and not os.environ.get(name, "").strip():
2839
+ os.environ[name] = value
2840
+ activated[name] = value
2841
+ agent_name = infer_livekit_agent_name(destination)
2842
+ if agent_name and not os.environ.get("LIVEKIT_TARGET_AGENT_NAME", "").strip():
2843
+ os.environ["LIVEKIT_TARGET_AGENT_NAME"] = agent_name
2844
+ activated["LIVEKIT_TARGET_AGENT_NAME"] = agent_name
2845
+ if system_prompt and not os.environ.get("LIVEKIT_TARGET_SYSTEM_PROMPT", "").strip():
2846
+ os.environ["LIVEKIT_TARGET_SYSTEM_PROMPT"] = system_prompt
2847
+ activated["LIVEKIT_TARGET_SYSTEM_PROMPT"] = system_prompt
2848
+ return activated
2849
+
2850
+
2851
+ def stop_runtime(destination: str | Path) -> bool:
2852
+ """Stop only the submitted worker, leaving its scenario infrastructure running."""
2853
+ destination = Path(destination)
2854
+ environment = ProvisionedEnvironment.load(destination)
2855
+ if environment is None or not environment.runtime_container:
2856
+ return False
2857
+ if environment.runtime_trace_volume and environment.runtime_trace_path:
2858
+ trace = Path(environment.runtime_trace_path)
2859
+ trace.parent.mkdir(parents=True, exist_ok=True)
2860
+ _docker(
2861
+ "cp",
2862
+ f"{environment.runtime_container}:/run/harness-trace/{trace.name}",
2863
+ str(trace),
2864
+ check=False,
2865
+ )
2866
+ _docker("rm", "--force", environment.runtime_container, check=False)
2867
+ if environment.runtime_trace_volume:
2868
+ _docker(
2869
+ "volume", "rm", environment.runtime_trace_volume, check=False, timeout=30
2870
+ )
2871
+ environment.runtime_container = ""
2872
+ environment.runtime_trace_volume = ""
2873
+ environment.runtime_trace_path = ""
2874
+ environment.runtime_endpoints = {}
2875
+ # Keep the runner attached while the scenario is graded against the real datastore. The
2876
+ # worker is gone, but post-call state checks still need the run's private network. ``reset``
2877
+ # and ``stop`` own the eventual network detach together with infrastructure teardown.
2878
+ environment.save(destination)
2879
+ return True
2880
+
2881
+
2882
+ def stop(destination: str | Path) -> bool:
2883
+ """Tear down the exact project recorded for a session, including its test data."""
2884
+ destination = Path(destination)
2885
+ environment = ProvisionedEnvironment.load(destination)
2886
+ if environment is None:
2887
+ return False
2888
+ if environment.runtime_container:
2889
+ stop_runtime(destination)
2890
+ environment = ProvisionedEnvironment.load(destination) or environment
2891
+ _detach_runner_network(environment)
2892
+ _run(environment, "down", "--volumes", "--remove-orphans", check=False, timeout=120)
2893
+ _remove_empty_project_default_network(environment.project)
2894
+ environment.running = False
2895
+ environment.save(destination)
2896
+ return True