@wardby/cli 0.2.1 → 0.3.0

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 (407) hide show
  1. package/.env.example +34 -4
  2. package/README.md +41 -4
  3. package/dist/claude-coding-worker/driver.d.ts +6 -1
  4. package/dist/claude-coding-worker/driver.js +27 -1
  5. package/dist/claude-coding-worker/main.js +2 -0
  6. package/dist/claude-coding-worker/tool-socket.d.ts +9 -0
  7. package/dist/claude-coding-worker/tool-socket.js +26 -0
  8. package/dist/cli-help.d.ts +1 -1
  9. package/dist/cli-help.js +13 -4
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.js +219 -29
  12. package/dist/coding/collect-exclude.d.ts +25 -0
  13. package/dist/coding/collect-exclude.js +76 -0
  14. package/dist/coding/profile.d.ts +67 -22
  15. package/dist/coding/profile.js +58 -25
  16. package/dist/coding/protected-path-wording.d.ts +24 -0
  17. package/dist/coding/protected-path-wording.js +55 -0
  18. package/dist/coding/protected-paths.d.ts +31 -0
  19. package/dist/coding/protected-paths.js +48 -0
  20. package/dist/coding/protocol.d.ts +75 -4
  21. package/dist/coding/protocol.js +107 -7
  22. package/dist/coding/registry/adapters.d.ts +2 -0
  23. package/dist/coding/registry/adapters.js +9 -0
  24. package/dist/coding/registry/allowlist.d.ts +9 -0
  25. package/dist/coding/registry/allowlist.js +28 -0
  26. package/dist/coding/registry/json-scan.d.ts +55 -0
  27. package/dist/coding/registry/json-scan.js +191 -0
  28. package/dist/coding/registry/lockfiles.d.ts +6 -0
  29. package/dist/coding/registry/lockfiles.js +89 -0
  30. package/dist/coding/registry/npm-lockfile.d.ts +9 -0
  31. package/dist/coding/registry/npm-lockfile.js +73 -0
  32. package/dist/coding/registry/npm-plan.d.ts +29 -0
  33. package/dist/coding/registry/npm-plan.js +289 -0
  34. package/dist/coding/registry/npm.d.ts +7 -0
  35. package/dist/coding/registry/npm.js +252 -0
  36. package/dist/coding/registry/pypi.d.ts +4 -0
  37. package/dist/coding/registry/pypi.js +210 -0
  38. package/dist/coding/registry/report.d.ts +37 -0
  39. package/dist/coding/registry/report.js +31 -0
  40. package/dist/coding/registry/token.d.ts +4 -0
  41. package/dist/coding/registry/token.js +7 -0
  42. package/dist/coding/registry/types.d.ts +286 -0
  43. package/dist/coding/registry/types.js +19 -0
  44. package/dist/coding/registry/worker-config.d.ts +14 -0
  45. package/dist/coding/registry/worker-config.js +31 -0
  46. package/dist/coding/services/builtins.d.ts +22 -0
  47. package/dist/coding/services/builtins.js +85 -0
  48. package/dist/coding/services/catalog.d.ts +443 -0
  49. package/dist/coding/services/catalog.js +159 -0
  50. package/dist/coding/services/declaration.d.ts +11 -0
  51. package/dist/coding/services/declaration.js +114 -0
  52. package/dist/coding/services/note.d.ts +8 -0
  53. package/dist/coding/services/note.js +15 -0
  54. package/dist/coding/services/resolve.d.ts +25 -0
  55. package/dist/coding/services/resolve.js +50 -0
  56. package/dist/coding/services/wording.d.ts +25 -0
  57. package/dist/coding/services/wording.js +70 -0
  58. package/dist/coding-proxy/main.js +24 -4
  59. package/dist/coding-worker/artifact.d.ts +6 -0
  60. package/dist/coding-worker/debug-trace.d.ts +37 -0
  61. package/dist/coding-worker/debug-trace.js +117 -0
  62. package/dist/coding-worker/driver.d.ts +13 -1
  63. package/dist/coding-worker/driver.js +102 -13
  64. package/dist/coding-worker/errors.js +8 -0
  65. package/dist/coding-worker/main.js +10 -2
  66. package/dist/coding-worker/sdk.d.ts +2 -2
  67. package/dist/coding-worker/sdk.js +7 -2
  68. package/dist/coding-worker/types.d.ts +3 -0
  69. package/dist/config/providers.d.ts +41 -0
  70. package/dist/config/providers.js +66 -0
  71. package/dist/core/budget-groups.d.ts +108 -5
  72. package/dist/core/budget-groups.js +125 -19
  73. package/dist/core/budget-wording.d.ts +22 -0
  74. package/dist/core/budget-wording.js +55 -0
  75. package/dist/core/datastores.js +18 -2
  76. package/dist/core/db.d.ts +5 -1
  77. package/dist/core/db.js +8 -2
  78. package/dist/core/dispatch.d.ts +33 -6
  79. package/dist/core/dispatch.js +260 -43
  80. package/dist/core/engine-native.js +27 -10
  81. package/dist/core/grants.d.ts +86 -0
  82. package/dist/core/grants.js +126 -0
  83. package/dist/core/host-events.d.ts +49 -0
  84. package/dist/core/host-events.js +257 -0
  85. package/dist/core/host-identity-links.d.ts +54 -0
  86. package/dist/core/host-identity-links.js +189 -0
  87. package/dist/core/host-status.d.ts +61 -0
  88. package/dist/core/host-status.js +211 -0
  89. package/dist/core/in-flight-runs.d.ts +8 -0
  90. package/dist/core/in-flight-runs.js +56 -0
  91. package/dist/core/provider-wording.d.ts +12 -0
  92. package/dist/core/provider-wording.js +44 -0
  93. package/dist/core/reconciler.d.ts +38 -2
  94. package/dist/core/reconciler.js +86 -2
  95. package/dist/core/repo-access.d.ts +99 -0
  96. package/dist/core/repo-access.js +136 -0
  97. package/dist/core/review-host-checks.d.ts +11 -0
  98. package/dist/core/review-host-checks.js +41 -0
  99. package/dist/core/review-host-tools.d.ts +47 -0
  100. package/dist/core/review-host-tools.js +347 -0
  101. package/dist/core/run-heartbeat.d.ts +27 -0
  102. package/dist/core/run-heartbeat.js +54 -0
  103. package/dist/core/runner.d.ts +30 -8
  104. package/dist/core/runner.js +237 -40
  105. package/dist/core/secrets.d.ts +11 -2
  106. package/dist/core/secrets.js +25 -6
  107. package/dist/core/subagent-memory-tools.d.ts +1 -1
  108. package/dist/core/subagent-memory-tools.js +16 -3
  109. package/dist/core/tool-admin.d.ts +81 -0
  110. package/dist/core/tool-admin.js +129 -0
  111. package/dist/core/tool-names.d.ts +42 -0
  112. package/dist/core/tool-names.js +64 -0
  113. package/dist/core/untrusted-content.d.ts +32 -0
  114. package/dist/core/untrusted-content.js +72 -0
  115. package/dist/core/webhooks.d.ts +8 -1
  116. package/dist/core/webhooks.js +23 -2
  117. package/dist/generated/prisma/browser.d.ts +100 -0
  118. package/dist/generated/prisma/client.d.ts +100 -0
  119. package/dist/generated/prisma/commonInputTypes.d.ts +30 -0
  120. package/dist/generated/prisma/enums.d.ts +6 -0
  121. package/dist/generated/prisma/enums.js +6 -1
  122. package/dist/generated/prisma/internal/class.d.ts +143 -0
  123. package/dist/generated/prisma/internal/class.js +4 -4
  124. package/dist/generated/prisma/internal/prismaNamespace.d.ts +1746 -577
  125. package/dist/generated/prisma/internal/prismaNamespace.js +179 -6
  126. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +186 -0
  127. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +179 -6
  128. package/dist/generated/prisma/models/Agent.d.ts +294 -1
  129. package/dist/generated/prisma/models/AgentRepository.d.ts +1425 -0
  130. package/dist/generated/prisma/models/AgentRepository.js +1 -0
  131. package/dist/generated/prisma/models/AgentTool.d.ts +95 -1
  132. package/dist/generated/prisma/models/AuthUser.d.ts +56 -1
  133. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +258 -7
  134. package/dist/generated/prisma/models/CodingProxySession.d.ts +123 -2
  135. package/dist/generated/prisma/models/CodingRun.d.ts +1276 -95
  136. package/dist/generated/prisma/models/CodingService.d.ts +1348 -0
  137. package/dist/generated/prisma/models/CodingService.js +1 -0
  138. package/dist/generated/prisma/models/HostEventDelivery.d.ts +946 -0
  139. package/dist/generated/prisma/models/HostEventDelivery.js +1 -0
  140. package/dist/generated/prisma/models/HostIdentity.d.ts +1232 -0
  141. package/dist/generated/prisma/models/HostIdentity.js +1 -0
  142. package/dist/generated/prisma/models/HostIdentityLinkRequest.d.ts +1473 -0
  143. package/dist/generated/prisma/models/HostIdentityLinkRequest.js +1 -0
  144. package/dist/generated/prisma/models/Principal.d.ts +455 -0
  145. package/dist/generated/prisma/models/RegistryAllowance.d.ts +1148 -0
  146. package/dist/generated/prisma/models/RegistryAllowance.js +1 -0
  147. package/dist/generated/prisma/models/RegistryApprovedVersion.d.ts +1219 -0
  148. package/dist/generated/prisma/models/RegistryApprovedVersion.js +1 -0
  149. package/dist/generated/prisma/models/RegistryFetch.d.ts +1428 -0
  150. package/dist/generated/prisma/models/RegistryFetch.js +1 -0
  151. package/dist/generated/prisma/models/RegistryPlanRefusal.d.ts +1294 -0
  152. package/dist/generated/prisma/models/RegistryPlanRefusal.js +1 -0
  153. package/dist/generated/prisma/models/RegistryVersionFact.d.ts +1085 -0
  154. package/dist/generated/prisma/models/RegistryVersionFact.js +1 -0
  155. package/dist/generated/prisma/models/ResourceGrant.d.ts +1437 -0
  156. package/dist/generated/prisma/models/ResourceGrant.js +1 -0
  157. package/dist/generated/prisma/models/Run.d.ts +389 -1
  158. package/dist/generated/prisma/models/RunHostCheck.d.ts +1239 -0
  159. package/dist/generated/prisma/models/RunHostCheck.js +1 -0
  160. package/dist/generated/prisma/models/RunHostStatus.d.ts +1315 -0
  161. package/dist/generated/prisma/models/RunHostStatus.js +1 -0
  162. package/dist/generated/prisma/models/Tool.d.ts +15 -3
  163. package/dist/generated/prisma/models.d.ts +13 -0
  164. package/dist/help/build.d.ts +1 -0
  165. package/dist/help/build.js +9 -0
  166. package/dist/help/catalog.d.ts +24 -0
  167. package/dist/help/catalog.js +160 -0
  168. package/dist/help/cli.d.ts +2 -0
  169. package/dist/help/cli.js +65 -0
  170. package/dist/help/runtime.d.ts +3 -0
  171. package/dist/help/runtime.js +44 -0
  172. package/dist/help/search.d.ts +9 -0
  173. package/dist/help/search.js +104 -0
  174. package/dist/help-index.json +660 -0
  175. package/dist/import/cli-args.js +3 -2
  176. package/dist/import/create.d.ts +5 -0
  177. package/dist/import/create.js +67 -13
  178. package/dist/import/index.js +19 -9
  179. package/dist/import/neutral-schema.d.ts +11 -11
  180. package/dist/mcp/auth/access.d.ts +70 -0
  181. package/dist/mcp/auth/access.js +58 -0
  182. package/dist/mcp/auth/grants-cli.d.ts +149 -0
  183. package/dist/mcp/auth/grants-cli.js +518 -0
  184. package/dist/mcp/auth/host-account-cli.d.ts +2 -0
  185. package/dist/mcp/auth/host-account-cli.js +47 -0
  186. package/dist/mcp/auth/ownership.d.ts +26 -52
  187. package/dist/mcp/auth/ownership.js +19 -14
  188. package/dist/mcp/auth/repo-authorization.d.ts +22 -0
  189. package/dist/mcp/auth/repo-authorization.js +51 -0
  190. package/dist/mcp/auth/resource-server.d.ts +31 -2
  191. package/dist/mcp/auth/resource-server.js +84 -3
  192. package/dist/mcp/auth/self-hosted/browser.js +2 -2
  193. package/dist/mcp/auth/self-hosted/cli.js +47 -13
  194. package/dist/mcp/auth/self-hosted/credentials.d.ts +20 -2
  195. package/dist/mcp/auth/self-hosted/credentials.js +62 -4
  196. package/dist/mcp/auth/self-hosted/session.d.ts +2 -1
  197. package/dist/mcp/context.d.ts +27 -1
  198. package/dist/mcp/errors.d.ts +25 -6
  199. package/dist/mcp/errors.js +98 -0
  200. package/dist/mcp/host-events/github-ingress.d.ts +36 -0
  201. package/dist/mcp/host-events/github-ingress.js +92 -0
  202. package/dist/mcp/host-events/github-user-callback.d.ts +18 -0
  203. package/dist/mcp/host-events/github-user-callback.js +50 -0
  204. package/dist/mcp/index.d.ts +8 -1
  205. package/dist/mcp/index.js +106 -16
  206. package/dist/mcp/server.js +27 -9
  207. package/dist/mcp/tools/agents.js +446 -48
  208. package/dist/mcp/tools/budget-groups.js +3 -3
  209. package/dist/mcp/tools/datastore.js +22 -15
  210. package/dist/mcp/tools/grants.d.ts +2 -0
  211. package/dist/mcp/tools/grants.js +239 -0
  212. package/dist/mcp/tools/help.d.ts +5 -0
  213. package/dist/mcp/tools/help.js +67 -0
  214. package/dist/mcp/tools/host-accounts.d.ts +2 -0
  215. package/dist/mcp/tools/host-accounts.js +114 -0
  216. package/dist/mcp/tools/memory.d.ts +7 -1
  217. package/dist/mcp/tools/memory.js +5 -5
  218. package/dist/mcp/tools/repositories.d.ts +2 -0
  219. package/dist/mcp/tools/repositories.js +181 -0
  220. package/dist/mcp/tools/runs.d.ts +6 -0
  221. package/dist/mcp/tools/runs.js +60 -6
  222. package/dist/mcp/tools/scheduling.js +3 -6
  223. package/dist/mcp/tools/secrets.js +18 -6
  224. package/dist/mcp/tools/services.d.ts +2 -0
  225. package/dist/mcp/tools/services.js +222 -0
  226. package/dist/mcp/tools/subagents.js +52 -16
  227. package/dist/mcp/tools/tools.d.ts +41 -0
  228. package/dist/mcp/tools/tools.js +201 -40
  229. package/dist/mcp/tools/trigger.js +36 -6
  230. package/dist/mcp/tools/webhooks.js +15 -4
  231. package/dist/mcp/transport/streamable-http.d.ts +10 -0
  232. package/dist/mcp/transport/streamable-http.js +32 -2
  233. package/dist/providers/auth/delegating.d.ts +19 -0
  234. package/dist/providers/auth/delegating.js +71 -0
  235. package/dist/providers/auth/index.d.ts +2 -0
  236. package/dist/providers/auth/index.js +11 -0
  237. package/dist/providers/auth/self-hosted.d.ts +2 -1
  238. package/dist/providers/auth/self-hosted.js +16 -4
  239. package/dist/providers/auth/types.d.ts +6 -0
  240. package/dist/providers/coding-proxy/memory-ledger.d.ts +3 -0
  241. package/dist/providers/coding-proxy/memory-ledger.js +21 -2
  242. package/dist/providers/coding-proxy/metering.d.ts +4 -0
  243. package/dist/providers/coding-proxy/metering.js +28 -2
  244. package/dist/providers/coding-proxy/prisma-ledger.d.ts +17 -0
  245. package/dist/providers/coding-proxy/prisma-ledger.js +50 -5
  246. package/dist/providers/coding-proxy/proxy.d.ts +13 -0
  247. package/dist/providers/coding-proxy/proxy.js +479 -18
  248. package/dist/providers/coding-proxy/registry/audit.d.ts +131 -0
  249. package/dist/providers/coding-proxy/registry/audit.js +380 -0
  250. package/dist/providers/coding-proxy/registry/bounded-fetch.d.ts +16 -0
  251. package/dist/providers/coding-proxy/registry/bounded-fetch.js +77 -0
  252. package/dist/providers/coding-proxy/registry/plan.d.ts +58 -0
  253. package/dist/providers/coding-proxy/registry/plan.js +304 -0
  254. package/dist/providers/coding-proxy/registry/prisma-store.d.ts +34 -0
  255. package/dist/providers/coding-proxy/registry/prisma-store.js +151 -0
  256. package/dist/providers/coding-proxy/registry/service.d.ts +213 -0
  257. package/dist/providers/coding-proxy/registry/service.js +1137 -0
  258. package/dist/providers/coding-proxy/registry/store.d.ts +127 -0
  259. package/dist/providers/coding-proxy/registry/store.js +70 -0
  260. package/dist/providers/coding-proxy/runtime.d.ts +17 -0
  261. package/dist/providers/coding-proxy/runtime.js +63 -0
  262. package/dist/providers/coding-proxy/secure-fetch.js +0 -1
  263. package/dist/providers/coding-proxy/server.d.ts +11 -0
  264. package/dist/providers/coding-proxy/server.js +163 -0
  265. package/dist/providers/coding-proxy/types.d.ts +15 -0
  266. package/dist/providers/engine/types.d.ts +10 -0
  267. package/dist/providers/executor/build.d.ts +2 -2
  268. package/dist/providers/executor/composition.d.ts +3 -0
  269. package/dist/providers/executor/composition.js +19 -0
  270. package/dist/providers/executor/container.d.ts +105 -3
  271. package/dist/providers/executor/container.js +296 -20
  272. package/dist/providers/executor/dbos.d.ts +2 -3
  273. package/dist/providers/executor/in-process.d.ts +2 -3
  274. package/dist/providers/executor/routing.d.ts +7 -0
  275. package/dist/providers/executor/routing.js +13 -0
  276. package/dist/providers/executor/types.d.ts +22 -0
  277. package/dist/providers/jobs/claude-tool-setup.d.ts +23 -0
  278. package/dist/providers/jobs/claude-tool-setup.js +50 -0
  279. package/dist/providers/jobs/collect-prune.d.ts +7 -0
  280. package/dist/providers/jobs/collect-prune.js +27 -0
  281. package/dist/providers/jobs/docker-isolation.d.ts +48 -3
  282. package/dist/providers/jobs/docker-isolation.js +213 -22
  283. package/dist/providers/jobs/docker-services.d.ts +35 -0
  284. package/dist/providers/jobs/docker-services.js +191 -0
  285. package/dist/providers/jobs/docker.d.ts +49 -2
  286. package/dist/providers/jobs/docker.js +279 -32
  287. package/dist/providers/jobs/fake-kubernetes-api.d.ts +1 -0
  288. package/dist/providers/jobs/fake-kubernetes-api.js +12 -3
  289. package/dist/providers/jobs/kubernetes-isolation.d.ts +17 -2
  290. package/dist/providers/jobs/kubernetes-isolation.js +238 -57
  291. package/dist/providers/jobs/kubernetes-platform.d.ts +10 -4
  292. package/dist/providers/jobs/kubernetes-platform.js +11 -5
  293. package/dist/providers/jobs/kubernetes-preflight.js +3 -0
  294. package/dist/providers/jobs/kubernetes.d.ts +21 -2
  295. package/dist/providers/jobs/kubernetes.js +112 -21
  296. package/dist/providers/jobs/types.d.ts +18 -0
  297. package/dist/providers/llm/anthropic.js +6 -2
  298. package/dist/providers/llm/bedrock.js +2 -1
  299. package/dist/providers/llm/claude-messages.d.ts +5 -1
  300. package/dist/providers/llm/claude-messages.js +1 -0
  301. package/dist/providers/llm/claude-provider.d.ts +3 -1
  302. package/dist/providers/llm/claude-provider.js +5 -1
  303. package/dist/providers/llm/index.d.ts +1 -1
  304. package/dist/providers/llm/index.js +1 -1
  305. package/dist/providers/llm/pricing-anthropic.d.ts +10 -0
  306. package/dist/providers/llm/pricing-anthropic.js +32 -14
  307. package/dist/providers/llm/pricing-bedrock-claude.d.ts +8 -0
  308. package/dist/providers/llm/pricing-bedrock-claude.js +9 -0
  309. package/dist/providers/llm/routing.d.ts +10 -1
  310. package/dist/providers/llm/routing.js +18 -0
  311. package/dist/providers/llm/types.d.ts +12 -0
  312. package/dist/providers/llm/types.js +8 -1
  313. package/dist/providers/review-host/diff-lines.d.ts +16 -0
  314. package/dist/providers/review-host/diff-lines.js +59 -0
  315. package/dist/providers/review-host/github-events.d.ts +6 -0
  316. package/dist/providers/review-host/github-events.js +180 -0
  317. package/dist/providers/review-host/github-user-auth.d.ts +38 -0
  318. package/dist/providers/review-host/github-user-auth.js +128 -0
  319. package/dist/providers/review-host/github.d.ts +57 -0
  320. package/dist/providers/review-host/github.js +567 -0
  321. package/dist/providers/review-host/index.d.ts +12 -0
  322. package/dist/providers/review-host/index.js +30 -0
  323. package/dist/providers/review-host/review-format.d.ts +19 -0
  324. package/dist/providers/review-host/review-format.js +59 -0
  325. package/dist/providers/review-host/types.d.ts +284 -0
  326. package/dist/providers/review-host/types.js +24 -0
  327. package/dist/providers/vcs/git.d.ts +16 -6
  328. package/dist/providers/vcs/git.js +77 -13
  329. package/dist/providers/vcs/github.d.ts +74 -3
  330. package/dist/providers/vcs/github.js +195 -15
  331. package/dist/providers/vcs/types.d.ts +42 -4
  332. package/dist/quickstart/index.js +13 -2
  333. package/dist/sandbox/fetch-policy.d.ts +20 -2
  334. package/dist/sandbox/fetch-policy.js +64 -3
  335. package/dist/sandbox/host-functions.d.ts +9 -1
  336. package/dist/sandbox/host-functions.js +12 -5
  337. package/dist/serve.js +1 -1
  338. package/dist/wardby-bin.js +6 -0
  339. package/docs/README.md +30 -0
  340. package/docs/architecture-runtime.md +90 -0
  341. package/docs/assets/brand/wardby-icon-512.png +0 -0
  342. package/docs/assets/brand/wardby-icon.svg +16 -0
  343. package/docs/assets/brand/wardby-mascot-profile-512.png +0 -0
  344. package/docs/assets/brand/wardby-mascot.png +0 -0
  345. package/docs/assets/brand/wardby-mascot.svg +5 -0
  346. package/docs/assets/wardby-workflow.svg +106 -0
  347. package/docs/code-review-agents.md +481 -0
  348. package/docs/coding-agent-setup.md +172 -0
  349. package/docs/coding-packages.md +455 -0
  350. package/docs/coding-services.md +300 -0
  351. package/docs/coding-worker-byo-images.md +98 -0
  352. package/docs/coding-worker-isolation.md +1061 -0
  353. package/docs/getting-started-gke.md +615 -0
  354. package/docs/getting-started-identity-provider.md +308 -0
  355. package/docs/getting-started.md +133 -0
  356. package/docs/observability.md +53 -0
  357. package/docs/release-verification.md +66 -0
  358. package/docs/security-deployment.md +657 -0
  359. package/help/code-review-agents.md +31 -0
  360. package/help/coding-packages.md +31 -0
  361. package/help/coding-services.md +71 -0
  362. package/help/creating-agents.md +68 -0
  363. package/help/deploy-gke.md +39 -0
  364. package/help/deployment-targets.md +39 -0
  365. package/help/errors/budget-group-exhausted.md +25 -0
  366. package/help/errors/docker-isolation-unsupported.md +26 -0
  367. package/help/errors/protected-path.md +45 -0
  368. package/help/errors/repo-access.md +27 -0
  369. package/help/errors/service-declaration-invalid.md +27 -0
  370. package/help/errors/service-declaration-unavailable.md +25 -0
  371. package/help/errors/service-launcher-unsupported.md +30 -0
  372. package/help/errors/service-not-allowed.md +27 -0
  373. package/help/errors/service-unknown.md +23 -0
  374. package/help/errors/service-unready.md +49 -0
  375. package/help/getting-started.md +31 -0
  376. package/help/github.md +30 -0
  377. package/help/identity-and-access.md +39 -0
  378. package/help/mcp.md +30 -0
  379. package/help/native-capabilities.md +30 -0
  380. package/help/observability.md +41 -0
  381. package/help/operating-agents.md +30 -0
  382. package/help/security.md +27 -0
  383. package/help/troubleshooting/budgets.md +26 -0
  384. package/help/troubleshooting/coding-workers.md +47 -0
  385. package/help/troubleshooting/repository-access.md +27 -0
  386. package/package.json +12 -3
  387. package/prisma/migrations/20260925010000_coding_collect_exclude/migration.sql +8 -0
  388. package/prisma/migrations/20260925015000_allowed_egress_default/migration.sql +8 -0
  389. package/prisma/migrations/20260925020000_coding_package_registry/migration.sql +55 -0
  390. package/prisma/migrations/20260925030000_tool_name_per_owner/migration.sql +11 -0
  391. package/prisma/migrations/20260926010000_run_trigger_host_event/migration.sql +7 -0
  392. package/prisma/migrations/20260926020000_code_review_hosts/migration.sql +53 -0
  393. package/prisma/migrations/20260926030000_auth_user_roles/migration.sql +9 -0
  394. package/prisma/migrations/20260926040000_agent_effort/migration.sql +6 -0
  395. package/prisma/migrations/20260926050000_registry_lockfile_plan/migration.sql +32 -0
  396. package/prisma/migrations/20260926050000_repo_access_authorization/migration.sql +82 -0
  397. package/prisma/migrations/20260926060000_registry_plan_refusal/migration.sql +19 -0
  398. package/prisma/migrations/20260926100000_registry_plan_refusal_published_at/migration.sql +5 -0
  399. package/prisma/migrations/20260926190000_run_host_status/migration.sql +18 -0
  400. package/prisma/migrations/20260926210000_run_host_status_at_dispatch/migration.sql +5 -0
  401. package/prisma/migrations/20260927010000_resource_grants/migration.sql +72 -0
  402. package/prisma/migrations/20260927020000_proxy_session_budget_exhausted/migration.sql +3 -0
  403. package/prisma/migrations/20260927030000_coding_debug_trace/migration.sql +4 -0
  404. package/prisma/migrations/20260927040000_proxy_session_upstream_failure/migration.sql +3 -0
  405. package/prisma/migrations/20260927050000_coding_run_services/migration.sql +74 -0
  406. package/prisma/migrations/20260928000000_coding_run_tool_image/migration.sql +5 -0
  407. package/prisma/schema.prisma +407 -21
@@ -0,0 +1,1137 @@
1
+ /**
2
+ * RegistryService: the security core of the coding package registry. It
3
+ * authenticates the registry-only token, enforces the per-run allowlist and
4
+ * the dependency graph grown from it, filters versions by allowlisted
5
+ * range, minimum release age and OSV advisories, streams downloads with
6
+ * integrity verification and size/idle limits, and records every served or
7
+ * refused fetch. Adapters (npm, PyPI, …) supply ecosystem-specific parsing
8
+ * and protocol only; every safeguard lives here so a new adapter cannot
9
+ * weaken one by omission.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { logger } from "../../../core/logger.js";
13
+ import { matchRoot, parseAllowlist, resolvePolicy } from "../../../coding/registry/allowlist.js";
14
+ import { PLAN_REFUSAL_FILENAME } from "../../../coding/registry/report.js";
15
+ import { RegistryError, } from "../../../coding/registry/types.js";
16
+ import { capabilityHash } from "../proxy.js";
17
+ import { boundedMetadataFetch, DEFAULT_MAX_METADATA_BYTES, DEFAULT_METADATA_TIMEOUT_MS } from "./bounded-fetch.js";
18
+ import { DEFAULT_PLAN_MAX_ENTRIES, DEFAULT_PLAN_TIMEOUT_MS, verifyLockfilePlan } from "./plan.js";
19
+ const registryLog = logger.child({ module: "coding-registry" });
20
+ /** Server-side record of a registry failure. The worker only ever sees the
21
+ * RegistryError's code and message; the operator needs the cause. Without
22
+ * this, a proxy database role missing its grants (permission denied) was
23
+ * indistinguishable from an upstream outage: an unlogged 502 to npm or pip.
24
+ * Expected refusals (4xx) stay unlogged here: they are recorded per run. */
25
+ function logRegistryFailure(route, ecosystem, error) {
26
+ if (error instanceof RegistryError) {
27
+ if (error.status >= 500) {
28
+ registryLog.warn({
29
+ event: "registry.unavailable",
30
+ route,
31
+ ecosystem,
32
+ status: error.status,
33
+ code: error.code,
34
+ reason: error.message,
35
+ }, "coding registry could not answer");
36
+ }
37
+ return;
38
+ }
39
+ registryLog.error({ event: "registry.failed", route, ecosystem, err: error }, "coding registry request failed");
40
+ }
41
+ const DAY_MS = 86_400_000;
42
+ /** Bytes of a served file kept for `dependenciesFromFile`. A larger file is
43
+ * truncated here, the adapter cannot read its metadata from a truncated
44
+ * archive, and it yields no dependencies: this fails closed (nothing is
45
+ * allowed that the file did not prove). */
46
+ const DEPENDENCY_BUFFER_LIMIT = 64 * 1024 * 1024;
47
+ /** npm's own name-length limit; longer recorded names are truncated. */
48
+ const MAX_RECORDED_NAME = 214;
49
+ export const DEFAULT_MAX_GRAPH_PACKAGES = 3000;
50
+ export const DEFAULT_GRAPH_TIMEOUT_MS = 180_000;
51
+ /** Packages one graph walk expands at once (metadata + audit each). Each
52
+ * expansion may hold a whole parsed npm packument (tens of MB for the
53
+ * largest), so this bounds the walk's peak memory, not only its upstream
54
+ * load: 8 at once OOM-killed a 512Mi proxy in a live lockfile install. */
55
+ const GRAPH_WALK_CONCURRENCY = 2;
56
+ /** Graph-walk expansions in flight at once across every run, so one run's
57
+ * walk cannot starve the others' upstream fetches -- or the proxy's event
58
+ * loop: parsing packuments is CPU work on the same loop that carries model
59
+ * traffic (per-walk 2 and global 3 since a live install at 4/6 on 500m
60
+ * expired a ledger transaction). The walk is only the fallback now; lockfile
61
+ * verification answers most installs without it. */
62
+ export const DEFAULT_MAX_GRAPH_CONCURRENCY = 3;
63
+ /** A node whose metadata or audit fails transiently is retried once more
64
+ * in the same walk, then parked; a later miss retries it again, up to
65
+ * this many attempts per node per run. */
66
+ const GRAPH_NODE_ATTEMPTS_PER_WALK = 2;
67
+ const MAX_GRAPH_NODE_ATTEMPTS = 4;
68
+ /** RegistryErrors that are a definitive answer about a package (it has no
69
+ * dependencies the run can use), not a transient failure to retry. */
70
+ const DEFINITIVE_NODE_ERRORS = new Set(["wardby_package_not_found", "wardby_metadata_too_large"]);
71
+ /** Largest per-version document read from upstream for lockfile
72
+ * verification (they are a few KB). */
73
+ const MAX_VERSION_DOCUMENT_BYTES = 4 * 1024 * 1024;
74
+ /** Lockfile plans read and verified at once across every run: with the
75
+ * 20 MiB body cap, this bounds the lockfile text held at once. */
76
+ export const DEFAULT_PLAN_CONCURRENCY = 2;
77
+ /** Lockfile plans one run may make (REGISTRY_PLAN_MAX_PER_RUN). */
78
+ export const DEFAULT_PLAN_MAX_PER_RUN = 20;
79
+ /** The `filename` of a refused RegistryFetch row a lockfile plan recorded
80
+ * (the route it came from), which is how get_run tells plan refusals from
81
+ * download refusals. */
82
+ export const PLAN_FETCH_FILENAME = PLAN_REFUSAL_FILENAME;
83
+ /** Largest lockfile a plan reads (the HTTP layer enforces it: 413). */
84
+ export const MAX_PLAN_BODY_BYTES = 20 * 1024 * 1024;
85
+ /** A small counting semaphore. */
86
+ class Slots {
87
+ size;
88
+ active = 0;
89
+ waiting = [];
90
+ constructor(size) {
91
+ this.size = size;
92
+ }
93
+ async run(task) {
94
+ if (this.active >= this.size)
95
+ await new Promise((resolve) => this.waiting.push(resolve));
96
+ else
97
+ this.active += 1;
98
+ try {
99
+ return await task();
100
+ }
101
+ finally {
102
+ const next = this.waiting.shift();
103
+ if (next)
104
+ next();
105
+ else
106
+ this.active -= 1;
107
+ }
108
+ }
109
+ }
110
+ const edgeKey = (edge) => `${edge.name}\0${edge.range}`;
111
+ export const DEFAULT_MAX_REFUSAL_RECORDS = 500;
112
+ function contentTypeOf(route) {
113
+ return route.kind === "file-metadata" ? "text/plain" : "application/octet-stream";
114
+ }
115
+ export function errorResponse(error) {
116
+ return {
117
+ status: error.status,
118
+ contentType: "application/json",
119
+ body: JSON.stringify({ error: `${error.code}: ${error.message}` }),
120
+ };
121
+ }
122
+ export const DEFAULT_METADATA_CACHE_ENTRIES = 500;
123
+ /** Byte budget of the metadata cache, by each entry's approximate retained
124
+ * size. Entries are trimmed (an npm package's is a few MB at most, for
125
+ * thousands of versions), but 500 of them could still add up. */
126
+ export const DEFAULT_METADATA_CACHE_BYTES = 64 * 1024 * 1024;
127
+ /** setTimeout's largest delay; a longer deadline is re-armed on firing. */
128
+ const MAX_TIMER_MS = 2 ** 31 - 1;
129
+ /** `approxBytes` when the adapter reports it, otherwise a generous
130
+ * estimate from the versions alone. */
131
+ function metadataBytes(meta) {
132
+ if (meta.approxBytes !== undefined)
133
+ return meta.approxBytes;
134
+ let bytes = 256 + meta.name.length;
135
+ for (const info of meta.versions.values()) {
136
+ bytes += 400 + 2 * info.version.length;
137
+ for (const spec of info.dependencySpecs ?? [])
138
+ bytes += 80 + spec.name.length + spec.range.length;
139
+ for (const name of info.dependencies)
140
+ bytes += 40 + name.length;
141
+ for (const file of info.files)
142
+ bytes += 400 + file.filename.length + file.upstreamUrl.length + (file.integrity?.hex.length ?? 0);
143
+ }
144
+ return bytes;
145
+ }
146
+ /** Normalized dependency names of a package's kept versions: what serving
147
+ * its metadata unlocks, and what the graph walk follows. Dependency keys
148
+ * come from the upstream document, so only real package names are kept:
149
+ * neither path ever records an allowance for, or builds an upstream
150
+ * request from, a key the adapter's own parser would reject. */
151
+ function keptDependencies(adapter, meta, keep) {
152
+ return [...new Set([...keep].flatMap((version) => versionDependencies(adapter, meta, version).map((d) => d.name)))];
153
+ }
154
+ /** One version's declared dependencies (name and range), normalized and
155
+ * limited to real package names: the single source both the metadata
156
+ * path (names only) and the graph walk (names and ranges) read. */
157
+ function versionDependencies(adapter, meta, version) {
158
+ const info = meta.versions.get(version);
159
+ if (!info)
160
+ return [];
161
+ const specs = info.dependencySpecs ?? info.dependencies.map((name) => ({ name, range: "*" }));
162
+ return specs
163
+ .map((spec) => ({ name: adapter.normalizeName(spec.name), range: spec.range }))
164
+ .filter((spec) => isPackageName(adapter, spec.name));
165
+ }
166
+ /** Whether `name` is a plain, valid package name in this ecosystem (no
167
+ * wildcard, no range), by the adapter's own allowlist parser. */
168
+ function isPackageName(adapter, name) {
169
+ try {
170
+ const entry = adapter.parseAllowlistEntry(name);
171
+ return !entry.wildcard && entry.range === undefined && entry.name === name;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
177
+ /** The strongest supported hash of an SRI integrity string (`sha512-…`,
178
+ * possibly several space-separated), or null when it has none. */
179
+ export function parseSri(value) {
180
+ const order = ["sha512", "sha384", "sha256", "sha1"];
181
+ let best = null;
182
+ for (const token of value.trim().split(/\s+/)) {
183
+ const match = token.match(/^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/]+={0,2})(?:\?.*)?$/);
184
+ if (!match)
185
+ continue;
186
+ const algorithm = match[1];
187
+ if (best && order.indexOf(best.algorithm) <= order.indexOf(algorithm))
188
+ continue;
189
+ best = { algorithm, hex: Buffer.from(match[2], "base64").toString("hex") };
190
+ }
191
+ return best;
192
+ }
193
+ export class RegistryService {
194
+ options;
195
+ /** Parsed, trimmed metadata (adapters keep only the fields the proxy
196
+ * reads, never the upstream document), least recently used first.
197
+ * Bounded to `metadataCacheEntries` and `metadataCacheBytes`; expired
198
+ * entries are dropped on access and on insert. */
199
+ metadataCache = new Map();
200
+ /** Sum of the cached entries' `bytes`. */
201
+ metadataCacheTotal = 0;
202
+ /** Single-flight: concurrent misses for one package (and render need)
203
+ * share one upstream fetch. */
204
+ metadataLoads = new Map();
205
+ now;
206
+ metadataTtlMs;
207
+ metadataCacheEntries;
208
+ metadataCacheBytes;
209
+ metadataUpstream;
210
+ /** Per-run in-flight download usage: files and bytes reserved by downloads
211
+ * that are streaming right now but not yet recorded to the store. Without
212
+ * this, `usage()` (which only counts already-served rows) is read once
213
+ * per request, so concurrent downloads for the same run (e.g. npm's
214
+ * parallel installs) count toward neither the file nor the byte limit
215
+ * until each finishes, letting them overshoot both. This reservation is
216
+ * in-process only — it holds per replica of the registry proxy, not
217
+ * across replicas. */
218
+ inFlight = new Map();
219
+ tallies = new Map();
220
+ walkSlots;
221
+ planSlots;
222
+ versionUpstream;
223
+ constructor(options) {
224
+ this.options = options;
225
+ this.now = options.now ?? (() => new Date());
226
+ this.walkSlots = new Slots(options.maxGraphConcurrency ?? DEFAULT_MAX_GRAPH_CONCURRENCY);
227
+ this.planSlots = new Slots(options.planConcurrency ?? DEFAULT_PLAN_CONCURRENCY);
228
+ this.metadataTtlMs = options.metadataTtlMs ?? 300_000;
229
+ this.metadataCacheEntries = options.metadataCacheEntries ?? DEFAULT_METADATA_CACHE_ENTRIES;
230
+ this.metadataCacheBytes = options.metadataCacheBytes ?? DEFAULT_METADATA_CACHE_BYTES;
231
+ this.metadataUpstream = boundedMetadataFetch(options.upstream, {
232
+ timeoutMs: options.metadataTimeoutMs ?? DEFAULT_METADATA_TIMEOUT_MS,
233
+ maxBytes: options.maxMetadataBytes ?? DEFAULT_MAX_METADATA_BYTES,
234
+ });
235
+ this.versionUpstream = boundedMetadataFetch(options.upstream, {
236
+ timeoutMs: options.metadataTimeoutMs ?? DEFAULT_METADATA_TIMEOUT_MS,
237
+ maxBytes: MAX_VERSION_DOCUMENT_BYTES,
238
+ });
239
+ }
240
+ /** `POST /registry/<ecosystem>/-/plan`: verifies a lockfile against the
241
+ * registry and approves exactly its verified, reachable name@version
242
+ * pairs for the run (plan.ts). Answers `{ approved, refused }`. */
243
+ async plan(request) {
244
+ try {
245
+ return await this.planLockfile(request);
246
+ }
247
+ catch (error) {
248
+ logRegistryFailure("plan", request.ecosystem, error);
249
+ if (error instanceof RegistryError)
250
+ return errorResponse(error);
251
+ return errorResponse(new RegistryError(502, "wardby_upstream_error", "the lockfile could not be verified"));
252
+ }
253
+ }
254
+ async planLockfile(request) {
255
+ const adapter = this.options.adapters.get(request.ecosystem);
256
+ if (!adapter)
257
+ throw new RegistryError(404, "wardby_registry_unknown", `no registry named "${request.ecosystem}"`);
258
+ const context = await this.options.store.findRunByRegistryTokenHash(capabilityHash(request.token), this.now());
259
+ if (!context)
260
+ throw new RegistryError(401, "invalid_capability", "the registry token is not valid for a live run");
261
+ const support = adapter.lockfilePlan;
262
+ const auditVersions = this.options.audit.auditVersions?.bind(this.options.audit);
263
+ if (!support || !auditVersions)
264
+ throw new RegistryError(404, "wardby_plan_unsupported", `the ${adapter.id} registry does not verify lockfiles`);
265
+ const tally = this.tally(context);
266
+ if (tally.planning)
267
+ throw new RegistryError(429, "wardby_plan_in_progress", "a lockfile plan is already running for this run; wait for it to finish");
268
+ const maxPlans = this.options.planMaxPerRun ?? DEFAULT_PLAN_MAX_PER_RUN;
269
+ if ((tally.plans ?? 0) >= maxPlans)
270
+ throw new RegistryError(429, "wardby_plan_limit", `this run has made its ${maxPlans} lockfile plans (REGISTRY_PLAN_MAX_PER_RUN)`);
271
+ tally.planning = true;
272
+ tally.plans = (tally.plans ?? 0) + 1;
273
+ try {
274
+ // The body is read only while a slot is held: a queued plan holds no
275
+ // lockfile text, and at most `planConcurrency` bodies exist at once.
276
+ return await this.planSlots.run(async () => {
277
+ const lockfile = support.parse(await request.readBody(), {
278
+ maxEntries: this.options.planMaxEntries ?? DEFAULT_PLAN_MAX_ENTRIES,
279
+ proxyRegistryUrl: `${this.options.proxyBase}${adapter.id}/`,
280
+ });
281
+ return this.verifyPlan(request, adapter, support, lockfile, auditVersions, context);
282
+ });
283
+ }
284
+ finally {
285
+ tally.planning = false;
286
+ }
287
+ }
288
+ async verifyPlan(request, adapter, support, lockfile, auditVersions, context) {
289
+ const entries = parseAllowlist(context.allowlist, this.options.adapters).get(adapter.id) ?? [];
290
+ const { minReleaseAgeDays } = resolvePolicy(context.policy);
291
+ const timeoutMs = this.options.planTimeoutMs ?? DEFAULT_PLAN_TIMEOUT_MS;
292
+ const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), request.signal]);
293
+ const store = this.options.store;
294
+ let result;
295
+ try {
296
+ result = await verifyLockfilePlan({
297
+ adapter,
298
+ support,
299
+ lockfile,
300
+ roots: entries,
301
+ cutoff: new Date(this.now().getTime() - minReleaseAgeDays * DAY_MS),
302
+ facts: {
303
+ getVersionFacts: (versions) => store.getVersionFacts(adapter.id, versions),
304
+ putVersionFacts: (facts) => store.putVersionFacts(adapter.id, facts),
305
+ },
306
+ upstream: this.versionUpstream,
307
+ streamUpstream: this.options.upstream,
308
+ audit: (versions, auditSignal) => auditVersions(adapter, versions, auditSignal),
309
+ hostAllowed: (url) => this.hostAllowed(adapter, url),
310
+ signal,
311
+ });
312
+ }
313
+ catch (error) {
314
+ if (!signal.aborted)
315
+ throw error;
316
+ // Every fact read so far is stored, so a retry resumes from there.
317
+ throw new RegistryError(503, "wardby_plan_incomplete", request.signal.aborted
318
+ ? "the client went away before the lockfile was verified"
319
+ : `the lockfile was not verified within ${Math.round(timeoutMs / 1000)} s (REGISTRY_PLAN_TIMEOUT_MS); what was verified is kept, so try again`);
320
+ }
321
+ await store.approveVersions(context.runId, adapter.id, result.approved);
322
+ // Only verdicts about the registry's name@version itself answer later
323
+ // downloads; a lockfile-dependent one (unreachable, an integrity or
324
+ // source the lockfile claims) must not block the same version reached
325
+ // another way, so those downloads take the usual path.
326
+ await store.refusePlanVersions(context.runId, adapter.id, result.refused
327
+ .filter((refusal) => refusal.intrinsic)
328
+ .map(({ name, version, code, reason, publishedAt }) => ({ name, version, code, reason, publishedAt })));
329
+ await this.recordPlanRefusals(context, adapter, result.refused);
330
+ return {
331
+ status: 200,
332
+ contentType: "application/json",
333
+ body: JSON.stringify({
334
+ approved: result.approved.length,
335
+ refused: result.refused.map(({ name, version, code, reason }) => ({ name, version, code, reason })),
336
+ }),
337
+ };
338
+ }
339
+ /** One refused row per plan refusal, within the run's refusal cap, and
340
+ * once per run however often the same lockfile is planned. */
341
+ async recordPlanRefusals(context, adapter, refused) {
342
+ const tally = this.tally(context);
343
+ tally.planRefusals ??= new Set();
344
+ for (const refusal of refused) {
345
+ const key = `${adapter.id}\0${refusal.name}@${refusal.version}\0${refusal.code}`;
346
+ if (tally.planRefusals.has(key))
347
+ continue;
348
+ tally.planRefusals.add(key);
349
+ await this.refuse(context, adapter, refusal.name.slice(0, MAX_RECORDED_NAME), refusal.code, {
350
+ version: refusal.version,
351
+ filename: PLAN_FETCH_FILENAME,
352
+ });
353
+ }
354
+ }
355
+ async handle(request) {
356
+ try {
357
+ return await this.dispatch(request);
358
+ }
359
+ catch (error) {
360
+ logRegistryFailure("handle", request.ecosystem, error);
361
+ if (error instanceof RegistryError)
362
+ return errorResponse(error);
363
+ return errorResponse(new RegistryError(502, "wardby_upstream_error", "the package registry request failed"));
364
+ }
365
+ }
366
+ async dispatch(request) {
367
+ const adapter = this.options.adapters.get(request.ecosystem);
368
+ if (!adapter)
369
+ throw new RegistryError(404, "wardby_registry_unknown", `no registry named "${request.ecosystem}"`);
370
+ const context = await this.options.store.findRunByRegistryTokenHash(capabilityHash(request.token), this.now());
371
+ if (!context)
372
+ throw new RegistryError(401, "invalid_capability", "the registry token is not valid for a live run");
373
+ let route;
374
+ try {
375
+ route = adapter.route(request.method, request.subpath, new Headers());
376
+ }
377
+ catch (error) {
378
+ // A malformed path (bad percent-encoding, an invalid package name) is
379
+ // the client's error, recorded like any other refusal.
380
+ await this.refuse(context, adapter, request.subpath.slice(0, MAX_RECORDED_NAME), "wardby_bad_request");
381
+ throw error instanceof RegistryError
382
+ ? error
383
+ : new RegistryError(400, "wardby_bad_request", "malformed registry path");
384
+ }
385
+ if (!route)
386
+ throw new RegistryError(404, "wardby_route_unknown", "unknown registry path");
387
+ const name = adapter.normalizeName(route.name);
388
+ if (route.kind === "download") {
389
+ // A version the run's lockfile plan approved is served as it stands:
390
+ // no allowlist lookup, no graph walk, integrity checked against the
391
+ // approved value.
392
+ const approved = await this.options.store.findApprovedVersion(context.runId, adapter.id, name, route.version);
393
+ const integrity = approved ? parseSri(approved.integrity) : null;
394
+ if (approved && integrity) {
395
+ const file = {
396
+ filename: route.filename,
397
+ version: route.version,
398
+ upstreamUrl: approved.downloadUrl,
399
+ integrity,
400
+ sizeBytes: null,
401
+ allowed: true,
402
+ publishedAt: null,
403
+ };
404
+ if (!this.hostAllowed(adapter, file.upstreamUrl)) {
405
+ await this.refuse(context, adapter, name, "wardby_upstream_host_not_allowed", file);
406
+ throw new RegistryError(502, "wardby_upstream_host_not_allowed", `"${file.filename}" is hosted outside the ${adapter.id} registry's upstreams`);
407
+ }
408
+ if (request.method === "HEAD")
409
+ return { status: 200, contentType: contentTypeOf(route), body: "" };
410
+ return this.download(adapter, context, name, route, file, request.signal);
411
+ }
412
+ // A version the plan definitively refused is answered from the plan,
413
+ // with its code and reason: no metadata, no graph walk.
414
+ const refused = approved
415
+ ? null
416
+ : await this.options.store.findPlanRefusal(context.runId, adapter.id, name, route.version);
417
+ // A too-new refusal lapses once the version is old enough: the
418
+ // release-age cutoff moves with time.
419
+ const cutoff = this.now().getTime() - resolvePolicy(context.policy).minReleaseAgeDays * DAY_MS;
420
+ if (refused && (refused.publishedAt === null || refused.publishedAt.getTime() > cutoff)) {
421
+ const tally = this.tally(context);
422
+ tally.planAnswered ??= new Set();
423
+ const key = `${adapter.id}\0${name}@${route.version}\0${refused.code}`;
424
+ if (!tally.planAnswered.has(key)) {
425
+ tally.planAnswered.add(key);
426
+ await this.refuse(context, adapter, name, refused.code, { version: route.version, filename: route.filename });
427
+ }
428
+ throw new RegistryError(403, refused.code, `"${name}" ${route.version} was refused by this run's lockfile verification: ${refused.reason}`);
429
+ }
430
+ }
431
+ const root = await this.authorize(adapter, context, name);
432
+ let meta;
433
+ try {
434
+ // Only a metadata request renders a document; a download, the version
435
+ // filter and the graph walk need only the trimmed versions.
436
+ meta = await this.metadata(adapter, name, route.kind === "metadata");
437
+ }
438
+ catch (error) {
439
+ // An oversized document is a refusal of this package, not a transient
440
+ // upstream failure, so it gets a row like every other refusal.
441
+ if (error instanceof RegistryError && error.code === "wardby_metadata_too_large") {
442
+ await this.refuse(context, adapter, name, "wardby_metadata_too_large");
443
+ }
444
+ throw error;
445
+ }
446
+ let keep;
447
+ let keptFiles;
448
+ try {
449
+ ({ keep, keptFiles } = await this.keptVersions(adapter, context, meta, root));
450
+ }
451
+ catch (error) {
452
+ // Every refusal gets a row, including one the run never chose: the
453
+ // audit being unreachable (fail-closed) still stops this request.
454
+ if (error instanceof RegistryError && error.code === "wardby_audit_unavailable") {
455
+ await this.refuse(context, adapter, name, "wardby_audit_unavailable");
456
+ }
457
+ throw error;
458
+ }
459
+ if (route.kind === "metadata") {
460
+ if (keep.size === 0) {
461
+ await this.refuse(context, adapter, name, "wardby_version_filtered");
462
+ throw new RegistryError(404, "wardby_version_filtered", `every matching version of "${name}" is outside the allowlisted range, newer than the release-age limit, or has a high-severity advisory`);
463
+ }
464
+ await this.options.store.addAllowances(context.runId, adapter.id, keptDependencies(adapter, meta, keep));
465
+ const document = adapter.renderMetadata(meta, keep, keptFiles, `${this.options.proxyBase}${adapter.id}/`);
466
+ return { status: 200, contentType: document.contentType, body: document.body };
467
+ }
468
+ const file = route.kind === "download"
469
+ ? adapter.resolveDownload(route, meta)
470
+ : (adapter.resolveFileMetadata?.(route, meta) ?? null);
471
+ // The release age applies per file: a file newer than the cutoff is
472
+ // refused exactly like a filtered version, even in a kept release. A
473
+ // PEP 658 metadata file is judged by the wheel it describes
474
+ // (route.filename), since keptFiles holds wheel filenames.
475
+ const agedFile = route.kind === "file-metadata" ? route.filename : file?.filename;
476
+ if (!file || !keep.has(file.version) || !agedFile || !keptFiles.has(agedFile)) {
477
+ await this.refuse(context, adapter, name, "wardby_version_filtered", file ?? undefined);
478
+ const versionSuffix = route.kind === "download" ? ` ${route.version}` : "";
479
+ throw new RegistryError(404, "wardby_version_filtered", `"${name}"${versionSuffix} is not available to this run`);
480
+ }
481
+ if (!file.allowed) {
482
+ await this.refuse(context, adapter, name, "wardby_file_not_allowed", file);
483
+ throw new RegistryError(403, "wardby_file_not_allowed", `"${file.filename}" is a source distribution; only wheels are allowed`);
484
+ }
485
+ // Each adapter may reach only its own upstream hosts, not every host
486
+ // the pinned fetch allows for the registry as a whole.
487
+ if (!this.hostAllowed(adapter, file.upstreamUrl)) {
488
+ await this.refuse(context, adapter, name, "wardby_upstream_host_not_allowed", file);
489
+ throw new RegistryError(502, "wardby_upstream_host_not_allowed", `"${file.filename}" is hosted outside the ${adapter.id} registry's upstreams`);
490
+ }
491
+ // HEAD answers from metadata alone: no upstream download, no
492
+ // reservation, nothing recorded as served.
493
+ if (request.method === "HEAD")
494
+ return { status: 200, contentType: contentTypeOf(route), body: "" };
495
+ return this.download(adapter, context, name, route, file, request.signal);
496
+ }
497
+ hostAllowed(adapter, url) {
498
+ try {
499
+ const parsed = new URL(url);
500
+ return parsed.protocol === "https:" && adapter.upstreamHosts.includes(parsed.hostname.toLowerCase());
501
+ }
502
+ catch {
503
+ return false;
504
+ }
505
+ }
506
+ /** Served usage for the run, seeded once from the store and then kept
507
+ * current in-process as downloads complete. */
508
+ async servedUsage(context) {
509
+ const tally = this.tally(context);
510
+ if (!tally.served) {
511
+ const recorded = await this.options.store.usage(context.runId);
512
+ tally.served ??= { files: recorded.files, bytes: recorded.bytes };
513
+ }
514
+ return tally.served;
515
+ }
516
+ async authorize(adapter, context, name) {
517
+ const entries = parseAllowlist(context.allowlist, this.options.adapters).get(adapter.id) ?? [];
518
+ const root = matchRoot(entries, name);
519
+ if (root || (await this.options.store.isAllowedDependency(context.runId, adapter.id, name)))
520
+ return root;
521
+ // Not a root and not yet an allowance: the name may still be in the
522
+ // approved graph, unrecorded only because the client never asked for
523
+ // its parent's metadata (a lockfile install requests tarballs
524
+ // directly). Resolve the graph before refusing.
525
+ const graph = await this.resolveInGraph(adapter, context, entries, name);
526
+ if (graph === "found")
527
+ return undefined;
528
+ if (typeof graph === "object" && "cutShort" in graph) {
529
+ if (graph.cutShort === "limit") {
530
+ // The bound is permanent for the rest of the run, so a retry can
531
+ // never succeed: answer with a definitive 4xx (npm does not retry
532
+ // it) rather than a 503 that a client or agent would retry forever.
533
+ // Still not "not allowed": the name was not proven absent.
534
+ await this.refuse(context, adapter, name, "wardby_graph_limit");
535
+ throw new RegistryError(403, "wardby_graph_limit", `"${name}" was not reached before this agent's ${adapter.id} dependency graph walk hit its package limit (REGISTRY_MAX_GRAPH_PACKAGES) for this run; retrying will not help: allowlist the package directly, or ask the operator to raise the limit`);
536
+ }
537
+ // Not proven absent, so never "not allowed": a timed-out walk resumes
538
+ // where it stopped on the next miss, so a retry (npm retries a 5xx on
539
+ // its own) can find the name.
540
+ await this.refuse(context, adapter, name, "wardby_graph_incomplete");
541
+ throw new RegistryError(503, "wardby_graph_incomplete", `"${name}" was not reached before this agent's ${adapter.id} dependency graph walk was cut short by its time limit (REGISTRY_GRAPH_TIMEOUT_MS); the walk continues where it stopped: try again`);
542
+ }
543
+ if (typeof graph === "object") {
544
+ // Some part of the graph could not be read, so whether the name is in
545
+ // it is unknown: say so honestly rather than "not allowed".
546
+ const [status, code, source] = graph.unavailable === "audit"
547
+ ? [503, "wardby_audit_unavailable", "the OSV vulnerability audit"]
548
+ : [502, "wardby_upstream_error", `the ${adapter.id} registry`];
549
+ await this.refuse(context, adapter, name, code);
550
+ throw new RegistryError(status, code, `"${name}" could not be checked against this agent's approved ${adapter.id} dependency graph because ${source} was unavailable while resolving it; try again`);
551
+ }
552
+ await this.refuse(context, adapter, name, "wardby_package_not_allowed");
553
+ const hint = entries.length === 0
554
+ ? `this agent has no ${adapter.id} package allowlist`
555
+ : `"${name}" is not on this agent's ${adapter.id} package allowlist or in its dependency graph`;
556
+ throw new RegistryError(403, "wardby_package_not_allowed", hint);
557
+ }
558
+ /** Whether `name` is in the run's approved graph for this ecosystem:
559
+ * the allowlist's exact roots plus the dependencies of every kept
560
+ * version (the same filter the metadata path applies: root range,
561
+ * release age, advisories), transitively. Each newly found name is
562
+ * recorded as an allowance as the walk goes, exactly as if its
563
+ * parent's metadata had been served. A scoped wildcard root cannot be
564
+ * enumerated, so it is never a starting point. */
565
+ async resolveInGraph(adapter, context, entries, name) {
566
+ if (!adapter.dependenciesInMetadata)
567
+ return "absent";
568
+ const tally = this.tally(context);
569
+ tally.graphs ??= new Map();
570
+ let walk = tally.graphs.get(adapter.id);
571
+ if (!walk) {
572
+ const roots = [...new Set(entries.filter((entry) => !entry.wildcard).map((entry) => entry.name))];
573
+ const edges = roots.map((root) => ({ name: root, range: "*" }));
574
+ walk = {
575
+ found: new Set(roots),
576
+ queue: edges,
577
+ seen: new Set(edges.map(edgeKey)),
578
+ expandedVersions: new Map(),
579
+ exhausted: false,
580
+ attempts: new Map(),
581
+ failed: new Map(),
582
+ };
583
+ tally.graphs.set(adapter.id, walk);
584
+ }
585
+ const current = walk;
586
+ let started = false;
587
+ for (;;) {
588
+ if (current.found.has(name))
589
+ return "found";
590
+ if (current.exhausted)
591
+ return { cutShort: "limit" };
592
+ // Single-flight: a miss while a walk is running waits for it rather
593
+ // than starting a second one, then re-checks (that walk may have
594
+ // stopped on finding a different name). Checked before the queue: a
595
+ // running walk has taken its current batch off the queue, so an
596
+ // empty queue alone does not mean the graph is complete. Each miss
597
+ // starts at most one walk of its own, so it spends at most one
598
+ // round of retries on failed nodes.
599
+ if (!current.running) {
600
+ const retriable = [...current.failed.keys()].some((key) => (current.attempts.get(key) ?? 0) < MAX_GRAPH_NODE_ATTEMPTS);
601
+ if (started || (current.queue.length === 0 && !retriable))
602
+ return this.graphMiss(current);
603
+ started = true;
604
+ current.running = this.walkGraph(adapter, context, entries, current, name).finally(() => {
605
+ current.running = undefined;
606
+ });
607
+ }
608
+ const end = await current.running;
609
+ if (end === "timeout" && !current.found.has(name))
610
+ return { cutShort: "timeout" };
611
+ }
612
+ }
613
+ /** The answer for a name the walk did not find: unavailable while any
614
+ * node could not be expanded (the audit, if any failure was the
615
+ * audit's), otherwise absent. */
616
+ graphMiss(walk) {
617
+ if (walk.failed.size === 0)
618
+ return "absent";
619
+ return { unavailable: [...walk.failed.values()].some((entry) => entry.cause === "audit") ? "audit" : "upstream" };
620
+ }
621
+ async walkGraph(adapter, context, entries, walk, target) {
622
+ const maxPackages = this.options.maxGraphPackages ?? DEFAULT_MAX_GRAPH_PACKAGES;
623
+ const deadline = this.now().getTime() + (this.options.graphTimeoutMs ?? DEFAULT_GRAPH_TIMEOUT_MS);
624
+ // Parked failures with attempts left go first: a later miss is this
625
+ // walk's retry of them.
626
+ const retry = [...walk.failed.entries()].filter(([key]) => (walk.attempts.get(key) ?? 0) < MAX_GRAPH_NODE_ATTEMPTS);
627
+ for (const [key] of retry)
628
+ walk.failed.delete(key);
629
+ walk.queue.unshift(...retry.map(([, entry]) => entry.edge));
630
+ const attemptsThisWalk = new Map();
631
+ // Per-walk memo: a walk lasts at most the graph timeout, well inside
632
+ // the metadata and audit cache lifetimes, so this changes no decision.
633
+ // A failed computation is not memoized, so its retry really retries.
634
+ const kept = new Map();
635
+ while (walk.queue.length > 0) {
636
+ if (walk.found.has(target))
637
+ return "found";
638
+ if (walk.released)
639
+ return "timeout";
640
+ const remaining = deadline - this.now().getTime();
641
+ if (remaining <= 0)
642
+ return "timeout";
643
+ const room = maxPackages - walk.expandedVersions.size;
644
+ if (room <= 0) {
645
+ walk.exhausted = true;
646
+ return "limit";
647
+ }
648
+ // Each edge adds at most one new package, so a batch no larger than
649
+ // the room left can never overshoot the bound.
650
+ const batch = walk.queue.splice(0, Math.min(GRAPH_WALK_CONCURRENCY, room));
651
+ const expansions = Promise.all(batch.map((edge) => this.graphNode(adapter, context, entries, edge, kept)));
652
+ let timer;
653
+ const timedOut = new Promise((resolve) => {
654
+ timer = setTimeout(() => resolve("timeout"), remaining);
655
+ });
656
+ const results = await Promise.race([expansions, timedOut]).finally(() => clearTimeout(timer));
657
+ if (results === "timeout") {
658
+ // Not expanded: put the batch back so a later miss retries it (its
659
+ // metadata is likely cached by then).
660
+ walk.queue.unshift(...batch);
661
+ return "timeout";
662
+ }
663
+ const expanded = [];
664
+ batch.forEach((edge, index) => {
665
+ const result = results[index];
666
+ if (result.ok) {
667
+ expanded.push({ edge, selected: result.selected });
668
+ return;
669
+ }
670
+ // A transient failure is never counted as expanded: the edge is
671
+ // retried (once more in this walk, then by later misses) instead
672
+ // of silently closing its whole subtree for the run.
673
+ const key = edgeKey(edge);
674
+ walk.attempts.set(key, (walk.attempts.get(key) ?? 0) + 1);
675
+ attemptsThisWalk.set(key, (attemptsThisWalk.get(key) ?? 0) + 1);
676
+ if ((attemptsThisWalk.get(key) ?? 0) < GRAPH_NODE_ATTEMPTS_PER_WALK &&
677
+ (walk.attempts.get(key) ?? 0) < MAX_GRAPH_NODE_ATTEMPTS)
678
+ walk.queue.push(edge);
679
+ else
680
+ walk.failed.set(key, { edge, cause: result.cause });
681
+ });
682
+ // A package joins the graph (and gets its allowance) only when some
683
+ // kept version of it satisfies a range that reached it. Recorded
684
+ // before it counts as found, so `found` never holds a name the store
685
+ // would refuse.
686
+ const discovered = [
687
+ ...new Set(expanded
688
+ .filter(({ edge, selected }) => selected.length > 0 && !walk.found.has(edge.name))
689
+ .map(({ edge }) => edge.name)),
690
+ ];
691
+ try {
692
+ if (discovered.length > 0)
693
+ await this.options.store.addAllowances(context.runId, adapter.id, discovered);
694
+ }
695
+ catch (error) {
696
+ // The store write failed: put the expanded edges back so a later
697
+ // miss re-expands them, rather than leaving the graph silently short.
698
+ walk.queue.unshift(...expanded.map(({ edge }) => edge));
699
+ throw error;
700
+ }
701
+ for (const name of discovered)
702
+ walk.found.add(name);
703
+ for (const { edge, selected } of expanded) {
704
+ let versions = walk.expandedVersions.get(edge.name);
705
+ if (!versions)
706
+ walk.expandedVersions.set(edge.name, (versions = new Set()));
707
+ for (const { version, dependencies } of selected) {
708
+ if (versions.has(version))
709
+ continue;
710
+ versions.add(version);
711
+ for (const dependency of dependencies) {
712
+ const key = edgeKey(dependency);
713
+ if (walk.seen.has(key))
714
+ continue;
715
+ walk.seen.add(key);
716
+ walk.queue.push(dependency);
717
+ }
718
+ }
719
+ }
720
+ }
721
+ return walk.found.has(target) ? "found" : "complete";
722
+ }
723
+ /** The kept versions of one package inside the edge's range, and each
724
+ * one's declared dependencies, through the same metadata cache and
725
+ * `keptVersions` filter the metadata path uses (a root's allowlist range
726
+ * applies there), within the process-wide walk concurrency cap. `"*"`
727
+ * selects every kept version; a range no kept version satisfies selects
728
+ * none. A package npm doesn't have, or whose metadata is too large,
729
+ * definitively selects nothing; any other failure (upstream error or
730
+ * timeout, audit unavailable) is transient and reported for retry. */
731
+ graphNode(adapter, context, entries, edge, memo) {
732
+ return this.walkSlots.run(async () => {
733
+ try {
734
+ let load = memo.get(edge.name);
735
+ if (!load) {
736
+ load = (async () => {
737
+ const meta = await this.metadata(adapter, edge.name, false);
738
+ const { keep } = await this.keptVersions(adapter, context, meta, matchRoot(entries, edge.name));
739
+ return new Map([...keep].map((version) => [version, versionDependencies(adapter, meta, version)]));
740
+ })();
741
+ memo.set(edge.name, load);
742
+ load.catch(() => memo.delete(edge.name));
743
+ }
744
+ const keep = await load;
745
+ const inRange = (version) => {
746
+ if (edge.range === "*")
747
+ return true;
748
+ try {
749
+ return adapter.satisfies(version, edge.range);
750
+ }
751
+ catch {
752
+ return false;
753
+ }
754
+ };
755
+ const selected = [];
756
+ for (const [version, dependencies] of keep)
757
+ if (inRange(version))
758
+ selected.push({ version, dependencies });
759
+ return { ok: true, selected };
760
+ }
761
+ catch (error) {
762
+ if (error instanceof RegistryError && DEFINITIVE_NODE_ERRORS.has(error.code))
763
+ return { ok: true, selected: [] };
764
+ const audit = error instanceof RegistryError && error.code === "wardby_audit_unavailable";
765
+ return { ok: false, cause: audit ? "audit" : "upstream" };
766
+ }
767
+ });
768
+ }
769
+ /** A package's metadata, cached. `render` asks for the form
770
+ * `renderMetadata` can serve; a cached entry fetched without it (by the
771
+ * graph walk or a download) is fetched again with it, and replaces it. */
772
+ async metadata(adapter, name, render) {
773
+ const key = `${adapter.id}:${name}`;
774
+ const now = this.now().getTime();
775
+ const cached = this.metadataCache.get(key);
776
+ if (cached) {
777
+ this.dropCached(key);
778
+ if (cached.expires > now) {
779
+ this.putCached(key, cached); // most recently used
780
+ if (!render || cached.meta.raw !== undefined)
781
+ return cached.meta;
782
+ }
783
+ }
784
+ // Single-flight per need: a render load also serves a walk that asks
785
+ // meanwhile, but not the other way round.
786
+ const renderLoad = this.metadataLoads.get(`${key}\0render`);
787
+ if (renderLoad)
788
+ return renderLoad;
789
+ const loadKey = render ? `${key}\0render` : key;
790
+ const pending = this.metadataLoads.get(loadKey);
791
+ if (pending)
792
+ return pending;
793
+ const load = adapter
794
+ .fetchMetadata(name, this.metadataUpstream, { render })
795
+ .then((meta) => {
796
+ this.cacheMetadata(key, meta);
797
+ return meta;
798
+ })
799
+ .finally(() => this.metadataLoads.delete(loadKey));
800
+ this.metadataLoads.set(loadKey, load);
801
+ return load;
802
+ }
803
+ dropCached(key) {
804
+ const entry = this.metadataCache.get(key);
805
+ if (!entry)
806
+ return;
807
+ this.metadataCache.delete(key);
808
+ this.metadataCacheTotal -= entry.bytes;
809
+ }
810
+ putCached(key, entry) {
811
+ this.metadataCache.set(key, entry);
812
+ this.metadataCacheTotal += entry.bytes;
813
+ }
814
+ /** Caches `meta`, least recently used evicted first, within both the entry
815
+ * and the byte budget. An entry larger than the whole byte budget is not
816
+ * cached at all. A live entry that can render is not replaced by one that
817
+ * cannot. */
818
+ cacheMetadata(key, meta) {
819
+ const now = this.now().getTime();
820
+ for (const [cachedKey, entry] of this.metadataCache) {
821
+ if (entry.expires <= now)
822
+ this.dropCached(cachedKey);
823
+ }
824
+ const existing = this.metadataCache.get(key);
825
+ if (existing && meta.raw === undefined && existing.meta.raw !== undefined)
826
+ return;
827
+ this.dropCached(key);
828
+ const bytes = metadataBytes(meta);
829
+ if (bytes > this.metadataCacheBytes)
830
+ return;
831
+ while (this.metadataCache.size > 0 &&
832
+ (this.metadataCache.size >= this.metadataCacheEntries ||
833
+ this.metadataCacheTotal + bytes > this.metadataCacheBytes)) {
834
+ this.dropCached(this.metadataCache.keys().next().value);
835
+ }
836
+ this.putCached(key, { expires: now + this.metadataTtlMs, bytes, meta });
837
+ }
838
+ async keptVersions(adapter, context, meta, root) {
839
+ const { minReleaseAgeDays } = resolvePolicy(context.policy);
840
+ const cutoff = this.now().getTime() - minReleaseAgeDays * DAY_MS;
841
+ const advisories = await this.options.audit.audit(adapter, meta.name);
842
+ const keep = new Set();
843
+ const keptFiles = new Set();
844
+ for (const info of meta.versions.values()) {
845
+ if (root?.range && !adapter.satisfies(info.version, root.range))
846
+ continue;
847
+ // Per-file release age: a file with an unknown or too-recent publish
848
+ // time is never listed or served. A version survives if any of its
849
+ // files does.
850
+ const oldEnough = info.files.filter((file) => file.publishedAt && file.publishedAt.getTime() <= cutoff);
851
+ if (oldEnough.length === 0)
852
+ continue;
853
+ if (advisories.withheld(info.version).length > 0)
854
+ continue;
855
+ keep.add(info.version);
856
+ for (const file of oldEnough)
857
+ keptFiles.add(file.filename);
858
+ }
859
+ return { keep, keptFiles };
860
+ }
861
+ tally(context) {
862
+ const now = this.now().getTime();
863
+ for (const [runId, stale] of this.tallies)
864
+ if (stale.deadline <= now)
865
+ this.releaseRun(runId);
866
+ let tally = this.tallies.get(context.runId);
867
+ if (!tally) {
868
+ tally = { deadline: context.deadlineAt.getTime() };
869
+ this.tallies.set(context.runId, tally);
870
+ this.scheduleRelease(context.runId, tally);
871
+ }
872
+ return tally;
873
+ }
874
+ /** Releases a run's in-process state when its deadline passes, whether or
875
+ * not any other request ever arrives: the timer does not keep the process
876
+ * alive. */
877
+ scheduleRelease(runId, tally) {
878
+ const delay = Math.min(Math.max(0, tally.deadline - this.now().getTime()), MAX_TIMER_MS);
879
+ tally.timer = setTimeout(() => {
880
+ if (this.tallies.get(runId) !== tally)
881
+ return;
882
+ if (tally.deadline <= this.now().getTime())
883
+ this.releaseRun(runId);
884
+ else
885
+ this.scheduleRelease(runId, tally);
886
+ }, delay);
887
+ tally.timer.unref?.();
888
+ }
889
+ /** Drops a run's tally and graph walks, and stops any walk still running. */
890
+ releaseRun(runId) {
891
+ const tally = this.tallies.get(runId);
892
+ if (!tally)
893
+ return;
894
+ this.tallies.delete(runId);
895
+ clearTimeout(tally.timer);
896
+ for (const walk of tally.graphs?.values() ?? [])
897
+ walk.released = true;
898
+ tally.graphs?.clear();
899
+ }
900
+ /** Releases every run's in-process state (timers included), e.g. when the
901
+ * server shuts down. */
902
+ close() {
903
+ for (const runId of [...this.tallies.keys()])
904
+ this.releaseRun(runId);
905
+ }
906
+ /** Records a refused row, at most `maxRefusalRecords` per run so a worker
907
+ * hammering refused names cannot grow the table (or get_run and the PR
908
+ * body) without bound. Callers still return the refusal's error. */
909
+ async refuse(context, adapter, name, reason, file) {
910
+ const tally = this.tally(context);
911
+ if (tally.refused === undefined) {
912
+ const recorded = await this.options.store.refusalCount(context.runId);
913
+ tally.refused ??= recorded; // a concurrent first refusal may have seeded it already
914
+ }
915
+ if (tally.refused >= (this.options.limits.maxRefusalRecords ?? DEFAULT_MAX_REFUSAL_RECORDS))
916
+ return;
917
+ tally.refused += 1;
918
+ await this.options.store.recordFetch({
919
+ runId: context.runId,
920
+ ecosystem: adapter.id,
921
+ name,
922
+ version: file?.version,
923
+ filename: file?.filename,
924
+ outcome: "refused",
925
+ reason,
926
+ });
927
+ }
928
+ inFlightUsage(runId) {
929
+ return this.inFlight.get(runId) ?? { files: 0, bytes: 0 };
930
+ }
931
+ /** Reserve one file slot for a download that is about to start, and its
932
+ * declared size when known. An unknown size (`null`) reserves no bytes
933
+ * up front; those are added incrementally as they stream, via
934
+ * `trackStreamedBytes`. */
935
+ reserveDownload(runId, sizeBytes) {
936
+ const slot = this.inFlight.get(runId) ?? { files: 0, bytes: 0 };
937
+ slot.files += 1;
938
+ if (sizeBytes !== null)
939
+ slot.bytes += sizeBytes;
940
+ this.inFlight.set(runId, slot);
941
+ }
942
+ /** Only called for downloads whose declared size was unknown at
943
+ * reservation time; adds each chunk's bytes to the in-flight total as it
944
+ * arrives, so the shared budget check sees them immediately. */
945
+ trackStreamedBytes(runId, delta) {
946
+ const slot = this.inFlight.get(runId);
947
+ if (slot)
948
+ slot.bytes += delta;
949
+ }
950
+ /** Release a download's reservation on every exit path (served, failed,
951
+ * cancelled, or refused before it ever reserved streaming). Undoes
952
+ * exactly what was reserved: the declared size when known, or the bytes
953
+ * actually streamed when it was not. */
954
+ releaseDownload(runId, sizeBytes, streamedBytes) {
955
+ const slot = this.inFlight.get(runId);
956
+ if (!slot)
957
+ return;
958
+ slot.files = Math.max(0, slot.files - 1);
959
+ slot.bytes = Math.max(0, slot.bytes - (sizeBytes ?? streamedBytes));
960
+ if (slot.files === 0 && slot.bytes === 0)
961
+ this.inFlight.delete(runId);
962
+ else
963
+ this.inFlight.set(runId, slot);
964
+ }
965
+ async download(adapter, context, name, route, file, signal) {
966
+ const { limits, store } = this.options;
967
+ const runId = context.runId;
968
+ const served = await this.servedUsage(context);
969
+ const flight = this.inFlightUsage(runId);
970
+ if (served.files + flight.files >= limits.maxFiles) {
971
+ await this.refuse(context, adapter, name, "wardby_package_limit", file);
972
+ throw new RegistryError(429, "wardby_package_limit", "this run has reached its package file limit");
973
+ }
974
+ if (file.sizeBytes !== null && file.sizeBytes > limits.maxFileBytes) {
975
+ await this.refuse(context, adapter, name, "wardby_package_too_large", file);
976
+ throw new RegistryError(413, "wardby_package_too_large", `"${file.filename}" exceeds the per-file size limit`);
977
+ }
978
+ // Reserve this download's slot before any further await, so a
979
+ // concurrent download for the same run (e.g. npm installing several
980
+ // dependencies in parallel) sees it immediately rather than racing past
981
+ // the same `usage()` snapshot. Released on every exit path below.
982
+ this.reserveDownload(runId, file.sizeBytes);
983
+ let bytes = 0;
984
+ let released = false;
985
+ const release = () => {
986
+ if (released)
987
+ return;
988
+ released = true;
989
+ this.releaseDownload(runId, file.sizeBytes, bytes);
990
+ };
991
+ let upstream;
992
+ try {
993
+ upstream = await this.options.upstream(file.upstreamUrl, { signal });
994
+ }
995
+ catch (error) {
996
+ release();
997
+ throw error;
998
+ }
999
+ if (!upstream.ok || !upstream.body) {
1000
+ release();
1001
+ throw new RegistryError(502, "wardby_upstream_error", `the registry returned ${upstream.status}`);
1002
+ }
1003
+ const hash = file.integrity ? createHash(file.integrity.algorithm) : null;
1004
+ const buffered = [];
1005
+ let idle;
1006
+ // Guards the race the controller flagged: the idle timer's fail() and a
1007
+ // pending reader.read() can both try to act on the same controller. Once
1008
+ // either the stream has failed or it has recorded a served/refused row,
1009
+ // `settled` is true and every later exit path becomes a no-op, so at
1010
+ // most one refused/served row is ever recorded and enqueue/close/error
1011
+ // are never called on an already-settled controller.
1012
+ let settled = false;
1013
+ // Cast: undici's ReadableStream defaults its element type to `any`, and
1014
+ // `Response.body`'s type carries that through. The bytes are always
1015
+ // Uint8Array chunks in practice, so name the type explicitly rather than
1016
+ // let `any` leak into every read below.
1017
+ const reader = upstream.body.getReader();
1018
+ const clearIdle = () => {
1019
+ clearTimeout(idle);
1020
+ idle = undefined;
1021
+ };
1022
+ const fail = async (controller, reason) => {
1023
+ if (settled)
1024
+ return;
1025
+ settled = true;
1026
+ clearIdle();
1027
+ release();
1028
+ await reader.cancel().catch(() => undefined);
1029
+ try {
1030
+ await this.refuse(context, adapter, name, reason, file);
1031
+ }
1032
+ catch {
1033
+ // A failed record must never crash the process (this can run from a
1034
+ // fire-and-forget `void fail(...)` off the idle timer, where an
1035
+ // unhandled rejection would terminate the whole proxy) or block the
1036
+ // client from seeing the stream error below.
1037
+ }
1038
+ finally {
1039
+ controller.error(new RegistryError(502, reason, `download of "${file.filename}" stopped: ${reason}`));
1040
+ }
1041
+ };
1042
+ // The client went away (its request signal aborted the upstream read):
1043
+ // release the reservation and record nothing, since nothing was served
1044
+ // and nothing was refused.
1045
+ const abandon = (controller) => {
1046
+ if (settled)
1047
+ return;
1048
+ settled = true;
1049
+ clearIdle();
1050
+ release();
1051
+ controller.error(new RegistryError(499, "client_disconnected", "the client disconnected"));
1052
+ };
1053
+ const stream = new ReadableStream({
1054
+ pull: async (controller) => {
1055
+ if (settled)
1056
+ return;
1057
+ clearIdle();
1058
+ idle = setTimeout(() => void fail(controller, "wardby_download_idle"), limits.idleTimeoutMs);
1059
+ const outcome = await reader.read().then((result) => result.done ? { ok: true, done: true } : { ok: true, done: false, value: result.value }, () => ({ ok: false }));
1060
+ // The idle timer may have fired and already failed the stream while
1061
+ // this read was pending (or canceled the reader, causing the
1062
+ // rejection above); a late resolution must never enqueue, close, or
1063
+ // record on top of that already-settled outcome.
1064
+ if (settled)
1065
+ return;
1066
+ clearIdle();
1067
+ if (!outcome.ok)
1068
+ return signal.aborted ? abandon(controller) : fail(controller, "wardby_upstream_error");
1069
+ if (outcome.done) {
1070
+ if (hash && hash.digest("hex") !== file.integrity.hex)
1071
+ return fail(controller, "wardby_integrity_mismatch");
1072
+ settled = true;
1073
+ // Persist the served record (and the dependency allowances it
1074
+ // grows) BEFORE releasing the in-flight reservation. Releasing
1075
+ // first would open a window, between the release and the row
1076
+ // actually landing in the store, where neither `inFlight` nor
1077
+ // store.usage() counts this file — a concurrent request could be
1078
+ // admitted past maxFiles/maxTotalBytes in that window, reopening
1079
+ // the race the reservation exists to close. `release()` still
1080
+ // runs unconditionally (finally), so a failed record never leaks
1081
+ // the reservation; it errors the controller instead of closing it,
1082
+ // the same way every other failure path here does.
1083
+ try {
1084
+ await store.recordFetch({
1085
+ runId: context.runId,
1086
+ ecosystem: adapter.id,
1087
+ name,
1088
+ version: file.version,
1089
+ filename: file.filename,
1090
+ integrity: file.integrity ? `${file.integrity.algorithm}:${file.integrity.hex}` : undefined,
1091
+ sizeBytes: bytes,
1092
+ outcome: "served",
1093
+ });
1094
+ // Counted before release() drops the reservation, so no window
1095
+ // exists where neither the tally nor inFlight holds this file.
1096
+ served.files += 1;
1097
+ served.bytes += bytes;
1098
+ if (adapter.dependenciesFromFile && buffered.length > 0) {
1099
+ const body = Buffer.concat(buffered);
1100
+ const names = await adapter.dependenciesFromFile(route, body).catch(() => []);
1101
+ await store.addAllowances(context.runId, adapter.id, names.map((dependency) => adapter.normalizeName(dependency)));
1102
+ }
1103
+ controller.close();
1104
+ }
1105
+ catch (error) {
1106
+ controller.error(error instanceof RegistryError
1107
+ ? error
1108
+ : new RegistryError(502, "wardby_upstream_error", `failed to record the completed download of "${file.filename}"`));
1109
+ }
1110
+ finally {
1111
+ release();
1112
+ }
1113
+ return;
1114
+ }
1115
+ const { value } = outcome;
1116
+ bytes += value.byteLength;
1117
+ if (file.sizeBytes === null)
1118
+ this.trackStreamedBytes(runId, value.byteLength);
1119
+ if (bytes > limits.maxFileBytes)
1120
+ return fail(controller, "wardby_package_too_large");
1121
+ if (served.bytes + this.inFlightUsage(runId).bytes > limits.maxTotalBytes)
1122
+ return fail(controller, "wardby_package_limit");
1123
+ hash?.update(value);
1124
+ if (adapter.dependenciesFromFile && bytes <= DEPENDENCY_BUFFER_LIMIT)
1125
+ buffered.push(value);
1126
+ controller.enqueue(value);
1127
+ },
1128
+ cancel: async () => {
1129
+ settled = true;
1130
+ clearIdle();
1131
+ release();
1132
+ await reader.cancel().catch(() => undefined);
1133
+ },
1134
+ });
1135
+ return { status: 200, contentType: contentTypeOf(route), stream };
1136
+ }
1137
+ }