agent-hitch 0.2.6 → 0.2.7

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 (386) hide show
  1. package/README.md +14 -1
  2. package/dist/scripts/canary-buildkit-secrets.js +166 -0
  3. package/dist/scripts/canary-buildkit-secrets.js.map +1 -0
  4. package/dist/scripts/canary-collision-domains.js +77 -0
  5. package/dist/scripts/canary-collision-domains.js.map +1 -0
  6. package/dist/scripts/canary-docker-image-observation.js +181 -0
  7. package/dist/scripts/canary-docker-image-observation.js.map +1 -0
  8. package/dist/scripts/canary-gpu-hardware.js +95 -0
  9. package/dist/scripts/canary-gpu-hardware.js.map +1 -0
  10. package/dist/scripts/canary-harbor-load.js +365 -0
  11. package/dist/scripts/canary-harbor-load.js.map +1 -0
  12. package/dist/scripts/canary-packaged-harness.js +75 -0
  13. package/dist/scripts/canary-packaged-harness.js.map +1 -0
  14. package/dist/scripts/canary-resource-load.js +188 -0
  15. package/dist/scripts/canary-resource-load.js.map +1 -0
  16. package/dist/scripts/check-architecture.js +8 -4
  17. package/dist/scripts/check-architecture.js.map +1 -1
  18. package/dist/scripts/docker-reaper-crash-child.js +21 -0
  19. package/dist/scripts/docker-reaper-crash-child.js.map +1 -0
  20. package/dist/src/adapters/catalog.js +1 -0
  21. package/dist/src/adapters/catalog.js.map +1 -1
  22. package/dist/src/adapters/discovery.js +1 -1
  23. package/dist/src/adapters/discovery.js.map +1 -1
  24. package/dist/src/adapters/providers/claude.js +6 -0
  25. package/dist/src/adapters/providers/claude.js.map +1 -1
  26. package/dist/src/adapters/providers/codex.js +6 -0
  27. package/dist/src/adapters/providers/codex.js.map +1 -1
  28. package/dist/src/adapters/providers/deepseek.js +6 -0
  29. package/dist/src/adapters/providers/deepseek.js.map +1 -1
  30. package/dist/src/adapters/providers/opencode.js +6 -0
  31. package/dist/src/adapters/providers/opencode.js.map +1 -1
  32. package/dist/src/adapters/providers/pi.js +6 -0
  33. package/dist/src/adapters/providers/pi.js.map +1 -1
  34. package/dist/src/artifacts/index.js +1 -0
  35. package/dist/src/artifacts/index.js.map +1 -1
  36. package/dist/src/backends/harbor/backend.js +53 -103
  37. package/dist/src/backends/harbor/backend.js.map +1 -1
  38. package/dist/src/backends/harbor/environment-config.js +92 -0
  39. package/dist/src/backends/harbor/environment-config.js.map +1 -0
  40. package/dist/src/backends/harbor/index.js +5 -1
  41. package/dist/src/backends/harbor/index.js.map +1 -1
  42. package/dist/src/backends/harbor/model-proxy-config.js +23 -0
  43. package/dist/src/backends/harbor/model-proxy-config.js.map +1 -0
  44. package/dist/src/backends/harbor/process.js +177 -0
  45. package/dist/src/backends/harbor/process.js.map +1 -0
  46. package/dist/src/backends/harbor/runtime-toolchain.js +30 -0
  47. package/dist/src/backends/harbor/runtime-toolchain.js.map +1 -0
  48. package/dist/src/backends/harbor/supervisor.js +74 -0
  49. package/dist/src/backends/harbor/supervisor.js.map +1 -0
  50. package/dist/src/backends/harbor/task-resources.js +206 -0
  51. package/dist/src/backends/harbor/task-resources.js.map +1 -0
  52. package/dist/src/backends/harbor/tools.js +6 -29
  53. package/dist/src/backends/harbor/tools.js.map +1 -1
  54. package/dist/src/backends/index.js +4 -1
  55. package/dist/src/backends/index.js.map +1 -1
  56. package/dist/src/cli/commands/daemon.js +135 -8
  57. package/dist/src/cli/commands/daemon.js.map +1 -1
  58. package/dist/src/cli/commands/eval.js +214 -4
  59. package/dist/src/cli/commands/eval.js.map +1 -1
  60. package/dist/src/cli/commands/images.js +40 -0
  61. package/dist/src/cli/commands/images.js.map +1 -0
  62. package/dist/src/cli/commands/run.js +6 -3
  63. package/dist/src/cli/commands/run.js.map +1 -1
  64. package/dist/src/cli/commands/runs.js +25 -3
  65. package/dist/src/cli/commands/runs.js.map +1 -1
  66. package/dist/src/cli/commands/worker.js +79 -0
  67. package/dist/src/cli/commands/worker.js.map +1 -0
  68. package/dist/src/cli/main.js +4 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/output.js +71 -3
  71. package/dist/src/cli/output.js.map +1 -1
  72. package/dist/src/control-plane/build-admission.js +69 -0
  73. package/dist/src/control-plane/build-admission.js.map +1 -0
  74. package/dist/src/control-plane/build-records.js +6 -0
  75. package/dist/src/control-plane/build-records.js.map +1 -0
  76. package/dist/src/control-plane/collisions.js +40 -0
  77. package/dist/src/control-plane/collisions.js.map +1 -0
  78. package/dist/src/control-plane/eval-control-state.js +22 -0
  79. package/dist/src/control-plane/eval-control-state.js.map +1 -0
  80. package/dist/src/control-plane/eval-control-work.js +39 -0
  81. package/dist/src/control-plane/eval-control-work.js.map +1 -0
  82. package/dist/src/control-plane/eval-image-resolution.js +52 -0
  83. package/dist/src/control-plane/eval-image-resolution.js.map +1 -0
  84. package/dist/src/control-plane/eval-image-services.js +30 -0
  85. package/dist/src/control-plane/eval-image-services.js.map +1 -0
  86. package/dist/src/control-plane/eval-records.js +252 -0
  87. package/dist/src/control-plane/eval-records.js.map +1 -0
  88. package/dist/src/control-plane/eval-recovery.js +181 -0
  89. package/dist/src/control-plane/eval-recovery.js.map +1 -0
  90. package/dist/src/control-plane/eval-scheduler.js +447 -0
  91. package/dist/src/control-plane/eval-scheduler.js.map +1 -0
  92. package/dist/src/control-plane/index.js +20 -0
  93. package/dist/src/control-plane/index.js.map +1 -0
  94. package/dist/src/control-plane/local-worker.js +49 -0
  95. package/dist/src/control-plane/local-worker.js.map +1 -0
  96. package/dist/src/control-plane/model-capture-planning.js +12 -0
  97. package/dist/src/control-plane/model-capture-planning.js.map +1 -0
  98. package/dist/src/control-plane/remote-result-transport.js +172 -0
  99. package/dist/src/control-plane/remote-result-transport.js.map +1 -0
  100. package/dist/src/control-plane/remote-work-coordinator.js +332 -0
  101. package/dist/src/control-plane/remote-work-coordinator.js.map +1 -0
  102. package/dist/src/control-plane/remote-work-inputs.js +266 -0
  103. package/dist/src/control-plane/remote-work-inputs.js.map +1 -0
  104. package/dist/src/control-plane/remote-work-recovery.js +181 -0
  105. package/dist/src/control-plane/remote-work-recovery.js.map +1 -0
  106. package/dist/src/control-plane/remote-worker-artifacts.js +135 -0
  107. package/dist/src/control-plane/remote-worker-artifacts.js.map +1 -0
  108. package/dist/src/control-plane/remote-worker-client.js +202 -0
  109. package/dist/src/control-plane/remote-worker-client.js.map +1 -0
  110. package/dist/src/control-plane/remote-worker-configuration.js +34 -0
  111. package/dist/src/control-plane/remote-worker-configuration.js.map +1 -0
  112. package/dist/src/control-plane/remote-worker-credentials.js +58 -0
  113. package/dist/src/control-plane/remote-worker-credentials.js.map +1 -0
  114. package/dist/src/control-plane/remote-worker-protocol.js +510 -0
  115. package/dist/src/control-plane/remote-worker-protocol.js.map +1 -0
  116. package/dist/src/control-plane/remote-worker-runner.js +214 -0
  117. package/dist/src/control-plane/remote-worker-runner.js.map +1 -0
  118. package/dist/src/control-plane/remote-workers.js +377 -0
  119. package/dist/src/control-plane/remote-workers.js.map +1 -0
  120. package/dist/src/control-plane/rerun-scheduler.js +411 -0
  121. package/dist/src/control-plane/rerun-scheduler.js.map +1 -0
  122. package/dist/src/control-plane/rerun-submission.js +56 -0
  123. package/dist/src/control-plane/rerun-submission.js.map +1 -0
  124. package/dist/src/control-plane/resources.js +182 -0
  125. package/dist/src/control-plane/resources.js.map +1 -0
  126. package/dist/src/control-plane/scheduler-eval-entry.js +32 -0
  127. package/dist/src/control-plane/scheduler-eval-entry.js.map +1 -0
  128. package/dist/src/control-plane/synthetic-result.js +21 -0
  129. package/dist/src/control-plane/synthetic-result.js.map +1 -0
  130. package/dist/src/control-plane/work-admission.js +21 -0
  131. package/dist/src/control-plane/work-admission.js.map +1 -0
  132. package/dist/src/control-plane/work-dispatcher.js +229 -0
  133. package/dist/src/control-plane/work-dispatcher.js.map +1 -0
  134. package/dist/src/controller-runtime/hash.js +12 -1
  135. package/dist/src/controller-runtime/hash.js.map +1 -1
  136. package/dist/src/controller-runtime/index.js +1 -1
  137. package/dist/src/controller-runtime/index.js.map +1 -1
  138. package/dist/src/controller-runtime/store.js +15 -6
  139. package/dist/src/controller-runtime/store.js.map +1 -1
  140. package/dist/src/daemon/client.js.map +1 -1
  141. package/dist/src/daemon/event-stream.js +46 -0
  142. package/dist/src/daemon/event-stream.js.map +1 -0
  143. package/dist/src/daemon/health.js +66 -0
  144. package/dist/src/daemon/health.js.map +1 -0
  145. package/dist/src/daemon/launcher.js +28 -1
  146. package/dist/src/daemon/launcher.js.map +1 -1
  147. package/dist/src/daemon/logging.js +23 -0
  148. package/dist/src/daemon/logging.js.map +1 -0
  149. package/dist/src/daemon/scheduler.js +26 -5
  150. package/dist/src/daemon/scheduler.js.map +1 -1
  151. package/dist/src/daemon/server.js +255 -98
  152. package/dist/src/daemon/server.js.map +1 -1
  153. package/dist/src/daemon/telemetry.js +163 -0
  154. package/dist/src/daemon/telemetry.js.map +1 -0
  155. package/dist/src/daemon/worker-routes.js +219 -0
  156. package/dist/src/daemon/worker-routes.js.map +1 -0
  157. package/dist/src/domain/bundles.js +2 -0
  158. package/dist/src/domain/bundles.js.map +1 -0
  159. package/dist/src/domain/control-plane.js +2 -0
  160. package/dist/src/domain/control-plane.js.map +1 -0
  161. package/dist/src/domain/execution-evidence.js +2 -0
  162. package/dist/src/domain/execution-evidence.js.map +1 -0
  163. package/dist/src/domain/execution-plan.js +2 -0
  164. package/dist/src/domain/execution-plan.js.map +1 -0
  165. package/dist/src/domain/images.js +2 -0
  166. package/dist/src/domain/images.js.map +1 -0
  167. package/dist/src/domain/index.js +9 -0
  168. package/dist/src/domain/index.js.map +1 -1
  169. package/dist/src/domain/interactions.js +2 -0
  170. package/dist/src/domain/interactions.js.map +1 -0
  171. package/dist/src/domain/providers.js +2 -0
  172. package/dist/src/domain/providers.js.map +1 -0
  173. package/dist/src/domain/resources.js +2 -0
  174. package/dist/src/domain/resources.js.map +1 -0
  175. package/dist/src/domain/workers.js +2 -0
  176. package/dist/src/domain/workers.js.map +1 -0
  177. package/dist/src/evals/collect-only-rerun.js +182 -0
  178. package/dist/src/evals/collect-only-rerun.js.map +1 -0
  179. package/dist/src/evals/directory.js +30 -0
  180. package/dist/src/evals/directory.js.map +1 -0
  181. package/dist/src/evals/docker-engine-stats.js +89 -0
  182. package/dist/src/evals/docker-engine-stats.js.map +1 -0
  183. package/dist/src/evals/docker-ownership.js +51 -0
  184. package/dist/src/evals/docker-ownership.js.map +1 -0
  185. package/dist/src/evals/docker-reaper.js +144 -0
  186. package/dist/src/evals/docker-reaper.js.map +1 -0
  187. package/dist/src/evals/docker-resource-observer.js +207 -0
  188. package/dist/src/evals/docker-resource-observer.js.map +1 -0
  189. package/dist/src/evals/environment-image-planning.js +164 -0
  190. package/dist/src/evals/environment-image-planning.js.map +1 -0
  191. package/dist/src/evals/eval-artifact-planning.js +56 -0
  192. package/dist/src/evals/eval-artifact-planning.js.map +1 -0
  193. package/dist/src/evals/eval-finalization.js +10 -0
  194. package/dist/src/evals/eval-finalization.js.map +1 -0
  195. package/dist/src/evals/eval-lifecycle-events.js +16 -0
  196. package/dist/src/evals/eval-lifecycle-events.js.map +1 -0
  197. package/dist/src/evals/events.js +53 -5
  198. package/dist/src/evals/events.js.map +1 -1
  199. package/dist/src/evals/execution-evidence.js +116 -0
  200. package/dist/src/evals/execution-evidence.js.map +1 -0
  201. package/dist/src/evals/execution-leases.js +342 -0
  202. package/dist/src/evals/execution-leases.js.map +1 -0
  203. package/dist/src/evals/execution-plan-artifacts.js +72 -0
  204. package/dist/src/evals/execution-plan-artifacts.js.map +1 -0
  205. package/dist/src/evals/execution-plan-images.js +69 -0
  206. package/dist/src/evals/execution-plan-images.js.map +1 -0
  207. package/dist/src/evals/execution-plan-resources.js +174 -0
  208. package/dist/src/evals/execution-plan-resources.js.map +1 -0
  209. package/dist/src/evals/execution-plan.js +415 -0
  210. package/dist/src/evals/execution-plan.js.map +1 -0
  211. package/dist/src/evals/harbor-artifact-builder-image.js +99 -0
  212. package/dist/src/evals/harbor-artifact-builder-image.js.map +1 -0
  213. package/dist/src/evals/harbor-artifact-builder.js +354 -0
  214. package/dist/src/evals/harbor-artifact-builder.js.map +1 -0
  215. package/dist/src/evals/harbor-bridge-error.js +18 -3
  216. package/dist/src/evals/harbor-bridge-error.js.map +1 -1
  217. package/dist/src/evals/harbor-phase-timings.js +60 -0
  218. package/dist/src/evals/harbor-phase-timings.js.map +1 -0
  219. package/dist/src/evals/index.js +25 -2
  220. package/dist/src/evals/index.js.map +1 -1
  221. package/dist/src/evals/infrastructure-retry.js +100 -63
  222. package/dist/src/evals/infrastructure-retry.js.map +1 -1
  223. package/dist/src/evals/interaction-capture-import.js +49 -0
  224. package/dist/src/evals/interaction-capture-import.js.map +1 -0
  225. package/dist/src/evals/local-docker-provider.js +372 -0
  226. package/dist/src/evals/local-docker-provider.js.map +1 -0
  227. package/dist/src/evals/local-eval-planning.js +80 -0
  228. package/dist/src/evals/local-eval-planning.js.map +1 -0
  229. package/dist/src/evals/model-capture-plan.js +22 -0
  230. package/dist/src/evals/model-capture-plan.js.map +1 -0
  231. package/dist/src/evals/model-capture-runtime.js +114 -0
  232. package/dist/src/evals/model-capture-runtime.js.map +1 -0
  233. package/dist/src/evals/model-proxy-runtime-state.js +53 -0
  234. package/dist/src/evals/model-proxy-runtime-state.js.map +1 -0
  235. package/dist/src/evals/planned-execution-support.js +54 -0
  236. package/dist/src/evals/planned-execution-support.js.map +1 -0
  237. package/dist/src/evals/planned-execution.js +461 -0
  238. package/dist/src/evals/planned-execution.js.map +1 -0
  239. package/dist/src/evals/planned-remote-execution.js +40 -0
  240. package/dist/src/evals/planned-remote-execution.js.map +1 -0
  241. package/dist/src/evals/planned-retry-lifecycle.js +186 -0
  242. package/dist/src/evals/planned-retry-lifecycle.js.map +1 -0
  243. package/dist/src/evals/prepared-harness.js +76 -0
  244. package/dist/src/evals/prepared-harness.js.map +1 -0
  245. package/dist/src/evals/progress.js +4 -4
  246. package/dist/src/evals/progress.js.map +1 -1
  247. package/dist/src/evals/records.js +20 -3
  248. package/dist/src/evals/records.js.map +1 -1
  249. package/dist/src/evals/recovery.js +192 -0
  250. package/dist/src/evals/recovery.js.map +1 -0
  251. package/dist/src/evals/remote-infrastructure-retry.js +99 -0
  252. package/dist/src/evals/remote-infrastructure-retry.js.map +1 -0
  253. package/dist/src/evals/rerun-inputs.js +89 -0
  254. package/dist/src/evals/rerun-inputs.js.map +1 -0
  255. package/dist/src/evals/rerun-slots.js +4 -3
  256. package/dist/src/evals/rerun-slots.js.map +1 -1
  257. package/dist/src/evals/rerun-types.js +49 -0
  258. package/dist/src/evals/rerun-types.js.map +1 -0
  259. package/dist/src/evals/rerun.js +113 -75
  260. package/dist/src/evals/rerun.js.map +1 -1
  261. package/dist/src/evals/result-helpers.js +65 -0
  262. package/dist/src/evals/result-helpers.js.map +1 -0
  263. package/dist/src/evals/resume-state.js +36 -0
  264. package/dist/src/evals/resume-state.js.map +1 -0
  265. package/dist/src/evals/service-types.js +2 -0
  266. package/dist/src/evals/service-types.js.map +1 -0
  267. package/dist/src/evals/service.js +292 -257
  268. package/dist/src/evals/service.js.map +1 -1
  269. package/dist/src/evals/task-resources.js +209 -0
  270. package/dist/src/evals/task-resources.js.map +1 -0
  271. package/dist/src/evals/trial-environment-evidence.js +87 -0
  272. package/dist/src/evals/trial-environment-evidence.js.map +1 -0
  273. package/dist/src/evals/trial-execution-evidence.js +13 -0
  274. package/dist/src/evals/trial-execution-evidence.js.map +1 -0
  275. package/dist/src/evals/trial-import-identity.js +31 -0
  276. package/dist/src/evals/trial-import-identity.js.map +1 -0
  277. package/dist/src/evals/trial-import.js +143 -145
  278. package/dist/src/evals/trial-import.js.map +1 -1
  279. package/dist/src/evals/trial-publication-recovery.js +104 -0
  280. package/dist/src/evals/trial-publication-recovery.js.map +1 -0
  281. package/dist/src/evals/trial-publication.js +25 -0
  282. package/dist/src/evals/trial-publication.js.map +1 -0
  283. package/dist/src/evals/work-item-artifacts.js +23 -0
  284. package/dist/src/evals/work-item-artifacts.js.map +1 -0
  285. package/dist/src/foundation/config.js +17 -0
  286. package/dist/src/foundation/config.js.map +1 -1
  287. package/dist/src/foundation/credential-redaction.js +137 -0
  288. package/dist/src/foundation/credential-redaction.js.map +1 -0
  289. package/dist/src/foundation/environment-image-references.js +59 -0
  290. package/dist/src/foundation/environment-image-references.js.map +1 -0
  291. package/dist/src/foundation/executable.js +45 -5
  292. package/dist/src/foundation/executable.js.map +1 -1
  293. package/dist/src/foundation/index.js +5 -2
  294. package/dist/src/foundation/index.js.map +1 -1
  295. package/dist/src/foundation/process-identity.js +69 -0
  296. package/dist/src/foundation/process-identity.js.map +1 -0
  297. package/dist/src/images/context.js +47 -0
  298. package/dist/src/images/context.js.map +1 -0
  299. package/dist/src/images/docker-buildkit.js +137 -0
  300. package/dist/src/images/docker-buildkit.js.map +1 -0
  301. package/dist/src/images/dockerfile.js +67 -0
  302. package/dist/src/images/dockerfile.js.map +1 -0
  303. package/dist/src/images/gc.js +261 -0
  304. package/dist/src/images/gc.js.map +1 -0
  305. package/dist/src/images/index.js +10 -0
  306. package/dist/src/images/index.js.map +1 -0
  307. package/dist/src/images/manifest.js +109 -0
  308. package/dist/src/images/manifest.js.map +1 -0
  309. package/dist/src/images/ownership.js +5 -0
  310. package/dist/src/images/ownership.js.map +1 -0
  311. package/dist/src/images/records.js +32 -0
  312. package/dist/src/images/records.js.map +1 -0
  313. package/dist/src/images/registry.js +154 -0
  314. package/dist/src/images/registry.js.map +1 -0
  315. package/dist/src/images/service.js +224 -0
  316. package/dist/src/images/service.js.map +1 -0
  317. package/dist/src/model-access/capture.js +255 -0
  318. package/dist/src/model-access/capture.js.map +1 -0
  319. package/dist/src/model-access/index.js +5 -0
  320. package/dist/src/model-access/index.js.map +1 -0
  321. package/dist/src/model-access/policy.js +49 -0
  322. package/dist/src/model-access/policy.js.map +1 -0
  323. package/dist/src/model-access/proxy.js +336 -0
  324. package/dist/src/model-access/proxy.js.map +1 -0
  325. package/dist/src/model-access/records.js +167 -0
  326. package/dist/src/model-access/records.js.map +1 -0
  327. package/dist/src/runs/bundle.js +450 -0
  328. package/dist/src/runs/bundle.js.map +1 -0
  329. package/dist/src/runs/events.js +12 -1
  330. package/dist/src/runs/events.js.map +1 -1
  331. package/dist/src/runs/executor.js +24 -17
  332. package/dist/src/runs/executor.js.map +1 -1
  333. package/dist/src/runs/index.js +3 -1
  334. package/dist/src/runs/index.js.map +1 -1
  335. package/dist/src/runs/manifest.js +5 -5
  336. package/dist/src/runs/manifest.js.map +1 -1
  337. package/dist/src/runs/request.js +6 -1
  338. package/dist/src/runs/request.js.map +1 -1
  339. package/dist/src/runs/training-candidate.js +161 -0
  340. package/dist/src/runs/training-candidate.js.map +1 -0
  341. package/dist/src/trajectories/provider-capture.js +15 -24
  342. package/dist/src/trajectories/provider-capture.js.map +1 -1
  343. package/dist/src/trajectories/providers/deepseek.js +3 -3
  344. package/dist/src/trajectories/providers/deepseek.js.map +1 -1
  345. package/dist/src/workers/index.js +3 -0
  346. package/dist/src/workers/index.js.map +1 -0
  347. package/dist/src/workers/remote-harbor-work-spec.js +97 -0
  348. package/dist/src/workers/remote-harbor-work-spec.js.map +1 -0
  349. package/dist/src/workers/remote-harbor-worker.js +220 -0
  350. package/dist/src/workers/remote-harbor-worker.js.map +1 -0
  351. package/docs/schemas/environment-build-index.schema.json +13 -0
  352. package/docs/schemas/environment-build-record.schema.json +25 -0
  353. package/docs/schemas/environment-image-manifest.schema.json +62 -0
  354. package/docs/schemas/eval-control.schema.json +36 -0
  355. package/docs/schemas/eval-execution-plan.schema.json +216 -0
  356. package/docs/schemas/eval-rerun-result.schema.json +53 -0
  357. package/docs/schemas/eval-rerun-state.schema.json +61 -0
  358. package/docs/schemas/eval-rerun-submission.schema.json +58 -0
  359. package/docs/schemas/eval-result.schema.json +34 -1
  360. package/docs/schemas/eval-submission-input.schema.json +14 -0
  361. package/docs/schemas/eval-submission.schema.json +68 -0
  362. package/docs/schemas/eval-trial-publication.schema.json +39 -0
  363. package/docs/schemas/execution-evidence.schema.json +96 -0
  364. package/docs/schemas/execution-lease.schema.json +50 -0
  365. package/docs/schemas/execution-provider-status.schema.json +65 -0
  366. package/docs/schemas/execution-worker.schema.json +53 -0
  367. package/docs/schemas/harbor-process-exit.schema.json +14 -0
  368. package/docs/schemas/interaction-capture-ref.schema.json +45 -0
  369. package/docs/schemas/local-provider-execution.schema.json +33 -0
  370. package/docs/schemas/model-interaction.schema.json +41 -0
  371. package/docs/schemas/remote-result-envelope.schema.json +32 -0
  372. package/docs/schemas/remote-tree-envelope.schema.json +25 -0
  373. package/docs/schemas/remote-work-offer.schema.json +58 -0
  374. package/docs/schemas/remote-work-receipt.schema.json +50 -0
  375. package/docs/schemas/remote-worker-artifact.schema.json +17 -0
  376. package/docs/schemas/remote-worker-event.schema.json +18 -0
  377. package/docs/schemas/remote-worker-heartbeat.schema.json +37 -0
  378. package/docs/schemas/remote-worker-registration.schema.json +52 -0
  379. package/docs/schemas/result-bundle-index.schema.json +133 -0
  380. package/docs/schemas/run-environment-images.schema.json +39 -0
  381. package/docs/schemas/training-data-candidate.schema.json +46 -0
  382. package/integrations/harbor/hitch_harbor_agent.py +171 -539
  383. package/integrations/harbor/hitch_harbor_environment.py +331 -0
  384. package/integrations/harbor/hitch_harbor_task_resources.py +275 -0
  385. package/integrations/harbor/hitch_harbor_verifier.py +23 -0
  386. package/package.json +14 -1
@@ -2,17 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
- import errno
7
5
  import hashlib
8
6
  import json
9
- import os
10
7
  import re
11
8
  import shlex
12
- import shutil
13
9
  import stat as stat_module
14
10
  import tempfile
11
+ import time
15
12
  import uuid
13
+ from urllib.parse import urlparse
16
14
  from datetime import datetime, timezone
17
15
  from pathlib import Path, PurePosixPath
18
16
  from typing import Any
@@ -22,12 +20,7 @@ from harbor.environments.base import BaseEnvironment, ExecResult
22
20
  from harbor.models.agent.context import AgentContext
23
21
 
24
22
  CONTROLLER_RUNTIME_MANIFEST_VERSION = "2"
25
- LOCAL_GIT_TRANSPORT_MANIFEST_VERSION = "1"
26
- HARNESS_ARTIFACT_MANIFEST_VERSION = "1"
27
- LOCAL_GIT_TRANSPORT_MAX_BYTES = 512 * 1024 * 1024
28
- LOCAL_GIT_REMOTE_ROOT = "/opt/hitch-local-source"
29
23
  HARNESS_ARTIFACT_REMOTE_ROOT = "/opt/hitch-harness-artifact"
30
- HITCH_CONTAINER_STATE_ROOT = "/tmp/hitch-state"
31
24
  HITCH_BRIDGE_ERROR_LOG = "/logs/agent/hitch-bridge-error.json"
32
25
  HITCH_DIAGNOSTIC_MAX_BYTES = 8 * 1024
33
26
  HITCH_BRIDGE_ERROR_MAX_BYTES = 64 * 1024
@@ -56,16 +49,22 @@ class HitchHarborAgent(BaseAgent):
56
49
  candidate_id: str = "candidate-1",
57
50
  controller_runtime_id: str | None = None,
58
51
  harness_artifact: dict[str, Any] | None = None,
52
+ node_version: str = "v22.23.0",
53
+ # Legacy parameters are accepted only so old persisted jobs fail at
54
+ # the explicit artifact handoff boundary instead of leaking unknown
55
+ # kwargs into Harbor's BaseAgent. New jobs pass neither value.
59
56
  harness_artifact_cache_dir: str | None = None,
60
57
  local_source_transport: dict[str, Any] | None = None,
61
58
  hitch_timeout_ms: int = 900_000,
62
59
  agent_args: list[str] | None = None,
60
+ credential_names: list[str] | None = None,
63
61
  workdir: str | None = None,
64
62
  eval_id: str | None = None,
65
63
  benchmark_id: str | None = None,
66
64
  benchmark_revision: str | None = None,
67
65
  verifier_identity: str | None = None,
68
66
  logical_attempt: int | None = None,
67
+ model_capture: dict[str, Any] | None = None,
69
68
  **kwargs: Any,
70
69
  ) -> None:
71
70
  super().__init__(logs_dir=logs_dir, **kwargs)
@@ -74,11 +73,23 @@ class HitchHarborAgent(BaseAgent):
74
73
  self.hitch_runtime_dir = Path(hitch_runtime_dir)
75
74
  self.controller_runtime_id = controller_runtime_id
76
75
  self.harness_artifact = dict(harness_artifact) if harness_artifact else None
77
- self.harness_artifact_cache_dir = Path(harness_artifact_cache_dir) if harness_artifact_cache_dir else None
78
- self.local_source_transport = dict(local_source_transport) if local_source_transport else None
76
+ if harness_artifact_cache_dir is not None:
77
+ raise ValueError("trial-side harness artifact caches are no longer supported")
78
+ if not isinstance(node_version, str) or re.fullmatch(r"v\d+\.\d+\.\d+", node_version) is None:
79
+ raise ValueError("node_version must be an exact stable Node.js version")
80
+ self.required_node_version = node_version
81
+ if local_source_transport is not None:
82
+ raise ValueError("trial-side local source transports are no longer supported")
79
83
  self.candidate_id = candidate_id
80
84
  self.hitch_timeout_ms = int(hitch_timeout_ms)
81
85
  self.agent_args = list(agent_args or [])
86
+ raw_credential_names = list(credential_names or [])
87
+ if (
88
+ any(not isinstance(name, str) or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None for name in raw_credential_names)
89
+ or len(set(raw_credential_names)) != len(raw_credential_names)
90
+ ):
91
+ raise ValueError("credential_names must contain unique environment variable names")
92
+ self.credential_names = sorted(raw_credential_names)
82
93
  if workdir is not None and (not isinstance(workdir, str) or not workdir.strip()):
83
94
  raise ValueError("workdir must be a non-empty string when provided")
84
95
  self.workdir = workdir.strip() if isinstance(workdir, str) else None
@@ -90,13 +101,13 @@ class HitchHarborAgent(BaseAgent):
90
101
  if logical_attempt is not None and (isinstance(logical_attempt, bool) or not isinstance(logical_attempt, int) or logical_attempt < 1):
91
102
  raise ValueError("logical_attempt must be a positive integer")
92
103
  self.logical_attempt = logical_attempt
104
+ self.model_capture = _validate_model_capture(model_capture)
93
105
  self._hitch_version: str | None = None
94
106
  self._entrypoint: str | None = None
95
107
  self._artifact_manifest: dict[str, Any] | None = None
96
108
  self._artifact_host_directory: Path | None = None
97
109
  self._artifact_uploaded = False
98
110
  self._artifact_transport_status: str | None = None
99
- self._local_manifest: dict[str, Any] | None = None
100
111
 
101
112
  @staticmethod
102
113
  def name() -> str:
@@ -106,6 +117,13 @@ class HitchHarborAgent(BaseAgent):
106
117
  return self._hitch_version
107
118
 
108
119
  async def setup(self, environment: BaseEnvironment) -> None:
120
+ started_ns = time.monotonic_ns()
121
+ try:
122
+ await self._setup(environment)
123
+ finally:
124
+ self._write_phase_timing("setup", started_ns)
125
+
126
+ async def _setup(self, environment: BaseEnvironment) -> None:
109
127
  if not self.hitch_runtime_dir.is_dir():
110
128
  raise RuntimeError(f"Hitch runtime directory does not exist: {self.hitch_runtime_dir}")
111
129
  # The manifest declares the CLI entrypoint (relative to the upload
@@ -129,22 +147,13 @@ class HitchHarborAgent(BaseAgent):
129
147
  # and the actual container upload, spec §4.6).
130
148
  self._verify_manifest_identity(manifest)
131
149
  self._verify_payload(manifest)
132
- if self.harness_artifact is not None:
133
- try:
134
- self._artifact_manifest = self._verify_harness_artifact_host()
135
- self._artifact_host_directory = Path(str(self.harness_artifact["directory"]))
136
- except Exception as error:
137
- raise RuntimeError(f"hitch-artifact-materialize: {error}") from error
138
- if self.harness_artifact_cache_dir is not None:
139
- try:
140
- self._verify_harness_artifact_cache_host()
141
- except Exception as error:
142
- raise RuntimeError(f"hitch-artifact-cache: {error}") from error
143
- if self.local_source_transport is not None:
144
- try:
145
- self._local_manifest = self._verify_local_source_host()
146
- except Exception as error:
147
- raise RuntimeError(f"hitch-local-source-materialize: {error}") from error
150
+ if self.harness_artifact is None:
151
+ raise RuntimeError("hitch-artifact-materialize: Harbor requires a dedicated-builder artifact")
152
+ try:
153
+ self._artifact_manifest = self._verify_harness_artifact_host()
154
+ self._artifact_host_directory = Path(str(self.harness_artifact["directory"]))
155
+ except Exception as error:
156
+ raise RuntimeError(f"hitch-artifact-materialize: {error}") from error
148
157
  payload_dir = self.hitch_runtime_dir / "payload"
149
158
  if not payload_dir.is_dir():
150
159
  raise RuntimeError(f"Hitch runtime bundle has no payload directory: {self.hitch_runtime_dir}")
@@ -156,46 +165,18 @@ class HitchHarborAgent(BaseAgent):
156
165
  await self._ensure_node(environment)
157
166
  platform = await self._container_platform(environment)
158
167
  node_version = await self._container_node_version(environment)
159
- cache_lock = None
160
- try:
161
- if self._artifact_manifest is not None and self._artifact_compatible(
162
- self._artifact_manifest, platform, node_version
163
- ):
164
- await self._upload_harness_artifact(environment, self._artifact_host_directory)
165
- self._artifact_transport_status = "uploaded"
166
- elif self.harness_artifact_cache_dir is not None:
167
- cache_lock = await self._acquire_artifact_cache_lock(platform, node_version)
168
- cached = self._find_cached_harness_artifact(platform, node_version)
169
- if cached is not None:
170
- self._artifact_manifest, self._artifact_host_directory = cached
171
- await self._release_artifact_cache_lock(cache_lock)
172
- cache_lock = None
173
- await self._upload_harness_artifact(environment, self._artifact_host_directory)
174
- self._artifact_transport_status = "host_cache_hit"
175
-
176
- if self._local_manifest is not None and not self._artifact_uploaded:
177
- try:
178
- await self._upload_and_materialize_local_source(environment, self._local_manifest)
179
- except Exception as error:
180
- raise RuntimeError(f"hitch-local-source-materialize: {error}") from error
181
- entry = self._remote_entry(entrypoint)
182
- version = await self._exec(environment, f"{self._node_prefix()} node {entry} --version")
183
- self._hitch_version = (version.stdout or "").strip() or None
184
- if not self._artifact_uploaded:
185
- prepared = await self._prepare_harness_in_container(environment, entry)
186
- if cache_lock is not None:
187
- try:
188
- self._artifact_manifest, self._artifact_host_directory = await self._cache_container_artifact(
189
- environment, prepared, platform, node_version
190
- )
191
- self._artifact_transport_status = "host_cache_populated"
192
- except Exception:
193
- self._artifact_transport_status = "host_cache_write_failed"
194
- else:
195
- self._artifact_transport_status = "container_prepare"
196
- finally:
197
- if cache_lock is not None:
198
- await self._release_artifact_cache_lock(cache_lock)
168
+ if self._artifact_manifest is None or not self._artifact_compatible(
169
+ self._artifact_manifest, platform, node_version
170
+ ):
171
+ raise RuntimeError(
172
+ "hitch-artifact-platform: dedicated-builder artifact is incompatible with "
173
+ f"trial runtime {platform}/{node_version}"
174
+ )
175
+ await self._upload_harness_artifact(environment, self._artifact_host_directory)
176
+ self._artifact_transport_status = "dedicated_builder_upload"
177
+ entry = self._remote_entry(entrypoint)
178
+ version = await self._exec(environment, f"{self._node_prefix()} node {entry} --version")
179
+ self._hitch_version = (version.stdout or "").strip() or None
199
180
 
200
181
  async def _resolve_workdir(self, environment: BaseEnvironment) -> str:
201
182
  """Resolve Harbor's effective task directory and prove it is usable.
@@ -302,6 +283,26 @@ class HitchHarborAgent(BaseAgent):
302
283
  raise RuntimeError("Hitch agent setup() must resolve the Harbor task working directory before run()")
303
284
  return self.workdir
304
285
 
286
+ def _write_phase_timing(self, phase: str, started_ns: int) -> None:
287
+ if phase not in {"setup", "agent"}:
288
+ raise ValueError("invalid Harbor agent phase timing")
289
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
290
+ target = self.logs_dir / "hitch-phase-timings.json"
291
+ value: dict[str, Any] = {"schema_version": "1", "phases": {}}
292
+ try:
293
+ existing = json.loads(target.read_text(encoding="utf-8")) if target.is_file() else None
294
+ if isinstance(existing, dict) and existing.get("schema_version") == "1" and isinstance(existing.get("phases"), dict):
295
+ value = existing
296
+ except (OSError, json.JSONDecodeError):
297
+ pass
298
+ value["phases"][phase] = {
299
+ "duration_ms": max(0, (time.monotonic_ns() - started_ns) // 1_000_000),
300
+ "completed_at": datetime.now(timezone.utc).isoformat(),
301
+ }
302
+ temporary = target.with_suffix(".json.tmp")
303
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
304
+ temporary.replace(target)
305
+
305
306
  def _verify_harness_artifact_host(self) -> dict[str, Any]:
306
307
  """Pin host artifact metadata before Harbor copies the directory."""
307
308
  transport = self.harness_artifact or {}
@@ -356,6 +357,18 @@ class HitchHarborAgent(BaseAgent):
356
357
  raise RuntimeError("prepared artifact Node.js version is invalid")
357
358
  return manifest
358
359
 
360
+ @staticmethod
361
+ def _assert_regular_host_file(path: Path, label: str, maximum: int) -> Any:
362
+ try:
363
+ info = path.lstat()
364
+ except OSError as error:
365
+ raise RuntimeError(f"{label} is missing or unreadable") from error
366
+ if not stat_module.S_ISREG(info.st_mode):
367
+ raise RuntimeError(f"{label} must be a regular file")
368
+ if info.st_size > maximum:
369
+ raise RuntimeError(f"{label} exceeds the size limit ({maximum} bytes)")
370
+ return info
371
+
359
372
  async def _container_platform(self, environment: BaseEnvironment) -> str:
360
373
  result = await self._exec(
361
374
  environment,
@@ -390,448 +403,6 @@ class HitchHarborAgent(BaseAgent):
390
403
  await environment.upload_dir(directory, HARNESS_ARTIFACT_REMOTE_ROOT)
391
404
  self._artifact_uploaded = True
392
405
 
393
- async def _prepare_harness_in_container(
394
- self,
395
- environment: BaseEnvironment,
396
- entry: str,
397
- ) -> ExecResult:
398
- prepare = " ".join(
399
- [
400
- self._node_prefix(),
401
- f"HITCH_ROOT={HITCH_CONTAINER_STATE_ROOT}",
402
- *(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None else []),
403
- f"node {entry} prepare",
404
- shlex.quote(self.harness_ref),
405
- *self._local_source_cli_args(),
406
- "--json",
407
- ]
408
- )
409
- return await self._exec(environment, prepare)
410
-
411
- def _verify_harness_artifact_cache_host(self) -> None:
412
- cache = self.harness_artifact_cache_dir
413
- if cache is None or not cache.is_absolute():
414
- raise RuntimeError("prepared artifact cache path must be absolute")
415
- try:
416
- info = cache.lstat()
417
- except OSError as error:
418
- raise RuntimeError("prepared artifact cache is missing or unreadable") from error
419
- if not stat_module.S_ISDIR(info.st_mode) or cache.is_symlink():
420
- raise RuntimeError("prepared artifact cache must be a regular directory")
421
- for name in ("artifacts", "locks", "tmp", "invalid"):
422
- child = cache / name
423
- child.mkdir(mode=0o700, exist_ok=True)
424
- child_info = child.lstat()
425
- if not stat_module.S_ISDIR(child_info.st_mode) or child.is_symlink():
426
- raise RuntimeError(f"prepared artifact cache {name} path must be a regular directory")
427
-
428
- def _artifact_cache_key(self, platform: str, node_version: str) -> str:
429
- payload = json.dumps(
430
- [
431
- self.revision_identity,
432
- self.harness_artifact.get("adapter_version") if self.harness_artifact else None,
433
- self.harness_artifact.get("recipe_version") if self.harness_artifact else None,
434
- platform,
435
- node_version,
436
- ],
437
- separators=(",", ":"),
438
- ).encode("utf-8")
439
- return hashlib.sha256(payload).hexdigest()
440
-
441
- async def _acquire_artifact_cache_lock(self, platform: str, node_version: str) -> Any:
442
- cache = self.harness_artifact_cache_dir
443
- if cache is None:
444
- raise RuntimeError("prepared artifact cache is unavailable")
445
- handle = (cache / "locks" / f"{self._artifact_cache_key(platform, node_version)}.lock").open("a+b")
446
- try:
447
- while True:
448
- try:
449
- self._try_lock_file(handle)
450
- return handle
451
- except OSError as error:
452
- if error.errno not in (errno.EACCES, errno.EAGAIN):
453
- raise
454
- await asyncio.sleep(0.1)
455
- except BaseException:
456
- handle.close()
457
- raise
458
-
459
- @staticmethod
460
- def _try_lock_file(handle: Any) -> None:
461
- if os.name == "nt":
462
- import msvcrt
463
- handle.seek(0)
464
- if not handle.read(1):
465
- handle.write(b"\0")
466
- handle.flush()
467
- handle.seek(0)
468
- msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
469
- return
470
- import fcntl
471
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
472
-
473
- @staticmethod
474
- async def _release_artifact_cache_lock(handle: Any) -> None:
475
- try:
476
- if os.name == "nt":
477
- import msvcrt
478
- handle.seek(0)
479
- msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
480
- else:
481
- import fcntl
482
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
483
- finally:
484
- handle.close()
485
-
486
- def _find_cached_harness_artifact(
487
- self,
488
- platform: str,
489
- node_version: str,
490
- ) -> tuple[dict[str, Any], Path] | None:
491
- cache = self.harness_artifact_cache_dir
492
- if cache is None:
493
- return None
494
- requested = self.harness_artifact or {}
495
- for directory in sorted((cache / "artifacts").iterdir(), key=lambda item: item.name):
496
- if not re.fullmatch(r"[0-9a-f]{64}", directory.name):
497
- continue
498
- try:
499
- manifest = json.loads((directory / "artifact.json").read_text(encoding="utf-8"))
500
- except (OSError, UnicodeError, json.JSONDecodeError):
501
- continue
502
- if not isinstance(manifest, dict) or (
503
- manifest.get("revision_identity") != self.revision_identity
504
- or manifest.get("adapter_version") != requested.get("adapter_version")
505
- or manifest.get("recipe_version") != requested.get("recipe_version")
506
- or not self._artifact_compatible(manifest, platform, node_version)
507
- ):
508
- continue
509
- try:
510
- return self._verify_cached_harness_artifact(directory, platform, node_version), directory
511
- except Exception:
512
- self._quarantine_cached_artifact(directory)
513
- return None
514
-
515
- async def _cache_container_artifact(
516
- self,
517
- environment: BaseEnvironment,
518
- prepared: ExecResult,
519
- platform: str,
520
- node_version: str,
521
- ) -> tuple[dict[str, Any], Path]:
522
- try:
523
- payload = json.loads(prepared.stdout or "")
524
- artifact = payload["artifact"]
525
- except (KeyError, TypeError, json.JSONDecodeError) as error:
526
- raise RuntimeError("container prepare returned invalid artifact metadata") from error
527
- artifact_id = artifact.get("artifact_id") if isinstance(artifact, dict) else None
528
- if not isinstance(artifact_id, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", artifact_id):
529
- raise RuntimeError("container prepare returned an invalid artifact ID")
530
- if (
531
- artifact.get("revision_identity") != self.revision_identity
532
- or not self._artifact_compatible(artifact, platform, node_version)
533
- ):
534
- raise RuntimeError("container prepare returned an incompatible artifact")
535
- cache = self.harness_artifact_cache_dir
536
- if cache is None:
537
- raise RuntimeError("prepared artifact cache is unavailable")
538
- staging = cache / "tmp" / f"artifact-{uuid.uuid4().hex}"
539
- staging.mkdir(mode=0o700)
540
- try:
541
- remote = f'{HITCH_CONTAINER_STATE_ROOT}/store/artifacts/{artifact_id.removeprefix("sha256:")}'
542
- await environment.download_dir(remote, staging)
543
- manifest = self._verify_cached_harness_artifact(staging, platform, node_version)
544
- if manifest.get("artifact_id") != artifact_id:
545
- raise RuntimeError("downloaded artifact ID differs from container prepare output")
546
- destination = cache / "artifacts" / artifact_id.removeprefix("sha256:")
547
- if destination.exists():
548
- existing = self._verify_cached_harness_artifact(destination, platform, node_version)
549
- shutil.rmtree(staging)
550
- return existing, destination
551
- staging.rename(destination)
552
- return manifest, destination
553
- finally:
554
- if staging.exists():
555
- shutil.rmtree(staging, ignore_errors=True)
556
-
557
- def _verify_cached_harness_artifact(
558
- self,
559
- directory: Path,
560
- platform: str,
561
- node_version: str,
562
- ) -> dict[str, Any]:
563
- info = directory.lstat()
564
- if not stat_module.S_ISDIR(info.st_mode) or directory.is_symlink():
565
- raise RuntimeError("cached harness artifact is not a regular directory")
566
- artifact_file = directory / "artifact.json"
567
- self._assert_regular_host_file(artifact_file, "cached harness artifact manifest", 1024 * 1024)
568
- manifest = json.loads(artifact_file.read_text(encoding="utf-8"))
569
- if not isinstance(manifest, dict) or manifest.get("schema_version") != HARNESS_ARTIFACT_MANIFEST_VERSION:
570
- raise RuntimeError("cached harness artifact manifest schema is invalid")
571
- for field in ("artifact_id", "artifact_integrity", "entrypoint_integrity", "revision_identity"):
572
- if not re.fullmatch(r"sha256:[0-9a-f]{64}", str(manifest.get(field, ""))):
573
- raise RuntimeError(f"cached harness artifact {field} is invalid")
574
- if (
575
- manifest.get("artifact_id") != f"sha256:{directory.name}" and directory.parent.name == "artifacts"
576
- ):
577
- raise RuntimeError("cached harness artifact directory does not match its ID")
578
- if (
579
- manifest.get("revision_identity") != self.revision_identity
580
- or manifest.get("resolved_revision", {}).get("identity") != self.revision_identity
581
- or manifest.get("harness_id") != self.harness_ref.split("@", 1)[0]
582
- or manifest.get("adapter_version") != (self.harness_artifact or {}).get("adapter_version")
583
- or manifest.get("recipe_version") != (self.harness_artifact or {}).get("recipe_version")
584
- or not self._artifact_compatible(manifest, platform, node_version)
585
- or manifest.get("source_type") == "installed"
586
- ):
587
- raise RuntimeError("cached harness artifact identity is incompatible")
588
- entrypoint = manifest.get("entrypoint")
589
- if not isinstance(entrypoint, str) or not entrypoint or PurePosixPath(entrypoint).is_absolute():
590
- raise RuntimeError("cached harness artifact entrypoint is invalid")
591
- parts = PurePosixPath(entrypoint).parts
592
- if any(part in ("", ".", "..") for part in parts):
593
- raise RuntimeError("cached harness artifact entrypoint escapes its directory")
594
- executable = directory.joinpath(*parts)
595
- if not executable.is_file():
596
- raise RuntimeError("cached harness artifact entrypoint is missing")
597
- if self._sha256_file(executable) != manifest["entrypoint_integrity"]:
598
- raise RuntimeError("cached harness artifact entrypoint integrity mismatch")
599
- if self._artifact_directory_integrity(directory) != manifest["artifact_integrity"]:
600
- raise RuntimeError("cached harness artifact content integrity mismatch")
601
- return manifest
602
-
603
- @classmethod
604
- def _artifact_directory_integrity(cls, root: Path) -> str:
605
- digest = hashlib.sha256()
606
- resolved_root = root.resolve()
607
-
608
- def walk(directory: Path, relative: PurePosixPath, top_level: bool) -> None:
609
- for entry in sorted(os.scandir(directory), key=lambda item: item.name):
610
- if top_level and entry.name == "artifact.json":
611
- continue
612
- child_relative = relative / entry.name
613
- child = Path(entry.path)
614
- info = child.lstat()
615
- mode = info.st_mode & 0o7777
616
- encoded_relative = child_relative.as_posix()
617
- if stat_module.S_ISDIR(info.st_mode):
618
- digest.update(f"d\0{encoded_relative}\0{mode}\0".encode("utf-8"))
619
- walk(child, child_relative, False)
620
- elif stat_module.S_ISREG(info.st_mode):
621
- digest.update(f"f\0{encoded_relative}\0{mode}\0{info.st_size}\0".encode("utf-8"))
622
- with child.open("rb") as handle:
623
- while chunk := handle.read(1024 * 1024):
624
- digest.update(chunk)
625
- digest.update(b"\0")
626
- elif stat_module.S_ISLNK(info.st_mode):
627
- target = os.readlink(child)
628
- resolved_target = (child.parent / target).resolve()
629
- if resolved_target != resolved_root and resolved_root not in resolved_target.parents:
630
- raise RuntimeError("cached harness artifact symlink escapes its directory")
631
- digest.update(f"l\0{encoded_relative}\0{target}\0".encode("utf-8"))
632
- else:
633
- raise RuntimeError("cached harness artifact contains a special file")
634
-
635
- walk(root, PurePosixPath(), True)
636
- return "sha256:" + digest.hexdigest()
637
-
638
- def _quarantine_cached_artifact(self, directory: Path) -> None:
639
- cache = self.harness_artifact_cache_dir
640
- if cache is None or not directory.exists():
641
- return
642
- target = cache / "invalid" / f"{directory.name}-{uuid.uuid4().hex}"
643
- try:
644
- directory.rename(target)
645
- except OSError:
646
- pass
647
-
648
- def _verify_local_source_host(self) -> dict[str, Any]:
649
- """Validate the independent local-source handoff immediately before upload."""
650
- transport = self.local_source_transport or {}
651
- required = {
652
- "kind", "manifest_path", "payload_path", "locked_resolution_path", "harness_id",
653
- "resolution_identity", "commit", "tree", "payload_sha256", "payload_bytes",
654
- "object_count", "file_count",
655
- }
656
- if set(transport) != required:
657
- raise RuntimeError("local source transport metadata fields are invalid")
658
- manifest_path = Path(str(transport["manifest_path"]))
659
- payload_path = Path(str(transport["payload_path"]))
660
- resolution_path = Path(str(transport["locked_resolution_path"]))
661
- if not all(candidate.is_absolute() for candidate in (manifest_path, payload_path, resolution_path)):
662
- raise RuntimeError("local source transport host paths must be absolute")
663
- self._assert_regular_host_file(manifest_path, "local source manifest", 64 * 1024)
664
- payload_stat = self._assert_regular_host_file(payload_path, "local source payload", LOCAL_GIT_TRANSPORT_MAX_BYTES)
665
- self._assert_regular_host_file(resolution_path, "local source locked resolution", 1024 * 1024)
666
- try:
667
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
668
- resolution = json.loads(resolution_path.read_text(encoding="utf-8"))
669
- except (OSError, UnicodeError, json.JSONDecodeError) as error:
670
- raise RuntimeError(f"local source metadata is unreadable: {error}") from error
671
- self._validate_local_manifest(manifest)
672
- pinned = {
673
- "kind": manifest["kind"],
674
- "harness_id": manifest["harness_id"],
675
- "resolution_identity": manifest["resolution_identity"],
676
- "commit": manifest["commit"],
677
- "tree": manifest["tree"],
678
- "payload_sha256": manifest["payload_sha256"],
679
- "payload_bytes": manifest["payload_bytes"],
680
- "object_count": manifest["object_count"],
681
- "file_count": manifest["file_count"],
682
- }
683
- if any(transport.get(key) != value for key, value in pinned.items()):
684
- raise RuntimeError("local source transport metadata does not match its manifest")
685
- if manifest["resolution_identity"] != self.revision_identity:
686
- raise RuntimeError("local source resolution identity does not match the job-pinned identity")
687
- if self.harness_ref != f'{manifest["harness_id"]}@commit:{manifest["commit"]}':
688
- raise RuntimeError("local source commit does not match the job-pinned harness ref")
689
- if payload_stat.st_size != manifest["payload_bytes"]:
690
- raise RuntimeError("local source payload size does not match its manifest")
691
- actual_digest = self._sha256_file(payload_path)
692
- if actual_digest != manifest["payload_sha256"]:
693
- raise RuntimeError("local source payload digest does not match its manifest")
694
- if not isinstance(resolution, dict) or (
695
- resolution.get("harness_id") != manifest["harness_id"]
696
- or resolution.get("identity") != manifest["resolution_identity"]
697
- or resolution.get("revision", {}).get("commit") != manifest["commit"]
698
- or resolution.get("source", {}).get("type") != "git"
699
- or resolution.get("source", {}).get("registered") is not False
700
- ):
701
- raise RuntimeError("local source locked resolution does not match its manifest")
702
- return manifest
703
-
704
- @staticmethod
705
- def _assert_regular_host_file(path: Path, label: str, maximum: int) -> Any:
706
- try:
707
- info = path.lstat()
708
- except OSError as error:
709
- raise RuntimeError(f"{label} is missing or unreadable") from error
710
- if not stat_module.S_ISREG(info.st_mode):
711
- raise RuntimeError(f"{label} must be a regular file")
712
- if info.st_size > maximum:
713
- raise RuntimeError(f"{label} exceeds the size limit ({maximum} bytes)")
714
- return info
715
-
716
- @staticmethod
717
- def _sha256_file(path: Path) -> str:
718
- digest = hashlib.sha256()
719
- with path.open("rb") as handle:
720
- while chunk := handle.read(1024 * 1024):
721
- digest.update(chunk)
722
- return "sha256:" + digest.hexdigest()
723
-
724
- @staticmethod
725
- def _validate_local_manifest(manifest: Any) -> None:
726
- allowed = {
727
- "schema_version", "kind", "harness_id", "resolution_identity", "commit", "tree",
728
- "payload_sha256", "payload_bytes", "object_count", "file_count", "created_at",
729
- }
730
- if not isinstance(manifest, dict) or set(manifest) != allowed:
731
- raise RuntimeError("local source manifest fields are invalid")
732
- if manifest.get("schema_version") != LOCAL_GIT_TRANSPORT_MANIFEST_VERSION:
733
- raise RuntimeError("unsupported local source transport manifest schema")
734
- if manifest.get("kind") != "local-git-commit":
735
- raise RuntimeError("unsupported local source transport kind")
736
- if not isinstance(manifest.get("harness_id"), str) or not re.fullmatch(r"[a-z][a-z0-9-]*", manifest["harness_id"]):
737
- raise RuntimeError("local source manifest harness id is invalid")
738
- if not isinstance(manifest.get("resolution_identity"), str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["resolution_identity"]):
739
- raise RuntimeError("local source manifest resolution identity is invalid")
740
- commit = manifest.get("commit")
741
- if not isinstance(commit, str) or not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", commit):
742
- raise RuntimeError("local source manifest commit is not a full lowercase OID")
743
- tree = manifest.get("tree")
744
- if not isinstance(tree, str) or not re.fullmatch(rf"[0-9a-f]{{{len(commit)}}}", tree):
745
- raise RuntimeError("local source manifest tree is invalid")
746
- if not isinstance(manifest.get("payload_sha256"), str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["payload_sha256"]):
747
- raise RuntimeError("local source manifest payload digest is invalid")
748
- for key, maximum in (
749
- ("payload_bytes", LOCAL_GIT_TRANSPORT_MAX_BYTES),
750
- ("object_count", 100_000),
751
- ("file_count", 50_000),
752
- ):
753
- value = manifest.get(key)
754
- if not isinstance(value, int) or isinstance(value, bool) or value < 0 or value > maximum:
755
- raise RuntimeError(f"local source manifest {key} is invalid")
756
- if not isinstance(manifest.get("created_at"), str) or not manifest["created_at"]:
757
- raise RuntimeError("local source manifest created_at is invalid")
758
- try:
759
- datetime.fromisoformat(manifest["created_at"].replace("Z", "+00:00"))
760
- except ValueError as error:
761
- raise RuntimeError("local source manifest created_at is invalid") from error
762
-
763
- async def _upload_and_materialize_local_source(
764
- self,
765
- environment: BaseEnvironment,
766
- manifest: dict[str, Any],
767
- ) -> None:
768
- transport = self.local_source_transport or {}
769
- await self._exec(environment, f"mkdir -p {LOCAL_GIT_REMOTE_ROOT} && chmod 700 {LOCAL_GIT_REMOTE_ROOT}")
770
- await environment.upload_file(Path(str(transport["manifest_path"])), f"{LOCAL_GIT_REMOTE_ROOT}/manifest.json")
771
- await environment.upload_file(Path(str(transport["payload_path"])), f"{LOCAL_GIT_REMOTE_ROOT}/payload.pack")
772
- await environment.upload_file(Path(str(transport["locked_resolution_path"])), f"{LOCAL_GIT_REMOTE_ROOT}/resolution.json")
773
- await self._exec(environment, f"chmod 600 {LOCAL_GIT_REMOTE_ROOT}/manifest.json {LOCAL_GIT_REMOTE_ROOT}/payload.pack {LOCAL_GIT_REMOTE_ROOT}/resolution.json")
774
- # Verify both the uploaded manifest fields and payload bytes inside the
775
- # trial before importing any Git object. All interpolated values passed
776
- # to the shell have already been restricted to digest/OID grammars.
777
- verifier = """
778
- const fs = require('node:fs');
779
- const crypto = require('node:crypto');
780
- const expected = JSON.parse(process.argv[1]);
781
- const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
782
- if (JSON.stringify(Object.keys(manifest).sort()) !== JSON.stringify(Object.keys(expected).sort())) throw new Error('manifest fields mismatch');
783
- for (const key of Object.keys(expected)) if (manifest[key] !== expected[key]) throw new Error(`manifest mismatch: ${key}`);
784
- if (fs.statSync(process.argv[3]).size !== expected.payload_bytes) throw new Error('payload size mismatch');
785
- (async () => {
786
- const hash = crypto.createHash('sha256');
787
- for await (const chunk of fs.createReadStream(process.argv[3])) hash.update(chunk);
788
- const digest = 'sha256:' + hash.digest('hex');
789
- if (digest !== expected.payload_sha256) throw new Error('payload digest mismatch');
790
- })().catch((error) => { console.error(error.message); process.exit(1); });
791
- """.strip()
792
- expected = {
793
- "schema_version": manifest["schema_version"],
794
- "kind": manifest["kind"],
795
- "harness_id": manifest["harness_id"],
796
- "resolution_identity": manifest["resolution_identity"],
797
- "commit": manifest["commit"],
798
- "tree": manifest["tree"],
799
- "payload_sha256": manifest["payload_sha256"],
800
- "payload_bytes": manifest["payload_bytes"],
801
- "object_count": manifest["object_count"],
802
- "file_count": manifest["file_count"],
803
- "created_at": manifest["created_at"],
804
- }
805
- verify_command = " ".join([
806
- self._node_prefix(),
807
- "node", "-e", shlex.quote(verifier),
808
- shlex.quote(json.dumps(expected, separators=(",", ":"))),
809
- f"{LOCAL_GIT_REMOTE_ROOT}/manifest.json",
810
- f"{LOCAL_GIT_REMOTE_ROOT}/payload.pack",
811
- ])
812
- await self._exec(environment, verify_command)
813
- commit = manifest["commit"]
814
- tree = manifest["tree"]
815
- materialize = f"""
816
- set -eu
817
- git init --bare {LOCAL_GIT_REMOTE_ROOT}/repo.git >/dev/null
818
- git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git index-pack --stdin < {LOCAL_GIT_REMOTE_ROOT}/payload.pack >/dev/null
819
- test "$(git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git rev-parse {commit}^{{commit}})" = "{commit}"
820
- test "$(git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git rev-parse {commit}^{{tree}})" = "{tree}"
821
- printf '%s\n' {commit} > {LOCAL_GIT_REMOTE_ROOT}/repo.git/shallow
822
- git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commit}
823
- """
824
- await self._exec(environment, materialize)
825
-
826
- def _local_source_cli_args(self) -> list[str]:
827
- if self._local_manifest is None or self._artifact_uploaded:
828
- return []
829
- return [
830
- "--internal-locked-resolution", f"{LOCAL_GIT_REMOTE_ROOT}/resolution.json",
831
- "--internal-local-git-manifest", f"{LOCAL_GIT_REMOTE_ROOT}/manifest.json",
832
- "--internal-local-git-source", f"{LOCAL_GIT_REMOTE_ROOT}/repo.git",
833
- ]
834
-
835
406
  def _artifact_cli_args(self) -> list[str]:
836
407
  if not self._artifact_uploaded or self._artifact_manifest is None:
837
408
  return []
@@ -941,6 +512,18 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
941
512
  instruction: str,
942
513
  environment: BaseEnvironment,
943
514
  context: AgentContext,
515
+ ) -> None:
516
+ started_ns = time.monotonic_ns()
517
+ try:
518
+ await self._run(instruction, environment, context)
519
+ finally:
520
+ self._write_phase_timing("agent", started_ns)
521
+
522
+ async def _run(
523
+ self,
524
+ instruction: str,
525
+ environment: BaseEnvironment,
526
+ context: AgentContext,
944
527
  ) -> None:
945
528
  self.logs_dir.mkdir(parents=True, exist_ok=True)
946
529
  workdir = self._require_workdir()
@@ -1006,17 +589,18 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
1006
589
  if parent_temporary is not None:
1007
590
  parent_temporary.unlink(missing_ok=True)
1008
591
 
592
+ proxy_environment, proxy_health = await self._model_proxy_environment(environment, run_id)
1009
593
  if self._entrypoint is None:
1010
594
  raise RuntimeError("Hitch agent setup() must run before run() to resolve the runtime entrypoint")
1011
595
  entry = self._remote_entry(self._entrypoint)
1012
596
  arguments = [
1013
597
  self._node_prefix(),
1014
598
  "HITCH_ROOT=/tmp/hitch-state",
1015
- *(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None or self._artifact_uploaded or parent_payload is not None else []),
599
+ *proxy_environment,
600
+ *(["HITCH_HARBOR_INTERNAL=1"] if self._artifact_uploaded or parent_payload is not None else []),
1016
601
  f"node {entry} run",
1017
602
  "--harness",
1018
603
  shlex.quote(self.harness_ref),
1019
- *self._local_source_cli_args(),
1020
604
  *self._artifact_cli_args(),
1021
605
  "--cwd",
1022
606
  shlex.quote(workdir),
@@ -1041,6 +625,8 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
1041
625
  arguments.extend(["--model", shlex.quote(self.model_name)])
1042
626
  for value in self.agent_args:
1043
627
  arguments.extend(["--agent-arg", shlex.quote(value)])
628
+ for name in self.credential_names:
629
+ arguments.extend(["--internal-credential-name", shlex.quote(name)])
1044
630
  command = (
1045
631
  "set -o pipefail; "
1046
632
  + " ".join(arguments)
@@ -1138,26 +724,17 @@ mv "$stage_dir" "$target_dir"
1138
724
  "trial_id": trial_id,
1139
725
  "task_id": task_id,
1140
726
  "attempt": attempt,
727
+ "model_capture_health": proxy_health,
1141
728
  "hitch_status": hitch_result.get("status") if hitch_result else None,
1142
729
  "hitch_artifact_id": hitch_result.get("artifact_id") if hitch_result else None,
1143
730
  }
1144
- if self._local_manifest is not None:
1145
- context.metadata["local_source_transport"] = {
1146
- "kind": self._local_manifest["kind"],
1147
- "commit": self._local_manifest["commit"],
1148
- "tree": self._local_manifest["tree"],
1149
- "resolution_identity": self._local_manifest["resolution_identity"],
1150
- "payload_sha256": self._local_manifest["payload_sha256"],
1151
- "payload_bytes": self._local_manifest["payload_bytes"],
1152
- "status": "verified",
1153
- }
1154
731
  if self._artifact_manifest is not None:
1155
732
  context.metadata["harness_artifact_transport"] = {
1156
733
  "artifact_id": self._artifact_manifest["artifact_id"],
1157
734
  "artifact_integrity": self._artifact_manifest["artifact_integrity"],
1158
735
  "platform": self._artifact_manifest["platform"],
1159
736
  "node_version": self._artifact_manifest.get("toolchain", {}).get("node"),
1160
- "status": self._artifact_transport_status or "container_prepare",
737
+ "status": self._artifact_transport_status or "dedicated_builder_upload",
1161
738
  }
1162
739
  if primary_code is not None:
1163
740
  context.metadata["hitch_bridge_error_code"] = primary_code
@@ -1181,6 +758,32 @@ mv "$stage_dir" "$target_dir"
1181
758
  await self._write_bridge_error(environment, evidence)
1182
759
  raise HitchBridgeError(primary_code, primary_message or primary_code, evidence)
1183
760
 
761
+ async def _model_proxy_environment(
762
+ self,
763
+ environment: BaseEnvironment,
764
+ run_id: str,
765
+ ) -> tuple[list[str], str]:
766
+ if self.model_capture is None:
767
+ return [], "not-configured"
768
+ health = self.model_capture["health_url_template"].replace("{run_id}", run_id)
769
+ script = (
770
+ "fetch(process.argv[1],{signal:AbortSignal.timeout(5000)})"
771
+ ".then(r=>{if(!r.ok)throw new Error('status '+r.status)})"
772
+ ".catch(()=>{console.error('model proxy health check failed');process.exit(1)})"
773
+ )
774
+ probe = await environment.exec(
775
+ f"{self._node_prefix()} node -e {shlex.quote(script)} {shlex.quote(health)}"
776
+ )
777
+ if probe.return_code != 0:
778
+ if self.model_capture["required"]:
779
+ raise RuntimeError("hitch-model-proxy-health: required model proxy is unreachable")
780
+ return [], "degraded-unreachable"
781
+ base = self.model_capture["base_url_template"].replace("{run_id}", run_id)
782
+ return [
783
+ f"OPENAI_BASE_URL={shlex.quote(base.replace('{provider}', 'openai'))}",
784
+ f"ANTHROPIC_BASE_URL={shlex.quote(base.replace('{provider}', 'anthropic'))}",
785
+ ], "healthy"
786
+
1184
787
  @staticmethod
1185
788
  def _parse_hitch_result(
1186
789
  result: ExecResult,
@@ -1422,40 +1025,36 @@ process.stdout.write('sha256:' + hash.digest('hex'));
1422
1025
 
1423
1026
  async def _ensure_node(self, environment: BaseEnvironment) -> None:
1424
1027
  probe = await environment.exec(
1425
- "node -e 'process.exit(Number(process.versions.node.split(\".\")[0]) >= 22 ? 0 : 1)'"
1028
+ f"node -e 'process.exit(process.version === \"{self.required_node_version}\" ? 0 : 1)'"
1426
1029
  )
1427
- needs_git = "@commit:" in self.harness_ref
1428
- git_probe = await environment.exec("command -v git >/dev/null 2>&1")
1429
- if probe.return_code == 0 and (not needs_git or git_probe.return_code == 0):
1030
+ if probe.return_code == 0:
1430
1031
  return
1431
1032
  prerequisites = """
1432
1033
  set -eu
1433
- if command -v curl >/dev/null 2>&1 && command -v git >/dev/null 2>&1; then exit 0; fi
1034
+ if command -v curl >/dev/null 2>&1; then exit 0; fi
1434
1035
  if command -v apt-get >/dev/null 2>&1; then
1435
- apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates git
1036
+ apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates
1436
1037
  elif command -v apk >/dev/null 2>&1; then
1437
- apk add --no-cache curl ca-certificates git bash
1038
+ apk add --no-cache curl ca-certificates bash
1438
1039
  elif command -v dnf >/dev/null 2>&1; then
1439
- dnf install -y curl ca-certificates git
1040
+ dnf install -y curl ca-certificates
1440
1041
  elif command -v yum >/dev/null 2>&1; then
1441
- yum install -y curl ca-certificates git
1042
+ yum install -y curl ca-certificates
1442
1043
  else
1443
- echo 'Hitch requires Node.js 22+ and could not install curl in this task image' >&2
1044
+ echo 'Hitch could not install the pinned Node.js runtime in this task image' >&2
1444
1045
  exit 1
1445
1046
  fi
1446
1047
  """
1447
1048
  await self._exec(environment, prerequisites, user=0)
1448
- if probe.return_code == 0:
1449
- return
1450
- install = """
1049
+ install = f"""
1451
1050
  set -eu
1452
1051
  export NVM_DIR=/opt/hitch-node
1453
1052
  mkdir -p "$NVM_DIR"
1454
1053
  curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
1455
1054
  . "$NVM_DIR/nvm.sh"
1456
- nvm install 22
1457
- nvm alias default 22
1458
- node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'
1055
+ nvm install {self.required_node_version.removeprefix("v")}
1056
+ nvm alias default {self.required_node_version.removeprefix("v")}
1057
+ node -e 'process.exit(process.version === "{self.required_node_version}" ? 0 : 1)'
1459
1058
  """
1460
1059
  await self._exec(environment, install, user=0)
1461
1060
 
@@ -1495,6 +1094,39 @@ node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)
1495
1094
  return "no diagnostic output"
1496
1095
 
1497
1096
 
1097
+ def _validate_model_capture(value: dict[str, Any] | None) -> dict[str, Any] | None:
1098
+ if value is None:
1099
+ return None
1100
+ if not isinstance(value, dict) or set(value) != {
1101
+ "schema_version", "mode", "required", "topology", "base_url_template", "health_url_template"
1102
+ }:
1103
+ raise ValueError("model_capture fields are invalid")
1104
+ if (
1105
+ value.get("schema_version") != "1"
1106
+ or value.get("mode") not in {"proxy", "hybrid"}
1107
+ or not isinstance(value.get("required"), bool)
1108
+ or value.get("topology") != "host-side"
1109
+ ):
1110
+ raise ValueError("model_capture identity is invalid")
1111
+ for field, provider_count in (("base_url_template", 1), ("health_url_template", 0)):
1112
+ template = value.get(field)
1113
+ if (
1114
+ not isinstance(template, str)
1115
+ or not template
1116
+ or len(template) > 2048
1117
+ or any(character in template for character in ("\x00", "\r", "\n"))
1118
+ or template.count("{run_id}") != 1
1119
+ or template.count("{provider}") != provider_count
1120
+ ):
1121
+ raise ValueError(f"model_capture {field} is invalid")
1122
+ parsed = urlparse(
1123
+ template.replace("{run_id}", "run_" + "a" * 32).replace("{provider}", "openai")
1124
+ )
1125
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
1126
+ raise ValueError(f"model_capture {field} URL is invalid")
1127
+ return dict(value)
1128
+
1129
+
1498
1130
  def canonical_manifest_json(manifest: dict[str, Any]) -> str:
1499
1131
  """Canonically encode the runtime identity `{ schema_version, node_range,
1500
1132
  entrypoints, files }` with sorted object keys and no insignificant