@wardby/cli 0.5.3 → 0.7.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 (763) hide show
  1. package/.env.example +33 -0
  2. package/README.md +25 -14
  3. package/deploy/local/docker-compose.quickstart-coding.yml +13 -0
  4. package/dist/claude-coding-worker/context.d.ts +22 -0
  5. package/dist/claude-coding-worker/context.js +92 -0
  6. package/dist/claude-coding-worker/driver.d.ts +27 -3
  7. package/dist/claude-coding-worker/driver.js +161 -20
  8. package/dist/claude-coding-worker/main.js +34 -6
  9. package/dist/claude-coding-worker/sdk.js +25 -5
  10. package/dist/claude-coding-worker/tool-socket.d.ts +27 -0
  11. package/dist/claude-coding-worker/tool-socket.js +111 -0
  12. package/dist/claude-coding-worker/transcript.d.ts +18 -0
  13. package/dist/claude-coding-worker/transcript.js +77 -0
  14. package/dist/cli-help.d.ts +1 -1
  15. package/dist/cli-help.js +6 -2
  16. package/dist/cli-native-mode.d.ts +9 -0
  17. package/dist/cli-native-mode.js +20 -0
  18. package/dist/cli.d.ts +3 -1
  19. package/dist/cli.js +161 -11
  20. package/dist/coding/claude-context.d.ts +45 -0
  21. package/dist/coding/claude-context.js +434 -0
  22. package/dist/coding/continuation-wording.d.ts +35 -0
  23. package/dist/coding/continuation-wording.js +73 -0
  24. package/dist/coding/linked-issue-note.d.ts +23 -0
  25. package/dist/coding/linked-issue-note.js +47 -0
  26. package/dist/coding/merge-wording.d.ts +28 -0
  27. package/dist/coding/merge-wording.js +78 -0
  28. package/dist/coding/pr-description-note.d.ts +9 -0
  29. package/dist/coding/pr-description-note.js +32 -0
  30. package/dist/coding/profile.d.ts +50 -118
  31. package/dist/coding/profile.js +51 -4
  32. package/dist/coding/protected-path-wording.d.ts +8 -0
  33. package/dist/coding/protected-path-wording.js +1 -1
  34. package/dist/coding/protocol.d.ts +292 -270
  35. package/dist/coding/protocol.js +322 -15
  36. package/dist/coding/redact.d.ts +3 -0
  37. package/dist/coding/redact.js +12 -0
  38. package/dist/coding/registry/allowlist.d.ts +12 -0
  39. package/dist/coding/registry/allowlist.js +14 -1
  40. package/dist/coding/registry/audit-mode.d.ts +153 -0
  41. package/dist/coding/registry/audit-mode.js +304 -0
  42. package/dist/coding/registry/lockfile-shared.d.ts +36 -0
  43. package/dist/coding/registry/lockfile-shared.js +128 -0
  44. package/dist/coding/registry/lockfiles.js +1 -1
  45. package/dist/coding/registry/npm-lockfile-formats.d.ts +11 -0
  46. package/dist/coding/registry/npm-lockfile-formats.js +41 -0
  47. package/dist/coding/registry/npm-lockfile.d.ts +8 -4
  48. package/dist/coding/registry/npm-lockfile.js +4 -6
  49. package/dist/coding/registry/npm.js +78 -8
  50. package/dist/coding/registry/pnpm-lockfile.d.ts +17 -0
  51. package/dist/coding/registry/pnpm-lockfile.js +189 -0
  52. package/dist/coding/registry/pypi-plan.d.ts +50 -0
  53. package/dist/coding/registry/pypi-plan.js +406 -0
  54. package/dist/coding/registry/pypi-requirements.d.ts +58 -0
  55. package/dist/coding/registry/pypi-requirements.js +155 -0
  56. package/dist/coding/registry/pypi.d.ts +3 -27
  57. package/dist/coding/registry/pypi.js +40 -154
  58. package/dist/coding/registry/refusal-hints.d.ts +20 -0
  59. package/dist/coding/registry/refusal-hints.js +61 -0
  60. package/dist/coding/registry/refusal-wording.d.ts +11 -0
  61. package/dist/coding/registry/refusal-wording.js +56 -0
  62. package/dist/coding/registry/report.d.ts +70 -8
  63. package/dist/coding/registry/report.js +112 -5
  64. package/dist/coding/registry/types.d.ts +58 -8
  65. package/dist/coding/registry/yarn-lockfile.d.ts +30 -0
  66. package/dist/coding/registry/yarn-lockfile.js +367 -0
  67. package/dist/coding/services/catalog.d.ts +37 -351
  68. package/dist/coding/transcript/claude-code.d.ts +13 -0
  69. package/dist/coding/transcript/claude-code.js +273 -0
  70. package/dist/coding/transcript/download-protocol.d.ts +30 -0
  71. package/dist/coding/transcript/download-protocol.js +18 -0
  72. package/dist/coding/transcript/format.d.ts +20 -0
  73. package/dist/coding/transcript/format.js +32 -0
  74. package/dist/coding/transcript/recorder.d.ts +51 -0
  75. package/dist/coding/transcript/recorder.js +225 -0
  76. package/dist/coding/transcript/resume/checkout.d.ts +31 -0
  77. package/dist/coding/transcript/resume/checkout.js +203 -0
  78. package/dist/coding/transcript/resume/command.d.ts +17 -0
  79. package/dist/coding/transcript/resume/command.js +176 -0
  80. package/dist/coding/transcript/resume/download.d.ts +20 -0
  81. package/dist/coding/transcript/resume/download.js +153 -0
  82. package/dist/coding/transcript/resume/launch.d.ts +38 -0
  83. package/dist/coding/transcript/resume/launch.js +128 -0
  84. package/dist/coding/transcript/resume/session-file.d.ts +13 -0
  85. package/dist/coding/transcript/resume/session-file.js +79 -0
  86. package/dist/coding/transcript/resume/stand-in.d.ts +11 -0
  87. package/dist/coding/transcript/resume/stand-in.js +49 -0
  88. package/dist/coding-worker/artifact.d.ts +16 -3
  89. package/dist/coding-worker/artifact.js +5 -3
  90. package/dist/coding-worker/debug-trace.d.ts +6 -5
  91. package/dist/coding-worker/debug-trace.js +13 -18
  92. package/dist/coding-worker/dependency-cache.d.mts +229 -0
  93. package/dist/coding-worker/dependency-cache.mjs +2123 -0
  94. package/dist/coding-worker/driver.d.ts +4 -3
  95. package/dist/coding-worker/driver.js +118 -38
  96. package/dist/coding-worker/errors.d.ts +1 -0
  97. package/dist/coding-worker/errors.js +32 -0
  98. package/dist/coding-worker/sdk.d.ts +6 -0
  99. package/dist/coding-worker/sdk.js +1 -0
  100. package/dist/coding-worker/skills.d.ts +23 -0
  101. package/dist/coding-worker/skills.js +117 -0
  102. package/dist/coding-worker/task-note.d.ts +11 -0
  103. package/dist/coding-worker/task-note.js +13 -0
  104. package/dist/coding-worker/types.d.ts +13 -0
  105. package/dist/config/dependency-cache.d.ts +23 -0
  106. package/dist/config/dependency-cache.js +32 -0
  107. package/dist/config/github-allowed-accounts.d.ts +15 -0
  108. package/dist/config/github-allowed-accounts.js +36 -0
  109. package/dist/config/low-power.d.ts +12 -0
  110. package/dist/config/low-power.js +18 -0
  111. package/dist/config/mobile.d.ts +12 -0
  112. package/dist/config/mobile.js +18 -0
  113. package/dist/config/providers.d.ts +65 -3
  114. package/dist/config/providers.js +147 -11
  115. package/dist/config/test-simulators.d.ts +82 -0
  116. package/dist/config/test-simulators.js +305 -0
  117. package/dist/core/async-context.d.ts +2 -0
  118. package/dist/core/async-context.js +12 -0
  119. package/dist/core/budget-events.d.ts +108 -0
  120. package/dist/core/budget-events.js +346 -0
  121. package/dist/core/budget-groups.d.ts +50 -1
  122. package/dist/core/budget-groups.js +236 -27
  123. package/dist/core/budget-stopped-head.d.ts +46 -0
  124. package/dist/core/budget-stopped-head.js +81 -0
  125. package/dist/core/budget-topup.d.ts +51 -0
  126. package/dist/core/budget-topup.js +156 -0
  127. package/dist/core/budget-wording.d.ts +16 -2
  128. package/dist/core/budget-wording.js +40 -2
  129. package/dist/core/coding-queue.d.ts +2 -1
  130. package/dist/core/coding-queue.js +7 -0
  131. package/dist/core/cron.d.ts +19 -1
  132. package/dist/core/cron.js +17 -0
  133. package/dist/core/datastores.js +1 -1
  134. package/dist/core/db.d.ts +16 -6
  135. package/dist/core/db.js +12 -10
  136. package/dist/core/deferred-coding.d.ts +14 -0
  137. package/dist/core/deferred-coding.js +62 -0
  138. package/dist/core/delegation-child.d.ts +25 -0
  139. package/dist/core/delegation-child.js +37 -0
  140. package/dist/core/dispatch.d.ts +53 -0
  141. package/dist/core/dispatch.js +167 -57
  142. package/dist/core/engine-native.js +10 -6
  143. package/dist/core/error-code.d.ts +38 -0
  144. package/dist/core/error-code.js +168 -0
  145. package/dist/core/grants.d.ts +19 -6
  146. package/dist/core/grants.js +55 -6
  147. package/dist/core/host-events.d.ts +67 -5
  148. package/dist/core/host-events.js +571 -37
  149. package/dist/core/host-status.d.ts +85 -3
  150. package/dist/core/host-status.js +298 -20
  151. package/dist/core/issue-bridge.d.ts +50 -6
  152. package/dist/core/issue-bridge.js +151 -20
  153. package/dist/core/issue-events.d.ts +1 -1
  154. package/dist/core/issue-events.js +16 -11
  155. package/dist/core/issue-status.d.ts +1 -1
  156. package/dist/core/issue-status.js +68 -29
  157. package/dist/core/logger.d.ts +5 -0
  158. package/dist/core/logger.js +14 -1
  159. package/dist/core/lost-run-children.d.ts +6 -0
  160. package/dist/core/lost-run-children.js +32 -0
  161. package/dist/core/mark-ready.d.ts +34 -0
  162. package/dist/core/mark-ready.js +152 -0
  163. package/dist/core/markdown-code.d.ts +8 -0
  164. package/dist/core/markdown-code.js +107 -0
  165. package/dist/core/memory-tools.js +7 -0
  166. package/dist/core/merge-order-check.d.ts +130 -0
  167. package/dist/core/merge-order-check.js +343 -0
  168. package/dist/core/notification-dispatcher.d.ts +70 -0
  169. package/dist/core/notification-dispatcher.js +368 -0
  170. package/dist/core/notification-templates.d.ts +19 -0
  171. package/dist/core/notification-templates.js +139 -0
  172. package/dist/core/notifications.d.ts +10 -0
  173. package/dist/core/notifications.js +38 -0
  174. package/dist/core/pg-auth.d.ts +22 -0
  175. package/dist/core/pg-auth.js +152 -0
  176. package/dist/core/power-composition.d.ts +16 -0
  177. package/dist/core/power-composition.js +37 -0
  178. package/dist/core/power-idle.d.ts +14 -0
  179. package/dist/core/power-idle.js +30 -0
  180. package/dist/core/power.d.ts +71 -0
  181. package/dist/core/power.js +202 -0
  182. package/dist/core/pull-request-state-sync.js +2 -2
  183. package/dist/core/reconciler.d.ts +8 -5
  184. package/dist/core/reconciler.js +37 -6
  185. package/dist/core/related-pull-requests.d.ts +59 -7
  186. package/dist/core/related-pull-requests.js +140 -25
  187. package/dist/core/reply-parts.d.ts +21 -0
  188. package/dist/core/reply-parts.js +73 -0
  189. package/dist/core/repo-access.d.ts +15 -0
  190. package/dist/core/repo-access.js +66 -0
  191. package/dist/core/review-fix.d.ts +38 -1
  192. package/dist/core/review-fix.js +209 -7
  193. package/dist/core/review-host-checks.d.ts +1 -1
  194. package/dist/core/review-host-tools.d.ts +23 -0
  195. package/dist/core/review-host-tools.js +156 -14
  196. package/dist/core/review-reads.d.ts +55 -0
  197. package/dist/core/review-reads.js +233 -0
  198. package/dist/core/run-status.d.ts +12 -0
  199. package/dist/core/run-status.js +12 -0
  200. package/dist/core/runner.d.ts +384 -5
  201. package/dist/core/runner.js +1244 -372
  202. package/dist/core/schedule-start.d.ts +12 -0
  203. package/dist/core/schedule-start.js +14 -0
  204. package/dist/core/secret-broker-config.d.ts +39 -0
  205. package/dist/core/secret-broker-config.js +112 -0
  206. package/dist/core/secret-guard.d.ts +46 -0
  207. package/dist/core/secret-guard.js +104 -0
  208. package/dist/core/secret-guarded-stores.d.ts +12 -0
  209. package/dist/core/secret-guarded-stores.js +41 -0
  210. package/dist/core/secret-spans.d.ts +50 -0
  211. package/dist/core/secret-spans.js +94 -0
  212. package/dist/core/secrets.d.ts +39 -4
  213. package/dist/core/secrets.js +148 -40
  214. package/dist/core/stop-run.d.ts +46 -0
  215. package/dist/core/stop-run.js +61 -0
  216. package/dist/core/workflow-event-recorder.d.ts +14 -0
  217. package/dist/core/workflow-event-recorder.js +120 -0
  218. package/dist/core/workflow-events.d.ts +94 -0
  219. package/dist/core/workflow-events.js +48 -0
  220. package/dist/core/workflow-run-events.d.ts +18 -0
  221. package/dist/core/workflow-run-events.js +75 -0
  222. package/dist/core/zod-json-schema.d.ts +4 -0
  223. package/dist/core/zod-json-schema.js +39 -0
  224. package/dist/core/zod3-window.d.ts +35 -0
  225. package/dist/core/zod3-window.js +124 -0
  226. package/dist/generated/prisma/browser.d.ts +92 -3
  227. package/dist/generated/prisma/client.d.ts +92 -3
  228. package/dist/generated/prisma/commonInputTypes.d.ts +364 -54
  229. package/dist/generated/prisma/enums.d.ts +37 -0
  230. package/dist/generated/prisma/enums.js +31 -0
  231. package/dist/generated/prisma/internal/class.d.ts +132 -0
  232. package/dist/generated/prisma/internal/class.js +4 -4
  233. package/dist/generated/prisma/internal/prismaNamespace.d.ts +1332 -142
  234. package/dist/generated/prisma/internal/prismaNamespace.js +227 -5
  235. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +234 -0
  236. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +227 -5
  237. package/dist/generated/prisma/models/Agent.d.ts +464 -7
  238. package/dist/generated/prisma/models/AgentRepository.d.ts +42 -1
  239. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +301 -1
  240. package/dist/generated/prisma/models/CodingProxyRequest.d.ts +207 -3
  241. package/dist/generated/prisma/models/CodingProxySession.d.ts +114 -1
  242. package/dist/generated/prisma/models/CodingRun.d.ts +1841 -1
  243. package/dist/generated/prisma/models/DeferredReview.d.ts +78 -1
  244. package/dist/generated/prisma/models/DependencySnapshot.d.ts +1668 -0
  245. package/dist/generated/prisma/models/DependencySnapshot.js +1 -0
  246. package/dist/generated/prisma/models/IssuePullRequest.d.ts +6 -2
  247. package/dist/generated/prisma/models/LocalReview.d.ts +40 -1
  248. package/dist/generated/prisma/models/NativeGatewayCall.d.ts +1713 -0
  249. package/dist/generated/prisma/models/NativeGatewayCall.js +1 -0
  250. package/dist/generated/prisma/models/NativeGatewaySession.d.ts +1551 -0
  251. package/dist/generated/prisma/models/NativeGatewaySession.js +1 -0
  252. package/dist/generated/prisma/models/NativeWarmWorker.d.ts +1296 -0
  253. package/dist/generated/prisma/models/NativeWarmWorker.js +1 -0
  254. package/dist/generated/prisma/models/NotificationChannel.d.ts +1543 -0
  255. package/dist/generated/prisma/models/NotificationChannel.js +1 -0
  256. package/dist/generated/prisma/models/NotificationDelivery.d.ts +1502 -0
  257. package/dist/generated/prisma/models/NotificationDelivery.js +1 -0
  258. package/dist/generated/prisma/models/NotificationThread.d.ts +1090 -0
  259. package/dist/generated/prisma/models/NotificationThread.js +1 -0
  260. package/dist/generated/prisma/models/RegistryAllowance.d.ts +41 -1
  261. package/dist/generated/prisma/models/RegistryApprovedVersion.d.ts +75 -1
  262. package/dist/generated/prisma/models/RegistryAuditRecord.d.ts +1364 -0
  263. package/dist/generated/prisma/models/RegistryAuditRecord.js +1 -0
  264. package/dist/generated/prisma/models/Run.d.ts +1619 -58
  265. package/dist/generated/prisma/models/RunBudgetEvent.d.ts +1211 -0
  266. package/dist/generated/prisma/models/RunBudgetEvent.js +1 -0
  267. package/dist/generated/prisma/models/RunHostCheck.d.ts +36 -1
  268. package/dist/generated/prisma/models/RunTranscript.d.ts +1339 -0
  269. package/dist/generated/prisma/models/RunTranscript.js +1 -0
  270. package/dist/generated/prisma/models/Secret.d.ts +37 -1
  271. package/dist/generated/prisma/models/SecretBrokerChange.d.ts +1101 -0
  272. package/dist/generated/prisma/models/SecretBrokerChange.js +1 -0
  273. package/dist/generated/prisma/models/WorkflowEvent.d.ts +1422 -0
  274. package/dist/generated/prisma/models/WorkflowEvent.js +1 -0
  275. package/dist/generated/prisma/models.d.ts +12 -0
  276. package/dist/help/build.js +4 -2
  277. package/dist/help/catalog.d.ts +1 -1
  278. package/dist/help/catalog.js +31 -6
  279. package/dist/help-index.json +2584 -146
  280. package/dist/import/create.js +49 -3
  281. package/dist/import/neutral-schema.d.ts +50 -277
  282. package/dist/import/neutral-schema.js +7 -4
  283. package/dist/knowledge/check.d.ts +1 -1
  284. package/dist/knowledge/cli.d.ts +11 -1
  285. package/dist/knowledge/cli.js +37 -4
  286. package/dist/knowledge/concept.d.ts +3 -19
  287. package/dist/knowledge/location.d.ts +31 -0
  288. package/dist/knowledge/location.js +78 -0
  289. package/dist/mcp/auth/access.d.ts +11 -2
  290. package/dist/mcp/auth/access.js +15 -1
  291. package/dist/mcp/auth/ownership.d.ts +7 -7
  292. package/dist/mcp/auth/repo-authorization.js +14 -0
  293. package/dist/mcp/auth/resource-server.d.ts +2 -1
  294. package/dist/mcp/auth/resource-server.js +7 -2
  295. package/dist/mcp/auth/self-hosted/browser.d.ts +1 -1
  296. package/dist/mcp/auth/self-hosted/browser.js +19 -6
  297. package/dist/mcp/auth/self-hosted/credentials.d.ts +3 -3
  298. package/dist/mcp/auth/self-hosted/session.d.ts +2 -2
  299. package/dist/mcp/context.d.ts +6 -0
  300. package/dist/mcp/host-events/github-ingress.d.ts +25 -1
  301. package/dist/mcp/host-events/github-ingress.js +70 -1
  302. package/dist/mcp/index.d.ts +19 -0
  303. package/dist/mcp/index.js +117 -20
  304. package/dist/mcp/server.d.ts +13 -0
  305. package/dist/mcp/server.js +33 -14
  306. package/dist/mcp/tools/agents.d.ts +4 -0
  307. package/dist/mcp/tools/agents.js +277 -24
  308. package/dist/mcp/tools/datastore.js +17 -8
  309. package/dist/mcp/tools/grants.js +9 -3
  310. package/dist/mcp/tools/memory.js +11 -1
  311. package/dist/mcp/tools/notification-channels.d.ts +2 -0
  312. package/dist/mcp/tools/notification-channels.js +293 -0
  313. package/dist/mcp/tools/packages.d.ts +2 -0
  314. package/dist/mcp/tools/packages.js +195 -0
  315. package/dist/mcp/tools/power.d.ts +2 -0
  316. package/dist/mcp/tools/power.js +77 -0
  317. package/dist/mcp/tools/repositories.js +18 -5
  318. package/dist/mcp/tools/run-access.d.ts +16 -0
  319. package/dist/mcp/tools/run-access.js +25 -0
  320. package/dist/mcp/tools/run-transcript-download.d.ts +30 -0
  321. package/dist/mcp/tools/run-transcript-download.js +129 -0
  322. package/dist/mcp/tools/run-transcript.d.ts +19 -0
  323. package/dist/mcp/tools/run-transcript.js +70 -0
  324. package/dist/mcp/tools/runs.d.ts +0 -6
  325. package/dist/mcp/tools/runs.js +41 -14
  326. package/dist/mcp/tools/scheduling.js +3 -1
  327. package/dist/mcp/tools/secret-broker-compat.d.ts +12 -0
  328. package/dist/mcp/tools/secret-broker-compat.js +23 -0
  329. package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -2
  330. package/dist/mcp/tools/secret-elicitation-form.js +205 -10
  331. package/dist/mcp/tools/secret-elicitation-server.d.ts +6 -0
  332. package/dist/mcp/tools/secret-elicitation-server.js +10 -54
  333. package/dist/mcp/tools/secret-elicitation.d.ts +38 -3
  334. package/dist/mcp/tools/secret-elicitation.js +110 -12
  335. package/dist/mcp/tools/secrets.js +186 -9
  336. package/dist/mcp/tools/stop-run.d.ts +2 -0
  337. package/dist/mcp/tools/stop-run.js +82 -0
  338. package/dist/mcp/tools/subagents.js +2 -2
  339. package/dist/mcp/tools/tools.d.ts +2 -2
  340. package/dist/mcp/tools/tools.js +47 -11
  341. package/dist/mcp/transport/stdio-link-host.d.ts +20 -0
  342. package/dist/mcp/transport/stdio-link-host.js +67 -0
  343. package/dist/mcp/transport/streamable-http.d.ts +13 -0
  344. package/dist/mcp/transport/streamable-http.js +41 -1
  345. package/dist/native-worker/composition.d.ts +23 -0
  346. package/dist/native-worker/composition.js +77 -0
  347. package/dist/native-worker/docker-isolation.d.ts +65 -0
  348. package/dist/native-worker/docker-isolation.js +183 -0
  349. package/dist/native-worker/docker-launcher.d.ts +110 -0
  350. package/dist/native-worker/docker-launcher.js +297 -0
  351. package/dist/native-worker/gateway-server.d.ts +77 -0
  352. package/dist/native-worker/gateway-server.js +412 -0
  353. package/dist/native-worker/gateway.d.ts +70 -0
  354. package/dist/native-worker/gateway.js +263 -0
  355. package/dist/native-worker/http-server.d.ts +24 -0
  356. package/dist/native-worker/http-server.js +113 -0
  357. package/dist/native-worker/http-transport.d.ts +17 -0
  358. package/dist/native-worker/http-transport.js +88 -0
  359. package/dist/native-worker/kubernetes-isolation.d.ts +90 -0
  360. package/dist/native-worker/kubernetes-isolation.js +218 -0
  361. package/dist/native-worker/kubernetes-launcher.d.ts +106 -0
  362. package/dist/native-worker/kubernetes-launcher.js +375 -0
  363. package/dist/native-worker/launch.d.ts +62 -0
  364. package/dist/native-worker/launch.js +49 -0
  365. package/dist/native-worker/ledger.d.ts +125 -0
  366. package/dist/native-worker/ledger.js +245 -0
  367. package/dist/native-worker/loopback.d.ts +12 -0
  368. package/dist/native-worker/loopback.js +44 -0
  369. package/dist/native-worker/main.d.ts +8 -0
  370. package/dist/native-worker/main.js +85 -0
  371. package/dist/native-worker/protocol.d.ts +296 -0
  372. package/dist/native-worker/protocol.js +246 -0
  373. package/dist/native-worker/sandbox-executor.d.ts +83 -0
  374. package/dist/native-worker/sandbox-executor.js +284 -0
  375. package/dist/native-worker/stderr-redaction.d.ts +77 -0
  376. package/dist/native-worker/stderr-redaction.js +237 -0
  377. package/dist/native-worker/stdio.d.ts +29 -0
  378. package/dist/native-worker/stdio.js +189 -0
  379. package/dist/native-worker/warm-delivery.d.ts +16 -0
  380. package/dist/native-worker/warm-delivery.js +33 -0
  381. package/dist/native-worker/warm-pool-ledger.d.ts +39 -0
  382. package/dist/native-worker/warm-pool-ledger.js +73 -0
  383. package/dist/native-worker/warm-pool.d.ts +107 -0
  384. package/dist/native-worker/warm-pool.js +283 -0
  385. package/dist/native-worker/worker.d.ts +17 -0
  386. package/dist/native-worker/worker.js +141 -0
  387. package/dist/observability/metrics.js +6 -1
  388. package/dist/providers/auth/authorization-server.d.ts +3 -0
  389. package/dist/providers/auth/index.js +2 -0
  390. package/dist/providers/auth/self-hosted.d.ts +14 -2
  391. package/dist/providers/auth/self-hosted.js +54 -4
  392. package/dist/providers/chat/fake.d.ts +46 -0
  393. package/dist/providers/chat/fake.js +39 -0
  394. package/dist/providers/chat/index.d.ts +4 -0
  395. package/dist/providers/chat/index.js +8 -0
  396. package/dist/providers/chat/slack.d.ts +37 -0
  397. package/dist/providers/chat/slack.js +105 -0
  398. package/dist/providers/chat/types.d.ts +42 -0
  399. package/dist/providers/chat/types.js +19 -0
  400. package/dist/providers/coding-proxy/dependency-cache/eviction.d.ts +29 -0
  401. package/dist/providers/coding-proxy/dependency-cache/eviction.js +87 -0
  402. package/dist/providers/coding-proxy/dependency-cache/key.d.ts +33 -0
  403. package/dist/providers/coding-proxy/dependency-cache/key.js +86 -0
  404. package/dist/providers/coding-proxy/dependency-cache/model-request-seal.d.ts +18 -0
  405. package/dist/providers/coding-proxy/dependency-cache/model-request-seal.js +40 -0
  406. package/dist/providers/coding-proxy/dependency-cache/prisma-store.d.ts +25 -0
  407. package/dist/providers/coding-proxy/dependency-cache/prisma-store.js +131 -0
  408. package/dist/providers/coding-proxy/dependency-cache/service.d.ts +70 -0
  409. package/dist/providers/coding-proxy/dependency-cache/service.js +409 -0
  410. package/dist/providers/coding-proxy/dependency-cache/store.d.ts +85 -0
  411. package/dist/providers/coding-proxy/dependency-cache/store.js +95 -0
  412. package/dist/providers/coding-proxy/hold.d.ts +32 -0
  413. package/dist/providers/coding-proxy/hold.js +86 -0
  414. package/dist/providers/coding-proxy/interrupted.d.ts +70 -0
  415. package/dist/providers/coding-proxy/interrupted.js +104 -0
  416. package/dist/providers/coding-proxy/memory-ledger.d.ts +15 -2
  417. package/dist/providers/coding-proxy/memory-ledger.js +84 -8
  418. package/dist/providers/coding-proxy/metering.d.ts +16 -0
  419. package/dist/providers/coding-proxy/metering.js +27 -4
  420. package/dist/providers/coding-proxy/prisma-ledger.d.ts +15 -2
  421. package/dist/providers/coding-proxy/prisma-ledger.js +179 -67
  422. package/dist/providers/coding-proxy/proxy.d.ts +14 -1
  423. package/dist/providers/coding-proxy/proxy.js +166 -14
  424. package/dist/providers/coding-proxy/registry/audit.d.ts +53 -10
  425. package/dist/providers/coding-proxy/registry/audit.js +94 -17
  426. package/dist/providers/coding-proxy/registry/lockfile-parse-worker.d.ts +36 -0
  427. package/dist/providers/coding-proxy/registry/lockfile-parse-worker.js +67 -0
  428. package/dist/providers/coding-proxy/registry/lockfile-parse.d.ts +16 -0
  429. package/dist/providers/coding-proxy/registry/lockfile-parse.js +110 -0
  430. package/dist/providers/coding-proxy/registry/plan.d.ts +9 -0
  431. package/dist/providers/coding-proxy/registry/plan.js +201 -48
  432. package/dist/providers/coding-proxy/registry/prisma-store.d.ts +11 -5
  433. package/dist/providers/coding-proxy/registry/prisma-store.js +120 -17
  434. package/dist/providers/coding-proxy/registry/service.d.ts +97 -16
  435. package/dist/providers/coding-proxy/registry/service.js +579 -112
  436. package/dist/providers/coding-proxy/registry/store.d.ts +84 -9
  437. package/dist/providers/coding-proxy/registry/store.js +54 -1
  438. package/dist/providers/coding-proxy/runtime.d.ts +4 -0
  439. package/dist/providers/coding-proxy/runtime.js +78 -16
  440. package/dist/providers/coding-proxy/server.d.ts +6 -1
  441. package/dist/providers/coding-proxy/server.js +45 -1
  442. package/dist/providers/coding-proxy/types.d.ts +65 -1
  443. package/dist/providers/datastore/types.d.ts +5 -0
  444. package/dist/providers/engine/types.d.ts +36 -2
  445. package/dist/providers/engine/types.js +39 -0
  446. package/dist/providers/executor/composition.d.ts +31 -1
  447. package/dist/providers/executor/composition.js +81 -8
  448. package/dist/providers/executor/container.d.ts +385 -9
  449. package/dist/providers/executor/container.js +1234 -99
  450. package/dist/providers/executor/dbos-status.d.ts +31 -0
  451. package/dist/providers/executor/dbos-status.js +64 -0
  452. package/dist/providers/executor/dbos.d.ts +91 -0
  453. package/dist/providers/executor/dbos.js +319 -26
  454. package/dist/providers/executor/deferred.d.ts +44 -0
  455. package/dist/providers/executor/deferred.js +100 -0
  456. package/dist/providers/executor/in-process.d.ts +9 -1
  457. package/dist/providers/executor/in-process.js +14 -4
  458. package/dist/providers/executor/linked-issue-reader.d.ts +16 -0
  459. package/dist/providers/executor/linked-issue-reader.js +30 -0
  460. package/dist/providers/executor/routing.d.ts +39 -10
  461. package/dist/providers/executor/routing.js +77 -32
  462. package/dist/providers/executor/types.d.ts +25 -0
  463. package/dist/providers/index.d.ts +2 -1
  464. package/dist/providers/issue-tracker/types.d.ts +17 -0
  465. package/dist/providers/issue-tracker/types.js +39 -1
  466. package/dist/providers/jobs/claude-tool-setup.d.ts +4 -0
  467. package/dist/providers/jobs/claude-tool-setup.js +5 -2
  468. package/dist/providers/jobs/docker-isolation.d.ts +8 -0
  469. package/dist/providers/jobs/docker-isolation.js +17 -0
  470. package/dist/providers/jobs/docker.d.ts +1 -0
  471. package/dist/providers/jobs/docker.js +29 -4
  472. package/dist/providers/jobs/fake-kubernetes-api.d.ts +23 -2
  473. package/dist/providers/jobs/fake-kubernetes-api.js +45 -1
  474. package/dist/providers/jobs/kubernetes-api.d.ts +25 -1
  475. package/dist/providers/jobs/kubernetes-api.js +17 -0
  476. package/dist/providers/jobs/kubernetes-client.d.ts +10 -1
  477. package/dist/providers/jobs/kubernetes-client.js +61 -3
  478. package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +6 -1
  479. package/dist/providers/jobs/kubernetes-dry-run-fixture.js +6 -3
  480. package/dist/providers/jobs/kubernetes-isolation.d.ts +5 -1
  481. package/dist/providers/jobs/kubernetes-isolation.js +23 -1
  482. package/dist/providers/jobs/kubernetes-platform.d.ts +4 -1
  483. package/dist/providers/jobs/kubernetes-platform.js +38 -1
  484. package/dist/providers/jobs/kubernetes-preflight.d.ts +2 -1
  485. package/dist/providers/jobs/kubernetes-preflight.js +4 -2
  486. package/dist/providers/jobs/kubernetes.d.ts +42 -2
  487. package/dist/providers/jobs/kubernetes.js +178 -19
  488. package/dist/providers/jobs/metadata-probes.d.ts +11 -0
  489. package/dist/providers/jobs/metadata-probes.js +13 -0
  490. package/dist/providers/jobs/types.d.ts +42 -0
  491. package/dist/providers/jobs/types.js +13 -1
  492. package/dist/providers/llm/anthropic.js +5 -1
  493. package/dist/providers/llm/bedrock.d.ts +0 -12
  494. package/dist/providers/llm/bedrock.js +4 -0
  495. package/dist/providers/llm/claude-messages.d.ts +3 -1
  496. package/dist/providers/llm/claude-messages.js +4 -1
  497. package/dist/providers/llm/claude-provider.d.ts +2 -2
  498. package/dist/providers/llm/claude-provider.js +2 -2
  499. package/dist/providers/llm/openai.d.ts +2 -2
  500. package/dist/providers/llm/openai.js +6 -2
  501. package/dist/providers/llm/routing.d.ts +2 -2
  502. package/dist/providers/llm/routing.js +2 -2
  503. package/dist/providers/llm/types.d.ts +12 -2
  504. package/dist/providers/review-host/github-events.d.ts +9 -1
  505. package/dist/providers/review-host/github-events.js +34 -12
  506. package/dist/providers/review-host/github.d.ts +39 -2
  507. package/dist/providers/review-host/github.js +274 -29
  508. package/dist/providers/review-host/local.d.ts +8 -4
  509. package/dist/providers/review-host/local.js +49 -31
  510. package/dist/providers/review-host/patch-pages.d.ts +41 -0
  511. package/dist/providers/review-host/patch-pages.js +176 -0
  512. package/dist/providers/review-host/review-format.d.ts +19 -1
  513. package/dist/providers/review-host/review-format.js +38 -7
  514. package/dist/providers/review-host/types.d.ts +157 -2
  515. package/dist/providers/review-host/types.js +8 -0
  516. package/dist/providers/storage/gcs.d.ts +63 -0
  517. package/dist/providers/storage/gcs.js +69 -0
  518. package/dist/providers/storage/index.d.ts +15 -0
  519. package/dist/providers/storage/index.js +14 -0
  520. package/dist/providers/storage/local.d.ts +24 -0
  521. package/dist/providers/storage/local.js +92 -0
  522. package/dist/providers/storage/meter.d.ts +14 -0
  523. package/dist/providers/storage/meter.js +34 -0
  524. package/dist/providers/storage/s3.d.ts +41 -0
  525. package/dist/providers/storage/s3.js +107 -0
  526. package/dist/providers/storage/types.d.ts +37 -3
  527. package/dist/providers/storage/types.js +21 -4
  528. package/dist/providers/vcs/git.d.ts +33 -2
  529. package/dist/providers/vcs/git.js +324 -22
  530. package/dist/providers/vcs/github-remote.d.ts +26 -3
  531. package/dist/providers/vcs/github-remote.js +124 -14
  532. package/dist/providers/vcs/github.d.ts +246 -12
  533. package/dist/providers/vcs/github.js +521 -61
  534. package/dist/providers/vcs/index.js +8 -1
  535. package/dist/providers/vcs/pr-description.d.ts +127 -0
  536. package/dist/providers/vcs/pr-description.js +270 -0
  537. package/dist/providers/vcs/remote.d.ts +33 -1
  538. package/dist/providers/vcs/routing.d.ts +4 -2
  539. package/dist/providers/vcs/routing.js +6 -0
  540. package/dist/providers/vcs/types.d.ts +80 -3
  541. package/dist/quickstart/coding-seed.d.ts +21 -3
  542. package/dist/quickstart/coding-seed.js +283 -3
  543. package/dist/quickstart/images.d.ts +2 -0
  544. package/dist/quickstart/images.js +7 -1
  545. package/dist/quickstart/reviewer-prompt.d.ts +1 -1
  546. package/dist/quickstart/reviewer-prompt.js +6 -6
  547. package/dist/quickstart-images.json +1 -1
  548. package/dist/sandbox/brokered-fetch.d.ts +23 -0
  549. package/dist/sandbox/brokered-fetch.js +37 -0
  550. package/dist/sandbox/generated/zod.bundle.js +62 -1
  551. package/dist/sandbox/generated/zod3.bundle.js +5 -0
  552. package/dist/sandbox/host-functions.d.ts +35 -0
  553. package/dist/sandbox/host-functions.js +150 -91
  554. package/dist/sandbox/prelude.js +7 -1
  555. package/dist/sandbox/run-in-sandbox.d.ts +19 -0
  556. package/dist/sandbox/run-in-sandbox.js +28 -10
  557. package/dist/sandbox/safe-fetch.d.ts +2 -0
  558. package/dist/sandbox/safe-fetch.js +3 -1
  559. package/dist/sandbox/secret-broker-sigv4.d.ts +2 -0
  560. package/dist/sandbox/secret-broker-sigv4.js +49 -0
  561. package/dist/sandbox/secret-broker.d.ts +34 -0
  562. package/dist/sandbox/secret-broker.js +161 -0
  563. package/dist/sandbox/tool-capabilities.d.ts +10 -30
  564. package/dist/sandbox/user-tool.d.ts +14 -0
  565. package/dist/sandbox/user-tool.js +32 -0
  566. package/dist/sandbox/zod-params.d.ts +37 -5
  567. package/dist/sandbox/zod-params.js +181 -26
  568. package/dist/serve.js +25 -0
  569. package/dist/viewer/api-schema.d.ts +372 -2454
  570. package/dist/viewer/api-schema.js +29 -0
  571. package/dist/viewer/build-schemas.js +10 -3
  572. package/dist/viewer/event-bus.d.ts +6 -0
  573. package/dist/viewer/event-bus.js +16 -7
  574. package/dist/viewer/graph.d.ts +5 -2
  575. package/dist/viewer/graph.js +7 -2
  576. package/dist/viewer/http.d.ts +0 -1
  577. package/dist/viewer/http.js +2 -2
  578. package/dist/viewer/infra.js +25 -2
  579. package/dist/viewer/limits.d.ts +4 -0
  580. package/dist/viewer/limits.js +4 -0
  581. package/dist/viewer/openapi.d.ts +15 -0
  582. package/dist/viewer/openapi.js +170 -0
  583. package/dist/wardby-bin.js +10 -0
  584. package/docs/README.md +7 -0
  585. package/docs/agent-recipes.md +114 -27
  586. package/docs/architecture-runtime.md +16 -1
  587. package/docs/code-review-agents.md +689 -45
  588. package/docs/coding-agent-setup.md +602 -12
  589. package/docs/coding-packages.md +1037 -92
  590. package/docs/coding-services.md +30 -13
  591. package/docs/coding-worker-byo-images.md +218 -6
  592. package/docs/coding-worker-isolation.md +173 -44
  593. package/docs/getting-started-gke.md +332 -129
  594. package/docs/getting-started-identity-provider.md +31 -11
  595. package/docs/getting-started.md +14 -4
  596. package/docs/jira-agents.md +22 -9
  597. package/docs/knowledge/blob-not-found-semantics.md +41 -0
  598. package/docs/knowledge/bounded-file-descriptor.md +52 -0
  599. package/docs/knowledge/delegation-admission-slots.md +52 -0
  600. package/docs/knowledge/http-dispatcher-bootstrap.md +61 -0
  601. package/docs/knowledge/import-rename-target.md +52 -0
  602. package/docs/knowledge/index.md +57 -0
  603. package/docs/knowledge/log.md +9 -0
  604. package/docs/knowledge/mcp-cancelled-task-precedence.md +41 -0
  605. package/docs/knowledge/mcp-http-prompt-discovery.md +41 -0
  606. package/docs/knowledge/mcp-schema-module-boundary.md +41 -0
  607. package/docs/knowledge/nullable-tool-uniqueness.md +52 -0
  608. package/docs/knowledge/overview-cli.md +74 -0
  609. package/docs/knowledge/overview-coding-proxy.md +72 -0
  610. package/docs/knowledge/overview-coding.md +72 -0
  611. package/docs/knowledge/overview-config.md +60 -0
  612. package/docs/knowledge/overview-core.md +72 -0
  613. package/docs/knowledge/overview-deploy.md +83 -0
  614. package/docs/knowledge/overview-mcp.md +61 -0
  615. package/docs/knowledge/overview-prisma.md +72 -0
  616. package/docs/knowledge/overview-providers.md +89 -0
  617. package/docs/knowledge/overview-sandbox.md +83 -0
  618. package/docs/knowledge/overview-surfaces.md +83 -0
  619. package/docs/knowledge/private-datastore-shared-boundary.md +54 -0
  620. package/docs/knowledge/proxy-settlement-projections.md +41 -0
  621. package/docs/knowledge/run-pricing-first-writer.md +43 -0
  622. package/docs/knowledge/schedule-claim-transaction.md +41 -0
  623. package/docs/knowledge/secret-entry-replica-contract.md +52 -0
  624. package/docs/knowledge/terminal-run-first-writer.md +41 -0
  625. package/docs/knowledge/transactional-agent-authorization.md +52 -0
  626. package/docs/knowledge/viewer-notify-write-isolation.md +30 -0
  627. package/docs/knowledge/warm-input-publication.md +30 -0
  628. package/docs/knowledge/workspace-staging-failure.md +54 -0
  629. package/docs/knowledge.md +54 -14
  630. package/docs/native-sandbox.md +731 -0
  631. package/docs/observability.md +4 -0
  632. package/docs/release-verification.md +9 -0
  633. package/docs/security-deployment.md +146 -14
  634. package/docs/slack-notifications.md +268 -0
  635. package/docs/tool-secrets.md +284 -0
  636. package/docs/viewer-api.md +46 -5
  637. package/docs/viewer-api.openapi.yml +1404 -0
  638. package/help/admin-viewer.md +29 -3
  639. package/help/agent-recipes.md +27 -0
  640. package/help/architecture-agent.md +11 -6
  641. package/help/brokered-secrets.md +79 -0
  642. package/help/build-claude-code-image.md +131 -0
  643. package/help/build-worker-image.md +6 -7
  644. package/help/builder-agent.md +19 -6
  645. package/help/code-review-agents.md +243 -10
  646. package/help/coding-packages.md +156 -6
  647. package/help/cost-attribution.md +9 -5
  648. package/help/creating-agents.md +14 -1
  649. package/help/dependency-cache.md +143 -0
  650. package/help/deploy-gke.md +23 -9
  651. package/help/deployment-targets.md +25 -14
  652. package/help/errors/budget-exhausted.md +187 -0
  653. package/help/errors/budget-group-exhausted.md +5 -1
  654. package/help/errors/coding-provider-not-configured.md +2 -2
  655. package/help/errors/coding-stream-quota-exceeded.md +33 -0
  656. package/help/errors/coding-workspace-disk-exceeds-limit.md +39 -0
  657. package/help/errors/continuation-closed.md +9 -12
  658. package/help/errors/database-auth-invalid.md +28 -0
  659. package/help/errors/delegation-conflict.md +24 -0
  660. package/help/errors/file-list-truncated.md +45 -0
  661. package/help/errors/full-read-required.md +49 -0
  662. package/help/errors/github-account-not-allowed.md +29 -0
  663. package/help/errors/graph-incomplete.md +43 -0
  664. package/help/errors/graph-limit.md +43 -0
  665. package/help/errors/low-power-disabled.md +29 -0
  666. package/help/errors/low-power-scale-forbidden.md +35 -0
  667. package/help/errors/low-power-wake-timeout.md +30 -0
  668. package/help/errors/merge-conflict.md +49 -0
  669. package/help/errors/mobile-disabled.md +31 -0
  670. package/help/errors/native-sandbox-capacity.md +22 -0
  671. package/help/errors/native-sandbox-deadline-exceeded.md +19 -0
  672. package/help/errors/native-sandbox-docker-failed.md +20 -0
  673. package/help/errors/native-sandbox-gateway-error.md +20 -0
  674. package/help/errors/native-sandbox-gateway-unavailable.md +21 -0
  675. package/help/errors/native-sandbox-image-not-pinned.md +19 -0
  676. package/help/errors/native-sandbox-isolation-mismatch.md +20 -0
  677. package/help/errors/native-sandbox-launch-abandoned.md +21 -0
  678. package/help/errors/native-sandbox-network-unenforced.md +22 -0
  679. package/help/errors/native-sandbox-requires-catalog.md +31 -0
  680. package/help/errors/native-sandbox-unavailable.md +19 -0
  681. package/help/errors/native-sandbox-warm-delivery-failed.md +21 -0
  682. package/help/errors/native-sandbox-worker-exited.md +21 -0
  683. package/help/errors/native-sandbox-worker-lost.md +20 -0
  684. package/help/errors/native-sandbox-worker-unready.md +22 -0
  685. package/help/errors/package-not-allowed-pending.md +52 -0
  686. package/help/errors/package-not-allowed.md +51 -0
  687. package/help/errors/package-refusals.md +43 -0
  688. package/help/errors/patch-cursor-malformed.md +31 -0
  689. package/help/errors/run-lost.md +39 -0
  690. package/help/errors/run-transcript-download.md +44 -0
  691. package/help/errors/secret-broker-body-invalid.md +26 -0
  692. package/help/errors/secret-broker-changed.md +20 -0
  693. package/help/errors/secret-broker-config-invalid.md +30 -0
  694. package/help/errors/secret-broker-conflict.md +27 -0
  695. package/help/errors/secret-broker-destination-denied.md +28 -0
  696. package/help/errors/secret-broker-value-invalid.md +28 -0
  697. package/help/errors/secret-brokered.md +24 -0
  698. package/help/errors/secret-in-value.md +69 -0
  699. package/help/errors/secret-not-brokered.md +25 -0
  700. package/help/errors/slack-auth-failed.md +48 -0
  701. package/help/errors/slack-channel-unreachable.md +39 -0
  702. package/help/errors/slack-not-configured.md +33 -0
  703. package/help/errors/target-merge.md +40 -0
  704. package/help/fix-merge-conflicts.md +73 -0
  705. package/help/getting-started.md +4 -5
  706. package/help/github-app-setup.md +132 -0
  707. package/help/github.md +40 -1
  708. package/help/identity-and-access.md +7 -1
  709. package/help/jira.md +23 -6
  710. package/help/knowledge.md +16 -7
  711. package/help/local-repositories.md +3 -4
  712. package/help/low-power-mode.md +131 -0
  713. package/help/merge-order-check.md +122 -0
  714. package/help/mobile-app.md +54 -0
  715. package/help/native-capabilities.md +29 -1
  716. package/help/native-sandbox.md +150 -0
  717. package/help/observability.md +8 -0
  718. package/help/operating-agents.md +5 -0
  719. package/help/package-audit-mode.md +91 -0
  720. package/help/related-pull-requests.md +80 -16
  721. package/help/repo-instructions-and-skills.md +91 -0
  722. package/help/resume-runs-locally.md +131 -0
  723. package/help/review-fix-rounds.md +43 -1
  724. package/help/sharing-agents.md +59 -0
  725. package/help/slack-notifications.md +76 -0
  726. package/help/status-comments.md +31 -0
  727. package/help/stop-run.md +58 -0
  728. package/help/troubleshooting/budgets.md +28 -0
  729. package/help/troubleshooting/coding-workers.md +99 -7
  730. package/package.json +27 -13
  731. package/prisma/migrations/20261009000003_native_execution_mode/migration.sql +12 -0
  732. package/prisma/migrations/20261009000004_native_gateway_ledger/migration.sql +65 -0
  733. package/prisma/migrations/20261009000005_native_gateway_call_child_agent/migration.sql +5 -0
  734. package/prisma/migrations/20261009000006_run_deferred_start_stop/migration.sql +14 -0
  735. package/prisma/migrations/20261009000007_native_gateway_network_ready/migration.sql +5 -0
  736. package/prisma/migrations/20261010000001_workflow_notifications/migration.sql +99 -0
  737. package/prisma/migrations/20261011000001_native_gateway_session_status_index/migration.sql +2 -0
  738. package/prisma/migrations/20261012000001_native_warm_worker/migration.sql +32 -0
  739. package/prisma/migrations/20261013000001_coding_run_merge_order/migration.sql +2 -0
  740. package/prisma/migrations/20261013000001_secret_broker/migration.sql +17 -0
  741. package/prisma/migrations/20261013000002_deferred_review_request/migration.sql +4 -0
  742. package/prisma/migrations/20261014000000_repo_skills/migration.sql +15 -0
  743. package/prisma/migrations/20261015000001_coding_run_merge_order_index/migration.sql +2 -0
  744. package/prisma/migrations/20261015000002_no_change_rereview/migration.sql +2 -0
  745. package/prisma/migrations/20261016000000_registry_approved_accepted_advisories/migration.sql +3 -0
  746. package/prisma/migrations/20261016000001_mark_ready_when_passing/migration.sql +3 -0
  747. package/prisma/migrations/20261017000000_debug_trace_max_mb/migration.sql +4 -0
  748. package/prisma/migrations/20261018000000_dependency_cache/migration.sql +53 -0
  749. package/prisma/migrations/20261019000000_claude_max_output_tokens/migration.sql +5 -0
  750. package/prisma/migrations/20261021000000_pr_description_hash/migration.sql +4 -0
  751. package/prisma/migrations/20261022000000_dependency_snapshot_validation_metadata/migration.sql +4 -0
  752. package/prisma/migrations/20261023000000_package_audit_mode/migration.sql +27 -0
  753. package/prisma/migrations/20261024000000_budget_events/migration.sql +26 -0
  754. package/prisma/migrations/20261025000000_cache_aware_holds/migration.sql +9 -0
  755. package/prisma/migrations/20261026000000_budget_overrun/migration.sql +4 -0
  756. package/prisma/migrations/20261027000000_coding_run_request/migration.sql +1 -0
  757. package/prisma/migrations/20261028000000_native_gateway_review_reads/migration.sql +6 -0
  758. package/prisma/migrations/20261029000000_lead_survives_deploy/migration.sql +6 -0
  759. package/prisma/migrations/20261030000000_local_review_covered_sha/migration.sql +2 -0
  760. package/prisma/migrations/20261031000000_coding_run_budget_limit/migration.sql +9 -0
  761. package/prisma/migrations/20261101000000_run_transcripts/migration.sql +22 -0
  762. package/prisma/schema.prisma +631 -146
  763. package/dist/sandbox/generated/zod-to-json-schema.bundle.js +0 -5
@@ -0,0 +1,2123 @@
1
+ // Wardby's pre-agent dependency cache, run by trusted wardby code (the Codex
2
+ // worker driver and the Claude tool runner) before the agent starts.
3
+ //
4
+ // It finds the repo's npm projects (a committed package-lock.json at the root
5
+ // or up to three folders down; at most MAX_PROJECTS), and for each one plans
6
+ // the lockfile with the registry proxy (the same request the npm shim sends),
7
+ // asks the proxy's dependency cache for a snapshot of node_modules made for
8
+ // exactly that lockfile, image and policy, and then either:
9
+ // hit - downloads it, verifies its sha256, checks every tar entry,
10
+ // extracts it into a temp folder, checks the tree against the
11
+ // lockfile and renames it into place;
12
+ // nearest - restores the newest snapshot of an older lockfile the same way
13
+ // (minus the lockfile check), lets `npm install` bring it up to
14
+ // date, checks the tree, and snapshots the result;
15
+ // cold - runs `npm ci`, checks the tree, and snapshots it.
16
+ // Projects run one after another with a shared disk budget, which bounds the
17
+ // restores, gates each install (MIN_INSTALL_BUDGET_BYTES or a known tree
18
+ // size), and is re-measured before each snapshot archive (skipped, or stopped
19
+ // mid-write, when it would not fit); npm's download cache is removed after the
20
+ // module's own install. One project's failure never affects another. It always seals the run's upload window once,
21
+ // after every project, so nothing the agent does can ever be uploaded. Only
22
+ // then, for a project whose lockfile lists install scripts, it runs
23
+ // `npm rebuild` (snapshots are always built with --ignore-scripts, so what the
24
+ // scripts produce is never uploaded).
25
+ //
26
+ // A snapshot is untrusted input (storage could be tampered with), so the
27
+ // archive is parsed here, in one place, and every entry is checked before
28
+ // anything is written; extraction then writes exactly the entries that were
29
+ // checked (no second tar implementation to disagree with this one).
30
+ //
31
+ // The cache never fails a run: every error becomes `skipped` with a stable
32
+ // reason code, and the agent installs as it would without the cache. Logs
33
+ // carry event names, codes and counts only: never tokens, URLs or npm output.
34
+ //
35
+ // Dependency-free on purpose (it runs before anything is installed).
36
+ import { spawn } from "node:child_process";
37
+ import { createHash, randomBytes } from "node:crypto";
38
+ import {
39
+ closeSync,
40
+ constants as fsConstants,
41
+ createReadStream,
42
+ createWriteStream,
43
+ lstatSync,
44
+ openSync,
45
+ writeSync,
46
+ } from "node:fs";
47
+ import {
48
+ lstat,
49
+ mkdir,
50
+ open,
51
+ readdir,
52
+ readFile,
53
+ readlink,
54
+ realpath,
55
+ rename,
56
+ rm,
57
+ stat,
58
+ statfs,
59
+ symlink,
60
+ utimes,
61
+ writeFile,
62
+ } from "node:fs/promises";
63
+ import http from "node:http";
64
+ import https from "node:https";
65
+ import { basename, join, posix, resolve, sep } from "node:path";
66
+ import { Transform } from "node:stream";
67
+ import { pipeline } from "node:stream/promises";
68
+ import { setTimeout as sleep } from "node:timers/promises";
69
+ import { createGunzip } from "node:zlib";
70
+
71
+ const MIB = 1024 * 1024;
72
+
73
+ /** Time bounds (ms). `uploadWindowMs` mirrors the proxy's upload window
74
+ * (WARDBY_DEPENDENCY_CACHE_UPLOAD_SECONDS, 15 min by default), measured from
75
+ * this module's start, which is after the proxy session began: a snapshot is
76
+ * not uploaded once less than `uploadMarginMs` of it is left (a lookup's
77
+ * `uploadExpiresInMs`, when a proxy reports one, is used instead when
78
+ * sooner). With the default totalMs (20 min) a slow run simply stops
79
+ * uploading after ~14 min while still restoring and installing.
80
+ * `transferMs` is the floor for a download or upload,
81
+ * which also gets `transferMsPerMiB` for every MiB it moves; `tarMs` is the
82
+ * floor for packing a snapshot, which gets `tarMsPerMiB` for every MiB of
83
+ * the installed tree it reads (gzip is CPU-bound, so a large tree on a
84
+ * small pod needs longer), and never runs past the upload deadline. After
85
+ * `totalMs` the work is aborted and gets `settleMs` to clean up; then the
86
+ * workspace is closed to it (no rename, removal or install) and it is sealed.
87
+ * `httpMs` bounds each small proxy call (lookup, seal); `planMs` the
88
+ * lockfile plan alone, which on a proxy with a cold package-fact cache
89
+ * checks every package of a large lockfile before it answers (still bounded
90
+ * by `totalMs`, like every step). A proxy that stops at its own plan time
91
+ * bound (REGISTRY_PLAN_TIMEOUT_MS) answers 503 `wardby_plan_incomplete` and
92
+ * keeps what it verified; the plan is then sent again after
93
+ * `planRetryPauseMs`, resuming there, until `planMs` is used up. */
94
+ export const DEFAULT_TIMEOUTS = Object.freeze({
95
+ httpMs: 120_000,
96
+ planMs: 600_000,
97
+ planRetryPauseMs: 2_000,
98
+ transferMs: 300_000,
99
+ transferMsPerMiB: 1_000,
100
+ installMs: 600_000,
101
+ tarMs: 300_000,
102
+ tarMsPerMiB: 500,
103
+ totalMs: 1_200_000,
104
+ killGraceMs: 5_000,
105
+ settleMs: 6_000,
106
+ rebuildMs: 300_000,
107
+ uploadWindowMs: 900_000,
108
+ uploadMarginMs: 60_000,
109
+ });
110
+
111
+ /** The workspace budget when the caller names none and CODING_DISK_MB is unset. */
112
+ export const DEFAULT_BUDGET_MB = 2048;
113
+ /** The least room a cold install needs when no better estimate is known
114
+ * (`budget_too_small` below it): npm writes node_modules and, beside it, its
115
+ * download cache (`<npm_config_cache>/_cacache`, about as large again before
116
+ * this module removes it), and the snapshot archive follows. 1 GiB covers a
117
+ * large tree (about 450 MiB of node_modules plus 350 MiB of npm cache). */
118
+ export const MIN_INSTALL_BUDGET_BYTES = 1024 * MIB;
119
+ /** A cold install's room when the size of this project's tree is known (a
120
+ * hit or nearest snapshot that was scanned): node_modules plus npm's cache. */
121
+ const INSTALL_NEED_FACTOR = 2;
122
+ /** A nearest restore must leave room for the delta install and the archive:
123
+ * its unpacked size times this, before it is extracted. */
124
+ const NEAREST_NEED_FACTOR = 1.5;
125
+ /** node_modules bytes per archive byte when no snapshot of this project was
126
+ * seen (gzip of a typical npm tree is about 3:1). */
127
+ const ARCHIVE_RATIO_ESTIMATE = 3;
128
+ /** Most entries a snapshot may hold (an inode bomb is not a byte bomb). */
129
+ export const MAX_ARCHIVE_ENTRIES = 1_000_000;
130
+ const MAX_JSON_BYTES = 1 * MIB;
131
+ const MAX_PAX_BYTES = 1 * MIB;
132
+ const MAX_NAME_BYTES = 4096;
133
+ const ROOT = "node_modules";
134
+ /** Most npm projects one run prepares; more are reported `too_many_projects`. */
135
+ export const MAX_PROJECTS = 4;
136
+ /** Deepest project folder, in path segments (the proxy's own limit). */
137
+ const MAX_PROJECT_DEPTH = 3;
138
+ /** Most folders discovery looks at, so a huge checkout can't stall the run. */
139
+ const MAX_DISCOVERY_DIRS = 10_000;
140
+ /** A project path segment the proxy accepts (key.ts parseProjectPath): no
141
+ * leading dot, so dot-folders are never projects. */
142
+ const PROJECT_SEGMENT = /^[A-Za-z0-9_@+-][A-Za-z0-9._@+-]{0,99}$/;
143
+ /** This module's own temp names under <workspace>/.cache (randomName below). */
144
+ const OWN_TEMP = /^dep(?:-restore|-snap|-npmcache)?-[0-9a-f]{16}(?:\.tgz)?$/;
145
+
146
+ class Skip extends Error {
147
+ /** `detail`: log-only facts about why (a rule name and counts, never path contents). */
148
+ constructor(reason, detail) {
149
+ super(reason);
150
+ this.reason = reason;
151
+ this.detail = detail;
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------- tar parsing
156
+
157
+ /** Pulls exact byte counts out of an async iterable of Buffers. */
158
+ class ByteReader {
159
+ constructor(iterable) {
160
+ this.iterator = iterable[Symbol.asyncIterator]();
161
+ this.chunks = [];
162
+ this.buffered = 0;
163
+ this.done = false;
164
+ }
165
+
166
+ async fill(count) {
167
+ while (this.buffered < count && !this.done) {
168
+ const next = await this.iterator.next();
169
+ if (next.done) this.done = true;
170
+ else {
171
+ this.chunks.push(next.value);
172
+ this.buffered += next.value.length;
173
+ }
174
+ }
175
+ return this.buffered >= count;
176
+ }
177
+
178
+ /** Exactly `count` bytes, or null at the end of the stream. */
179
+ async read(count) {
180
+ if (!(await this.fill(count))) return null;
181
+ const all = this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks);
182
+ const out = all.subarray(0, count);
183
+ const rest = all.subarray(count);
184
+ this.chunks = rest.length ? [rest] : [];
185
+ this.buffered = rest.length;
186
+ return out;
187
+ }
188
+
189
+ /** Hands `count` bytes to `onChunk` piece by piece, without holding them all. */
190
+ async consume(count, onChunk) {
191
+ let left = count;
192
+ while (left > 0) {
193
+ if (this.buffered === 0 && !(await this.fill(1))) throw new Skip("unsafe_archive");
194
+ const head = this.chunks[0];
195
+ const take = Math.min(left, head.length);
196
+ if (onChunk) await onChunk(head.subarray(0, take));
197
+ if (take === head.length) this.chunks.shift();
198
+ else this.chunks[0] = head.subarray(take);
199
+ this.buffered -= take;
200
+ left -= take;
201
+ }
202
+ }
203
+
204
+ async close() {
205
+ await this.iterator.return?.();
206
+ }
207
+ }
208
+
209
+ function cString(block, start, length) {
210
+ const field = block.subarray(start, start + length);
211
+ const end = field.indexOf(0);
212
+ return field.subarray(0, end === -1 ? length : end).toString("utf8");
213
+ }
214
+
215
+ function octal(block, start, length) {
216
+ // A base-256 number (high bit set) is only needed past 8 GiB: refuse it.
217
+ if (block[start] & 0x80) throw new Skip("unsafe_archive");
218
+ const text = cString(block, start, length).trim();
219
+ if (!/^[0-7]*$/.test(text)) throw new Skip("unsafe_archive");
220
+ return text ? parseInt(text, 8) : 0;
221
+ }
222
+
223
+ function checksumOk(block) {
224
+ let sum = 0;
225
+ for (let index = 0; index < 512; index += 1) sum += index >= 148 && index < 156 ? 32 : block[index];
226
+ return octal(block, 148, 8) === sum;
227
+ }
228
+
229
+ /** The records of a pax extended header that matter here. */
230
+ function parsePax(data) {
231
+ const out = {};
232
+ let offset = 0;
233
+ while (offset < data.length) {
234
+ const space = data.indexOf(0x20, offset);
235
+ if (space === -1) throw new Skip("unsafe_archive");
236
+ const length = Number(data.subarray(offset, space).toString("ascii"));
237
+ if (!Number.isInteger(length) || length <= space - offset || offset + length > data.length)
238
+ throw new Skip("unsafe_archive");
239
+ const record = data.subarray(space + 1, offset + length - 1).toString("utf8");
240
+ const equals = record.indexOf("=");
241
+ if (equals === -1) throw new Skip("unsafe_archive");
242
+ const key = record.slice(0, equals);
243
+ const value = record.slice(equals + 1);
244
+ if (key === "path" || key === "linkpath") out[key] = value;
245
+ else if (key === "size") {
246
+ if (!/^\d+$/.test(value)) throw new Skip("unsafe_archive");
247
+ out.size = Number(value);
248
+ }
249
+ offset += length;
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * Walks a gzipped tar file entry by entry. `onEntry({ path, type, size,
256
+ * linkname, mode })` returns an optional data sink (an async function taking
257
+ * each chunk) for a regular file. Supports ustar, GNU long names (L/K) and pax
258
+ * extended headers (x); every other meta entry is refused.
259
+ */
260
+ async function walkTar(file, { signal, maxStreamBytes }, onEntry) {
261
+ let seen = 0;
262
+ const counter = new Transform({
263
+ transform(chunk, _encoding, callback) {
264
+ seen += chunk.length;
265
+ if (seen > maxStreamBytes) callback(new Skip("over_budget"));
266
+ else callback(null, chunk);
267
+ },
268
+ });
269
+ const source = createReadStream(file);
270
+ const gunzip = createGunzip();
271
+ source.on("error", (error) => gunzip.destroy(error));
272
+ const decompressed = source
273
+ .pipe(gunzip)
274
+ .on("error", (error) => counter.destroy(error))
275
+ .pipe(counter);
276
+ const reader = new ByteReader(decompressed);
277
+ try {
278
+ let pending = {};
279
+ for (;;) {
280
+ signal?.throwIfAborted();
281
+ const block = await reader.read(512);
282
+ if (!block) throw new Skip("unsafe_archive"); // no end-of-archive marker
283
+ if (block.every((byte) => byte === 0)) return; // end of archive
284
+ if (!checksumOk(block)) throw new Skip("unsafe_archive");
285
+ const type = String.fromCharCode(block[156] || 0x30);
286
+ let size = octal(block, 124, 12);
287
+ const padding = (512 - (size % 512)) % 512;
288
+ if (type === "L" || type === "K" || type === "x") {
289
+ if (size > (type === "x" ? MAX_PAX_BYTES : MAX_NAME_BYTES)) throw new Skip("unsafe_archive");
290
+ const data = await reader.read(size + padding);
291
+ if (!data) throw new Skip("unsafe_archive");
292
+ const body = data.subarray(0, size);
293
+ if (type === "x") pending = { ...pending, ...parsePax(body) };
294
+ else {
295
+ const end = body.indexOf(0);
296
+ const text = body.subarray(0, end === -1 ? body.length : end).toString("utf8");
297
+ if (type === "L") pending.path = text;
298
+ else pending.linkpath = text;
299
+ }
300
+ continue;
301
+ }
302
+ const magic = block.subarray(257, 263).toString("latin1");
303
+ const prefix = magic === "ustar\0" ? cString(block, 345, 155) : "";
304
+ const name = cString(block, 0, 100);
305
+ const path = pending.path ?? (prefix ? `${prefix}/${name}` : name);
306
+ const linkname = pending.linkpath ?? cString(block, 157, 100);
307
+ if (pending.size !== undefined) size = pending.size;
308
+ pending = {};
309
+ const sink = await onEntry({ path, type, size, linkname, mode: octal(block, 100, 8) });
310
+ const dataPadding = (512 - (size % 512)) % 512;
311
+ await reader.consume(size, sink);
312
+ await reader.consume(dataPadding);
313
+ }
314
+ } finally {
315
+ await reader.close().catch(() => undefined);
316
+ source.destroy();
317
+ }
318
+ }
319
+
320
+ /**
321
+ * The rules every entry must pass, kept as state across one walk:
322
+ * - only regular files ("0"), directories ("5") and symlinks ("2");
323
+ * - a relative path under `node_modules/`, with no empty, "." or ".." segment;
324
+ * - no path seen twice (counting the parents a path implies), and nothing
325
+ * beneath a symlink or a file;
326
+ * - a symlink's target, taken from the link's own folder, stays in
327
+ * node_modules (and is never absolute);
328
+ * - the summed file sizes and the entry count stay under the budget.
329
+ */
330
+ class EntryRules {
331
+ constructor(budgetBytes, maxEntries) {
332
+ this.budgetBytes = budgetBytes;
333
+ this.maxEntries = maxEntries;
334
+ this.kinds = new Map();
335
+ this.folded = new Map();
336
+ this.unpackedBytes = 0;
337
+ this.entries = 0;
338
+ }
339
+
340
+ check(entry) {
341
+ const kind =
342
+ entry.type === "0" || entry.type === "\0"
343
+ ? "file"
344
+ : entry.type === "5"
345
+ ? "dir"
346
+ : entry.type === "2"
347
+ ? "link"
348
+ : null;
349
+ if (!kind) throw new Skip("unsafe_archive");
350
+ const path = kind === "dir" ? entry.path.replace(/\/+$/, "") : entry.path;
351
+ if (!safeRelative(path)) throw new Skip("unsafe_archive");
352
+ const segments = path.split("/");
353
+ if (segments[0] !== ROOT || (segments.length === 1 && kind !== "dir")) throw new Skip("unsafe_archive");
354
+ for (let depth = 1; depth < segments.length; depth += 1) {
355
+ const parent = segments.slice(0, depth).join("/");
356
+ const parentKind = this.kinds.get(parent);
357
+ if (parentKind && parentKind !== "dir" && parentKind !== "implied") throw new Skip("unsafe_archive");
358
+ if (!parentKind) this.claim(parent, "implied");
359
+ }
360
+ const existing = this.kinds.get(path);
361
+ // A folder's own entry may follow entries that implied it; nothing else repeats.
362
+ if (existing && !(existing === "implied" && kind === "dir")) throw new Skip("unsafe_archive");
363
+ this.claim(path, kind);
364
+ if (kind === "link") {
365
+ const target = entry.linkname;
366
+ if (!plainLinkTarget(path, target)) throw new Skip("unsafe_archive");
367
+ }
368
+ this.entries += 1;
369
+ if (this.entries > this.maxEntries) throw new Skip("over_budget");
370
+ if (kind === "file") {
371
+ this.unpackedBytes += entry.size;
372
+ if (this.unpackedBytes > this.budgetBytes) throw new Skip("over_budget");
373
+ } else if (entry.size !== 0) throw new Skip("unsafe_archive");
374
+ return { kind, path };
375
+ }
376
+
377
+ /** Records a path. Two different paths that a case-insensitive or
378
+ * normalization-insensitive file system (APFS, NTFS) would treat as one are
379
+ * refused: there `a/s` is the link `a/S`, and writing beneath it escapes. */
380
+ claim(path, kind) {
381
+ const key = foldPath(path);
382
+ const owner = this.folded.get(key);
383
+ if (owner !== undefined && owner !== path)
384
+ throw new Skip("unsafe_archive", {
385
+ rule: "case_fold_duplicate",
386
+ pathLength: path.length,
387
+ entry: this.entries + 1,
388
+ });
389
+ this.folded.set(key, path);
390
+ this.kinds.set(path, kind);
391
+ }
392
+ }
393
+
394
+ /** Case- and normalization-folded form of a path (over-folding only refuses more). */
395
+ function foldPath(path) {
396
+ return path.normalize("NFC").toUpperCase().toLowerCase().normalize("NFC");
397
+ }
398
+
399
+ function safeRelative(path) {
400
+ if (!path || path.startsWith("/") || path.includes("\0") || path.includes("\\")) return false;
401
+ return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
402
+ }
403
+
404
+ /**
405
+ * A symlink target this module accepts: relative, `..` only as leading
406
+ * segments, and inside node_modules when resolved from the link's folder.
407
+ * The kernel applies `..` after following each link in the path, so a `..`
408
+ * after a named segment (`a/s/../..`) can leave the folder that a lexical
409
+ * normalize says it stays in when `a/s` is itself a link. npm's own links
410
+ * (`.bin` entries, `file:` packages) only ever climb first, then descend.
411
+ */
412
+ function plainLinkTarget(linkPath, target) {
413
+ if (!target || target.includes("\0") || target.startsWith("/")) return false;
414
+ let descended = false;
415
+ for (const segment of target.split("/")) {
416
+ if (segment === "..") {
417
+ if (descended) return false;
418
+ } else if (segment !== "" && segment !== ".") descended = true;
419
+ }
420
+ return insideRoot(posix.normalize(posix.join(posix.dirname(linkPath), target)));
421
+ }
422
+
423
+ function insideRoot(normalized) {
424
+ return normalized === ROOT || normalized.startsWith(`${ROOT}/`);
425
+ }
426
+
427
+ /** Checks every entry of the archive without writing anything. */
428
+ export async function scanArchive(file, { budgetBytes, maxEntries = MAX_ARCHIVE_ENTRIES, signal } = {}) {
429
+ const rules = new EntryRules(budgetBytes, maxEntries);
430
+ await walkTar(file, { signal, maxStreamBytes: streamCap(budgetBytes, maxEntries) }, (entry) => {
431
+ rules.check(entry);
432
+ return undefined;
433
+ });
434
+ return { entries: rules.entries, unpackedBytes: rules.unpackedBytes };
435
+ }
436
+
437
+ // The decompressed stream is file bytes plus headers and padding; bound it too.
438
+ const streamCap = (budgetBytes, maxEntries) => budgetBytes + (maxEntries + 2) * 2048 + MAX_PAX_BYTES;
439
+
440
+ // "wx" plus O_NOFOLLOW where the platform has it: never follows, creates or replaces.
441
+ // O_EXCL alone already refuses any existing name, a planted link included (and
442
+ // makes every written file a new one with a single link: see verifyLinks);
443
+ // O_NOFOLLOW is a second guard where the platform has it.
444
+ const CREATE_FLAGS = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
445
+
446
+ /** Each folder from `destination` down through `segments`, made if missing;
447
+ * refused if any of them is a symlink or not a folder (defence in depth
448
+ * against a file system that resolves two entry names to one path).
449
+ * mkdir first (it never follows a link at the last component): a folder it
450
+ * made is a real one; only when something already exists there (EEXIST)
451
+ * is it checked with lstat. Parents are checked before children, and each
452
+ * folder once (`verified`). */
453
+ async function safeDirectory(destination, segments, verified) {
454
+ let current = destination;
455
+ for (const segment of ["", ...segments]) {
456
+ if (segment) current = join(current, segment);
457
+ if (verified.has(current)) continue;
458
+ try {
459
+ await mkdir(current, { mode: 0o755 });
460
+ verified.add(current);
461
+ continue;
462
+ } catch (error) {
463
+ if (error?.code !== "EEXIST") throw new Skip("unsafe_archive");
464
+ }
465
+ let info;
466
+ try {
467
+ info = await lstat(current);
468
+ } catch {
469
+ throw new Skip("unsafe_archive");
470
+ }
471
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Skip("unsafe_archive");
472
+ verified.add(current);
473
+ }
474
+ }
475
+
476
+ /** Files up to this size are gathered in memory and written in one go. */
477
+ const BUFFERED_FILE_BYTES = 1 * MIB;
478
+
479
+ /** Creates `target` (never following or replacing anything) and writes
480
+ * `data` to it with synchronous calls: for a small file the three async
481
+ * round trips of open/write/close cost more than the work itself. */
482
+ function writeNewFileSync(target, mode, data) {
483
+ const fd = openSync(target, CREATE_FLAGS, mode);
484
+ try {
485
+ let offset = 0;
486
+ while (offset < data.length) offset += writeSync(fd, data, offset, data.length - offset);
487
+ } finally {
488
+ closeSync(fd);
489
+ }
490
+ }
491
+
492
+ /** Writes the checked entries under `destination` (the same walk and rules,
493
+ * so what is written is exactly what was checked). No owner, no setuid.
494
+ * Returns the symlinks it made (absolute paths, in archive order), for
495
+ * verifyLinks once the whole tree is written. */
496
+ export async function extractArchive(file, destination, { budgetBytes, maxEntries = MAX_ARCHIVE_ENTRIES, signal }) {
497
+ const rules = new EntryRules(budgetBytes, maxEntries);
498
+ const verified = new Set();
499
+ const links = [];
500
+ let openHandle;
501
+ try {
502
+ await extractEntries();
503
+ } finally {
504
+ await openHandle?.close().catch(() => undefined);
505
+ }
506
+ return { links };
507
+ async function extractEntries() {
508
+ await walkTar(file, { signal, maxStreamBytes: streamCap(budgetBytes, maxEntries) }, async (entry) => {
509
+ const { kind, path } = rules.check(entry);
510
+ signal?.throwIfAborted();
511
+ const segments = path.split("/");
512
+ const target = join(destination, ...segments);
513
+ if (kind === "dir") {
514
+ await safeDirectory(destination, segments, verified);
515
+ return undefined;
516
+ }
517
+ await safeDirectory(destination, segments.slice(0, -1), verified);
518
+ if (kind === "link") {
519
+ await symlink(entry.linkname, target);
520
+ links.push(target);
521
+ return undefined;
522
+ }
523
+ const mode = entry.mode & 0o111 ? 0o755 : 0o644;
524
+ if (entry.size <= BUFFERED_FILE_BYTES) {
525
+ if (entry.size === 0) {
526
+ writeNewFileSync(target, mode, Buffer.alloc(0));
527
+ return undefined;
528
+ }
529
+ // A file that arrives in one piece is written from it directly;
530
+ // otherwise its pieces are copied into one buffer of its declared size.
531
+ let buffer;
532
+ let filled = 0;
533
+ return (chunk) => {
534
+ if (!buffer && chunk.length === entry.size) {
535
+ writeNewFileSync(target, mode, chunk);
536
+ return;
537
+ }
538
+ buffer ??= Buffer.allocUnsafe(entry.size);
539
+ chunk.copy(buffer, filled);
540
+ filled += chunk.length;
541
+ if (filled === entry.size) writeNewFileSync(target, mode, buffer);
542
+ };
543
+ }
544
+ const handle = await open(target, CREATE_FLAGS, mode);
545
+ openHandle = handle;
546
+ let written = 0;
547
+ return async (chunk) => {
548
+ written += chunk.length;
549
+ await handle.write(chunk);
550
+ if (written === entry.size) {
551
+ openHandle = undefined;
552
+ await handle.close();
553
+ }
554
+ };
555
+ });
556
+ }
557
+ }
558
+
559
+ /**
560
+ * After extraction: every symlink extractArchive made must pass the same
561
+ * target rule as the scan, and its native realpath (the kernel's resolution,
562
+ * following every link on the way) must be inside the real node_modules
563
+ * folder. Run once the whole tree is written, since a link's resolution can
564
+ * depend on links written after it.
565
+ *
566
+ * Only the links need checking on disk; extraction itself guarantees the
567
+ * rest, so the tree is not walked again (a walk is one call per file, which
568
+ * is slow under gVisor):
569
+ * - it creates only files, folders and symlinks (EntryRules), so no other
570
+ * kind of entry exists;
571
+ * - each file is created with O_CREAT | O_EXCL (CREATE_FLAGS) in a folder it
572
+ * made or checked (safeDirectory, never through a link), so a file is
573
+ * always new and has one link: a hard link cannot appear (tar hard-link
574
+ * entries are refused) and nothing else writes to the tree before the
575
+ * restore finishes.
576
+ *
577
+ * So this is only a complete check for a tree extractArchive wrote alone:
578
+ * `links` must be every link it reported, and nothing else may have written
579
+ * to `root` since. Each listed path is lstat'ed through its parent folders,
580
+ * which extraction guarantees are real folders.
581
+ *
582
+ * A dangling link (no realpath) is accepted only on the target rule: its
583
+ * folder is a real folder (safeDirectory never makes or follows a link), its
584
+ * leading `..` segments are therefore resolved exactly as written, and every
585
+ * later segment only descends, through real folders or through links that
586
+ * are themselves checked here. So whatever later fills the gap is inside.
587
+ * npm leaves such links for optional packages it skipped.
588
+ */
589
+ export async function verifyLinks(root, links, signal) {
590
+ const realTop = await realpath(join(root, ROOT));
591
+ for (const path of links) {
592
+ signal?.throwIfAborted();
593
+ let info;
594
+ try {
595
+ info = lstatSync(path);
596
+ } catch {
597
+ throw new Skip("unsafe_archive");
598
+ }
599
+ if (!info.isSymbolicLink()) throw new Skip("unsafe_archive");
600
+ const target = await readlink(path);
601
+ if (!plainLinkTarget(toPosix(path.slice(root.length + 1)), target)) throw new Skip("unsafe_archive");
602
+ let real;
603
+ try {
604
+ real = await realpath(path); // native: resolves `..` after each link, like open() will
605
+ } catch (error) {
606
+ if (error?.code === "ENOENT") continue;
607
+ throw new Skip("unsafe_archive"); // ELOOP and the like
608
+ }
609
+ if (real !== realTop && !real.startsWith(realTop + sep)) throw new Skip("unsafe_archive");
610
+ }
611
+ }
612
+
613
+ const toPosix = (path) => path.split("\\").join("/");
614
+
615
+ // ---------------------------------------------------------------- consistency
616
+
617
+ /**
618
+ * Compares npm's hidden lockfile (`node_modules/.package-lock.json`) and the
619
+ * installed package.json files with the committed lockfile's `packages`.
620
+ * Only keys under `node_modules/` are compared (the tree this module
621
+ * restores). Compared: the set of paths, `version`, `integrity` (when the
622
+ * lockfile has one) and `link`/`resolved` for links. Ignored, because npm
623
+ * rewrites or derives them: `resolved` of registry packages (the registry
624
+ * host is replaced), `dev`/`optional`/`devOptional`/`peer`, `license`,
625
+ * `engines`, `os`/`cpu`, `bin`, `funding`, dependency maps and the like.
626
+ * An `optional`/`devOptional` entry may be missing (npm skips optional
627
+ * packages for another platform) but then must not be on disk either.
628
+ */
629
+ export async function checkConsistency(projectRoot, lockfile, signal) {
630
+ const fail = (detail) => ({ ok: false, detail });
631
+ let hidden;
632
+ try {
633
+ hidden = JSON.parse(await readFile(join(projectRoot, ROOT, ".package-lock.json"), "utf8"));
634
+ } catch {
635
+ return fail("no_hidden_lockfile");
636
+ }
637
+ const installed = hidden && typeof hidden.packages === "object" && hidden.packages ? hidden.packages : null;
638
+ if (!installed) return fail("no_hidden_lockfile");
639
+ const wanted = lockfile.packages;
640
+ const inTree = (key) => key.startsWith(`${ROOT}/`);
641
+ for (const key of Object.keys(installed)) {
642
+ if (!inTree(key)) continue;
643
+ if (!Object.hasOwn(wanted, key)) return fail("extraneous");
644
+ }
645
+ for (const [key, entry] of Object.entries(wanted)) {
646
+ signal?.throwIfAborted();
647
+ if (!inTree(key) || !entry || typeof entry !== "object") continue;
648
+ if (!safeRelative(key)) return fail("bad_key");
649
+ const have = Object.hasOwn(installed, key) ? installed[key] : undefined;
650
+ if (!have) {
651
+ if (!entry.optional && !entry.devOptional) return fail("missing");
652
+ if (await present(join(projectRoot, ...key.split("/")))) return fail("unlisted");
653
+ continue;
654
+ }
655
+ if (Boolean(entry.link) !== Boolean(have.link)) return fail("link");
656
+ if (entry.link) {
657
+ if (entry.resolved !== have.resolved) return fail("link");
658
+ continue;
659
+ }
660
+ if (entry.version !== have.version) return fail("version");
661
+ if (entry.integrity !== undefined && entry.integrity !== have.integrity) return fail("integrity");
662
+ let manifest;
663
+ try {
664
+ manifest = JSON.parse(await readFile(join(projectRoot, ...key.split("/"), "package.json"), "utf8"));
665
+ } catch {
666
+ return fail("manifest");
667
+ }
668
+ if (manifest?.version !== entry.version) return fail("manifest");
669
+ }
670
+ return { ok: true };
671
+ }
672
+
673
+ async function present(path) {
674
+ try {
675
+ await lstat(path);
676
+ return true;
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+
682
+ // ---------------------------------------------------------------- HTTP
683
+
684
+ function timeoutSignal(signal, ms) {
685
+ return signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
686
+ }
687
+
688
+ /** Maps a failed call to a stable reason: a timeout (or the overall
689
+ * deadline) is `timeout`; anything else is `failure`. */
690
+ function asSkip(error, failure) {
691
+ if (error instanceof Skip) return error;
692
+ if (error?.name === "TimeoutError" || error?.cause?.name === "TimeoutError") return new Skip("timeout");
693
+ if (error?.name === "AbortError" && error?.cause instanceof Skip) return error.cause;
694
+ if (error?.name === "AbortError") return new Skip("timeout");
695
+ return new Skip(failure);
696
+ }
697
+
698
+ /** One request. Resolves with the response once headers arrive; the signal
699
+ * (and its timeout) keeps bounding the body after that. */
700
+ function request(url, { method = "GET", headers = {}, body, signal }) {
701
+ return new Promise((resolve, reject) => {
702
+ const client = url.protocol === "https:" ? https : http;
703
+ const outgoing = client.request(url, { method, headers, signal }, resolve);
704
+ outgoing.on("error", reject);
705
+ if (body === undefined) outgoing.end();
706
+ else if (Buffer.isBuffer(body)) outgoing.end(body);
707
+ else pipeline(body, outgoing).catch((error) => outgoing.destroy(error));
708
+ });
709
+ }
710
+
711
+ async function readJson(response) {
712
+ const chunks = [];
713
+ let size = 0;
714
+ for await (const chunk of response) {
715
+ size += chunk.length;
716
+ if (size > MAX_JSON_BYTES) throw new Error("too_large");
717
+ chunks.push(chunk);
718
+ }
719
+ try {
720
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
721
+ } catch {
722
+ return undefined;
723
+ }
724
+ }
725
+
726
+ async function callJson(url, options, timeoutMs, failure) {
727
+ try {
728
+ const signal = timeoutSignal(options.signal, timeoutMs);
729
+ const response = await request(url, { ...options, signal });
730
+ return { status: response.statusCode ?? 0, body: await readJson(response) };
731
+ } catch (error) {
732
+ throw asSkip(error, failure);
733
+ }
734
+ }
735
+
736
+ // ---------------------------------------------------------------- child processes
737
+
738
+ /** Runs a command with no output captured (npm output is never logged),
739
+ * in its own process group so a timeout kills everything it started. */
740
+ function run(command, args, { cwd, env, timeoutMs, killGraceMs, signal, session, stdout }) {
741
+ return new Promise((resolve, reject) => {
742
+ if (signal?.aborted || session?.closed) {
743
+ reject(asSkip(signal?.reason, "timeout"));
744
+ return;
745
+ }
746
+ let child;
747
+ try {
748
+ child = spawn(command, args, {
749
+ cwd,
750
+ env,
751
+ stdio: ["ignore", stdout ? "pipe" : "ignore", "ignore"],
752
+ detached: true,
753
+ });
754
+ } catch {
755
+ reject(new Skip("install_failed"));
756
+ return;
757
+ }
758
+ session?.children.add(child.pid);
759
+ let reason;
760
+ let exited = false;
761
+ const kill = (why) => {
762
+ if (reason) return;
763
+ reason = why;
764
+ if (exited) return;
765
+ const group = (sig) => {
766
+ try {
767
+ process.kill(-child.pid, sig);
768
+ } catch {
769
+ // already gone
770
+ }
771
+ };
772
+ group("SIGTERM");
773
+ setTimeout(() => group("SIGKILL"), killGraceMs).unref();
774
+ };
775
+ const timer = setTimeout(() => kill(new Skip("timeout")), timeoutMs);
776
+ const onAbort = () => kill(asSkip(signal.reason, "timeout"));
777
+ signal?.addEventListener("abort", onAbort, { once: true });
778
+ // `stdout(stream)`: consumes the output (only tar's archive uses it); its
779
+ // failure kills the command with its reason, else `tar_failed` (e.g. EIO, ENOSPC).
780
+ const consumed = stdout
781
+ ? stdout(child.stdout).then(
782
+ () => undefined,
783
+ (error) => kill(asSkip(error, "tar_failed")),
784
+ )
785
+ : Promise.resolve();
786
+ child.on("error", () => {
787
+ session?.children.delete(child.pid);
788
+ clearTimeout(timer);
789
+ signal?.removeEventListener("abort", onAbort);
790
+ reject(new Skip("install_failed"));
791
+ });
792
+ child.on("exit", (code) => {
793
+ exited = true;
794
+ session?.children.delete(child.pid);
795
+ clearTimeout(timer);
796
+ signal?.removeEventListener("abort", onAbort);
797
+ void consumed.then(() => {
798
+ if (reason) reject(reason);
799
+ else resolve(code ?? 1);
800
+ });
801
+ });
802
+ });
803
+ }
804
+
805
+ // ---------------------------------------------------------------- setup
806
+
807
+ /** The plan URL and token, read exactly as the npm shim reads them: the
808
+ * registry is npm_config_registry, which must be WARDBY_NPM_PLAN_REGISTRY,
809
+ * and the token is that registry's _authToken in the driver's npmrc. */
810
+ async function registryAuth(env) {
811
+ const registry = env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY;
812
+ const userconfig = env.npm_config_userconfig ?? env.NPM_CONFIG_USERCONFIG;
813
+ if (!registry || !userconfig) return undefined;
814
+ const base = registry.endsWith("/") ? registry : `${registry}/`;
815
+ if (!env.WARDBY_NPM_PLAN_REGISTRY || base !== env.WARDBY_NPM_PLAN_REGISTRY) return undefined;
816
+ const prefix = `${base.replace(/^https?:/, "")}:_authToken=`;
817
+ let npmrc;
818
+ try {
819
+ npmrc = await readFile(userconfig, "utf8");
820
+ } catch {
821
+ return undefined;
822
+ }
823
+ const line = npmrc.split(/\r?\n/).find((entry) => entry.trim().startsWith(prefix));
824
+ const token = line?.trim().slice(prefix.length).trim();
825
+ return token ? { base, token } : undefined;
826
+ }
827
+
828
+ /** The workspace budget in bytes: `budgetBytes`, else CODING_DISK_MB, else
829
+ * DEFAULT_BUDGET_MB; never more than the file system has free. */
830
+ async function workspaceBudget(workspace, env, budgetBytes) {
831
+ const fromEnv = Number(env.CODING_DISK_MB);
832
+ let budget = budgetBytes ?? (Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv * MIB : DEFAULT_BUDGET_MB * MIB);
833
+ try {
834
+ const fs = await statfs(workspace);
835
+ budget = Math.min(budget, Number(fs.bavail) * Number(fs.bsize));
836
+ } catch {
837
+ // unknown: the budget alone
838
+ }
839
+ return { remaining: Math.max(0, budget), limit: Math.max(0, budget) };
840
+ }
841
+
842
+ /** `<workspace>/.cache`, created if missing; refused when it is anything but
843
+ * a real folder (a repo could commit a `.cache` symlink). */
844
+ async function cacheDirectory(workspace) {
845
+ const path = join(workspace, ".cache");
846
+ try {
847
+ const info = await lstat(path);
848
+ if (!info.isDirectory()) throw new Skip("cache_dir_unsafe");
849
+ } catch (error) {
850
+ if (error instanceof Skip) throw error;
851
+ try {
852
+ await mkdir(path, { mode: 0o700 });
853
+ } catch {
854
+ throw new Skip("cache_dir_unsafe");
855
+ }
856
+ }
857
+ return path;
858
+ }
859
+
860
+ const randomName = (prefix) => `${prefix}-${randomBytes(8).toString("hex")}`;
861
+
862
+ /** Remembers a temp path for this project and for the session, so the
863
+ * session can still remove it if the project never settles. */
864
+ function track(context, ...paths) {
865
+ context.temps.push(...paths);
866
+ for (const path of paths) context.session.temps?.add(path);
867
+ }
868
+
869
+ // ---------------------------------------------------------------- one project
870
+
871
+ /** The project's lockfile, read once: its exact bytes are what is planned,
872
+ * hashed and compared against. */
873
+ async function readProject(projectDir) {
874
+ let bytes;
875
+ try {
876
+ bytes = await readFile(join(projectDir, "package-lock.json"));
877
+ } catch {
878
+ throw new Skip("no_lockfile");
879
+ }
880
+ let lockfile;
881
+ let manifest;
882
+ try {
883
+ lockfile = JSON.parse(bytes.toString("utf8"));
884
+ } catch {
885
+ throw new Skip("lockfile_unreadable");
886
+ }
887
+ if (!lockfile || typeof lockfile !== "object" || !(lockfile.lockfileVersion >= 2)) throw new Skip("lockfile_version");
888
+ if (!lockfile.packages || typeof lockfile.packages !== "object") throw new Skip("lockfile_unreadable");
889
+ try {
890
+ manifest = JSON.parse(await readFile(join(projectDir, "package.json"), "utf8"));
891
+ } catch {
892
+ throw new Skip("no_package_json");
893
+ }
894
+ if (manifest && typeof manifest === "object" && manifest.workspaces !== undefined) throw new Skip("workspaces");
895
+ if (await present(join(projectDir, "npm-shrinkwrap.json"))) throw new Skip("shrinkwrap_present");
896
+ if (await present(join(projectDir, ROOT))) throw new Skip("node_modules_present");
897
+ if (!Object.keys(lockfile.packages).some((key) => key.startsWith(`${ROOT}/`))) throw new Skip("no_dependencies");
898
+ return { bytes, lockfile, manifestBytes: await readFile(join(projectDir, "package.json")) };
899
+ }
900
+
901
+ /** Metadata from a trusted pre-agent upload is usable only when the downloaded
902
+ * archive also verifies against its recorded digest. Legacy snapshots omit it
903
+ * and deliberately retain the full scan before extraction. */
904
+ function archiveManifest(value) {
905
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
906
+ const record = value;
907
+ if (
908
+ record.scanVersion !== 1 ||
909
+ typeof record.sha256 !== "string" ||
910
+ !/^[0-9a-f]{64}$/.test(record.sha256) ||
911
+ !Number.isSafeInteger(record.entries) ||
912
+ record.entries < 1 ||
913
+ record.entries > MAX_ARCHIVE_ENTRIES ||
914
+ !Number.isSafeInteger(record.unpackedBytes) ||
915
+ record.unpackedBytes < 0
916
+ )
917
+ return undefined;
918
+ return { sha256: record.sha256, entries: record.entries, unpackedBytes: record.unpackedBytes };
919
+ }
920
+
921
+ /** Streams a snapshot to `file`, checking its length and sha256 on the way. */
922
+ async function download(context, id, file, maxBytes, manifest) {
923
+ const { cacheBase, query, token, signal, timeouts } = context;
924
+ const url = new URL(`blob/${id}?${query}`, cacheBase);
925
+ // The size is unknown until the headers arrive: bound by maxBytes.
926
+ const timeoutMs = timeouts.transferMs + Math.ceil(maxBytes / MIB) * timeouts.transferMsPerMiB;
927
+ const bounded = timeoutSignal(signal, timeoutMs);
928
+ let response;
929
+ try {
930
+ response = await request(url, { headers: { authorization: `Bearer ${token}` }, signal: bounded });
931
+ } catch (error) {
932
+ throw asSkip(error, "download_failed");
933
+ }
934
+ if (response.statusCode !== 200) {
935
+ response.resume();
936
+ throw new Skip("download_failed");
937
+ }
938
+ const length = Number(response.headers["content-length"]);
939
+ const expected = String(response.headers["x-wardby-sha256"] ?? "");
940
+ if (!Number.isSafeInteger(length) || length < 0 || !/^[0-9a-f]{64}$/.test(expected)) {
941
+ response.destroy();
942
+ throw new Skip("download_failed");
943
+ }
944
+ if (manifest && expected !== manifest.sha256) {
945
+ response.destroy();
946
+ throw new Skip("download_failed");
947
+ }
948
+ if (length > maxBytes) {
949
+ response.destroy();
950
+ throw new Skip("too_large");
951
+ }
952
+ const hash = createHash("sha256");
953
+ let received = 0;
954
+ const meter = new Transform({
955
+ transform(chunk, _encoding, callback) {
956
+ received += chunk.length;
957
+ if (received > length) callback(new Skip("size_mismatch"));
958
+ else {
959
+ hash.update(chunk);
960
+ callback(null, chunk);
961
+ }
962
+ },
963
+ });
964
+ try {
965
+ await pipeline(response, meter, createWriteStream(file, { flags: "wx", mode: 0o600 }), { signal: bounded });
966
+ } catch (error) {
967
+ throw asSkip(error, "download_failed");
968
+ }
969
+ if (received !== length) throw new Skip("size_mismatch");
970
+ if (hash.digest("hex") !== expected) throw new Skip("sha_mismatch");
971
+ return length;
972
+ }
973
+
974
+ /** Download, scan, extract into a temp folder, re-check the written tree,
975
+ * optionally check it against the lockfile, then rename into place. */
976
+ async function restore(context, project, id, maxBytes, { consistency, manifest }) {
977
+ const { cacheDir, signal, budget } = context;
978
+ const archive = join(cacheDir, `${randomName("dep")}.tgz`);
979
+ const temp = join(cacheDir, randomName("dep-restore"));
980
+ track(context, archive, temp);
981
+ const measure = async (stage, action) => {
982
+ const started = Date.now();
983
+ try {
984
+ return await action();
985
+ } finally {
986
+ context.log("restore_phase", { stage, ms: Date.now() - started });
987
+ }
988
+ };
989
+ const sizeBytes = await measure("download", () => download(context, id, archive, maxBytes, manifest));
990
+ const limit = Math.max(0, budget.remaining - sizeBytes);
991
+ const refused = (error) => {
992
+ const skip = asSkip(error, "unsafe_archive");
993
+ if (skip.detail) context.log("archive_refused", skip.detail);
994
+ throw skip;
995
+ };
996
+ // A manifest (the scan recorded at upload, bound to the digest download()
997
+ // just verified) stands in for a second scan. Its unpackedBytes only feeds
998
+ // the early estimate below and context.known: extractArchive's budgetBytes
999
+ // remains the real bound, and it applies every scan rule again.
1000
+ const scan =
1001
+ manifest ?? (await measure("scan", () => scanArchive(archive, { budgetBytes: limit, signal })).catch(refused));
1002
+ // This project's tree size and compression, for the estimates below (a cold
1003
+ // install after a rejected restore, the snapshot's archive size). Recorded
1004
+ // only from a snapshot that passed every check up to the point it was
1005
+ // judged: here, a scan that accepted every entry (refused only for room),
1006
+ // and below, a tree extracted and verified on disk. Never from one refused
1007
+ // as unsafe, whose sizes are untrusted.
1008
+ const measured = { unpackedBytes: scan.unpackedBytes, archiveBytes: sizeBytes };
1009
+ // A nearest is only worth restoring with room left for the delta install and the archive.
1010
+ if (!consistency && scan.unpackedBytes * NEAREST_NEED_FACTOR > limit) {
1011
+ context.known = measured;
1012
+ throw new Skip("budget_too_small");
1013
+ }
1014
+ await mkdir(temp, { mode: 0o700 });
1015
+ const { links } = await measure("extract", () => extractArchive(archive, temp, { budgetBytes: limit, signal })).catch(
1016
+ refused,
1017
+ );
1018
+ await removeTemp(context.workspace, archive);
1019
+ await context.testHooks?.beforeVerifyLinks?.(temp);
1020
+ await measure("verify_links", () => verifyLinks(temp, links, signal));
1021
+ context.known = measured;
1022
+ // A sanity check that this is the tree for this lockfile, not an
1023
+ // integrity control: integrity is the sha256 the proxy recorded in its
1024
+ // database at upload time, verified above against the bytes received.
1025
+ if (consistency) {
1026
+ const check = await measure("check_consistency", () => checkConsistency(temp, project.lockfile, signal));
1027
+ if (!check.ok) {
1028
+ context.log("inconsistent", { detail: check.detail });
1029
+ throw new Skip("inconsistent");
1030
+ }
1031
+ }
1032
+ const target = join(project.dir, ROOT);
1033
+ if (await present(target)) throw new Skip("node_modules_present");
1034
+ await context.testHooks?.beforeRename?.();
1035
+ // Past the deadline nothing new is put in place (checked right before the rename).
1036
+ if (signal.aborted || context.session.closed) throw asSkip(signal.reason, "timeout");
1037
+ context.created = target;
1038
+ await rename(join(temp, ROOT), target);
1039
+ await removeTemp(context.workspace, temp);
1040
+ // npm trusts its hidden lockfile only when it is newer than the folders.
1041
+ const now = new Date();
1042
+ await utimes(join(target, ".package-lock.json"), now, now).catch(() => undefined);
1043
+ budget.remaining -= scan.unpackedBytes;
1044
+ context.restoredBytes = scan.unpackedBytes;
1045
+ return sizeBytes;
1046
+ }
1047
+
1048
+ /** Bytes of the regular files under `dir` (links not followed). */
1049
+ async function treeBytes(dir, signal) {
1050
+ let total = 0;
1051
+ const stack = [dir];
1052
+ while (stack.length) {
1053
+ signal?.throwIfAborted();
1054
+ const directory = stack.pop();
1055
+ let names;
1056
+ try {
1057
+ names = await readdir(directory);
1058
+ } catch {
1059
+ continue;
1060
+ }
1061
+ for (const name of names) {
1062
+ const info = await lstat(join(directory, name)).catch(() => undefined);
1063
+ if (info?.isDirectory()) stack.push(join(directory, name));
1064
+ else if (info?.isFile()) total += info.size;
1065
+ }
1066
+ }
1067
+ return total;
1068
+ }
1069
+
1070
+ /** Charges the shared budget for what an install added beyond a restore. */
1071
+ async function chargeInstall(context, project) {
1072
+ const bytes = await treeBytes(join(project.dir, ROOT), context.signal);
1073
+ context.installedBytes = bytes;
1074
+ const added = Math.max(0, bytes - (context.restoredBytes ?? 0));
1075
+ context.budget.remaining = Math.max(0, context.budget.remaining - added);
1076
+ }
1077
+
1078
+ async function install(context, project, args) {
1079
+ const { env, npmPath, signal, timeouts } = context;
1080
+ context.created = join(project.dir, ROOT);
1081
+ const started = Date.now();
1082
+ const code = await run(npmPath, args, {
1083
+ cwd: project.dir,
1084
+ env,
1085
+ timeoutMs: timeouts.installMs,
1086
+ killGraceMs: timeouts.killGraceMs,
1087
+ signal,
1088
+ session: context.session,
1089
+ });
1090
+ const installMs = Date.now() - started;
1091
+ context.log("install", { command: args[0], code, ms: installMs });
1092
+ if (code !== 0) throw new Skip("install_failed");
1093
+ return installMs;
1094
+ }
1095
+
1096
+ /** Puts back package.json and package-lock.json if an install rewrote them:
1097
+ * the agent's working tree must look exactly as it was committed. */
1098
+ async function restoreCommitted(context, project) {
1099
+ if (context.session.closed) return;
1100
+ for (const [name, bytes] of [
1101
+ ["package-lock.json", project.bytes],
1102
+ ["package.json", project.manifestBytes],
1103
+ ]) {
1104
+ const path = join(project.dir, name);
1105
+ const now = await readFile(path).catch(() => undefined);
1106
+ if (!now || !now.equals(bytes)) await writeFile(path, bytes);
1107
+ }
1108
+ }
1109
+
1110
+ /** How long packing a snapshot may take: `tarMs` plus `tarMsPerMiB` for
1111
+ * every MiB of the installed tree, cut to what is left of the upload window
1112
+ * (an archive finished after it could not be uploaded). `limit` names the
1113
+ * bound that applies: `pack` or `upload_window`. Still bounded by `totalMs`
1114
+ * through the run's signal. */
1115
+ export function archiveLimit(timeouts, treeBytes, uploadDeadline, now = Date.now()) {
1116
+ const packMs = timeouts.tarMs + Math.ceil(Math.max(0, treeBytes) / MIB) * (timeouts.tarMsPerMiB ?? 0);
1117
+ if (uploadDeadline !== undefined && uploadDeadline - now < packMs)
1118
+ return { limit: "upload_window", limitMs: Math.max(0, uploadDeadline - now) };
1119
+ return { limit: "pack", limitMs: packMs };
1120
+ }
1121
+
1122
+ /** Archives node_modules and uploads it. Never throws: the installed tree is
1123
+ * fine either way, so a failure here only logs. */
1124
+ async function snapshot(context, project, maxBytes) {
1125
+ const { cacheDir, cacheBase, query, token, signal, timeouts, session } = context;
1126
+ const archive = join(cacheDir, `${randomName("dep-snap")}.tgz`);
1127
+ track(context, archive);
1128
+ const started = Date.now();
1129
+ let stage = "estimate";
1130
+ let estimate;
1131
+ let written = 0;
1132
+ // Which bound a timeout in this stage hit, and how long it allowed.
1133
+ let bound;
1134
+ const skip = (reason) => {
1135
+ if (reason !== "timeout") {
1136
+ context.log("snapshot_skipped", { reason });
1137
+ return undefined;
1138
+ }
1139
+ const fired = signal.aborted ? { limit: "deadline", limitMs: timeouts.totalMs } : (bound ?? {});
1140
+ context.log("snapshot_skipped", {
1141
+ reason,
1142
+ stage,
1143
+ writtenBytes: written,
1144
+ ...(estimate !== undefined ? { estimateBytes: estimate } : {}),
1145
+ elapsedMs: Date.now() - started,
1146
+ ...fired,
1147
+ });
1148
+ return undefined;
1149
+ };
1150
+ try {
1151
+ if (!(await present(join(project.dir, ROOT, ".package-lock.json")))) return skip("no_hidden_lockfile");
1152
+ // Only an archive that fits what is left of the disk budget, measured now
1153
+ // (npm's own writes included), is written; the installed tree stays either way.
1154
+ const room = await measureRemaining(context);
1155
+ estimate = archiveEstimate(context);
1156
+ if (estimate > room) {
1157
+ context.log("snapshot_skipped", {
1158
+ reason: "budget",
1159
+ stage: "estimate",
1160
+ estimateBytes: estimate,
1161
+ roomBytes: room,
1162
+ });
1163
+ return undefined;
1164
+ }
1165
+ bound = archiveLimit(timeouts, context.installedBytes ?? 0, context.uploadDeadline);
1166
+ // Not packed at all when the archive could no longer be uploaded.
1167
+ if (bound.limit === "upload_window" && bound.limitMs <= 0) return skip("upload_window_passed");
1168
+ stage = "archive";
1169
+ const code = await run("tar", ["-czf", "-", "-C", project.dir, ROOT], {
1170
+ env: { PATH: context.env.PATH ?? process.env.PATH ?? "", COPYFILE_DISABLE: "1" },
1171
+ timeoutMs: bound.limitMs,
1172
+ killGraceMs: timeouts.killGraceMs,
1173
+ signal,
1174
+ session,
1175
+ // Counted before each chunk is written: the file never grows past the room or maxBytes.
1176
+ stdout: (output) =>
1177
+ pipeline(
1178
+ output,
1179
+ new Transform({
1180
+ transform(chunk, _encoding, callback) {
1181
+ written += chunk.length;
1182
+ if (written > room) callback(new Skip("budget"));
1183
+ else if (written > maxBytes) callback(new Skip("too_large"));
1184
+ else callback(null, chunk);
1185
+ },
1186
+ }),
1187
+ createWriteStream(archive, { flags: "wx", mode: 0o600 }),
1188
+ ),
1189
+ }).catch((error) => asSkip(error, "tar_failed").reason);
1190
+ if (code !== 0) {
1191
+ const reason = typeof code === "string" ? code : "tar_failed";
1192
+ if (reason === "budget") {
1193
+ await removeTemp(context.workspace, archive);
1194
+ await context.testHooks?.afterArchiveAborted?.(archive);
1195
+ context.log("snapshot_skipped", { reason, stage: "archive", writtenBytes: written, roomBytes: room });
1196
+ return undefined;
1197
+ }
1198
+ return skip(reason);
1199
+ }
1200
+ const { size } = await stat(archive);
1201
+ if (size > maxBytes) return skip("too_large");
1202
+ // Upload only what a restore would accept. The proxy binds these measured
1203
+ // bounds to its digest, so a later restore that verifies that digest may
1204
+ // safely avoid parsing the same archive a second time.
1205
+ let validation;
1206
+ stage = "scan";
1207
+ bound = undefined;
1208
+ try {
1209
+ validation = await scanArchive(archive, { budgetBytes: Number.MAX_SAFE_INTEGER, signal });
1210
+ } catch (error) {
1211
+ return skip(asSkip(error, "unsafe_archive").reason);
1212
+ }
1213
+ if (session.sealed || signal.aborted) return skip("sealed");
1214
+ // The proxy refuses an upload after its window; don't start one that can't land.
1215
+ if (context.uploadDeadline !== undefined && Date.now() >= context.uploadDeadline)
1216
+ return skip("upload_window_passed");
1217
+ const timeoutMs = timeouts.transferMs + Math.ceil(size / MIB) * timeouts.transferMsPerMiB;
1218
+ stage = "upload";
1219
+ bound = { limit: "upload", limitMs: timeoutMs };
1220
+ const result = await callJson(
1221
+ new URL(`blob?${query}`, cacheBase),
1222
+ {
1223
+ method: "PUT",
1224
+ headers: {
1225
+ authorization: `Bearer ${token}`,
1226
+ "content-type": "application/gzip",
1227
+ "content-length": String(size),
1228
+ "x-wardby-archive-scan-version": "1",
1229
+ "x-wardby-archive-entries": String(validation.entries),
1230
+ "x-wardby-archive-unpacked-bytes": String(validation.unpackedBytes),
1231
+ },
1232
+ body: createReadStream(archive),
1233
+ signal,
1234
+ },
1235
+ timeoutMs,
1236
+ "upload_failed",
1237
+ );
1238
+ const stored = result.status === 201 ? "stored" : result.status === 200 ? "exists" : "refused";
1239
+ context.log("snapshot", {
1240
+ status: result.status,
1241
+ stored,
1242
+ ...(stored === "refused" && typeof result.body?.error === "string" ? { code: result.body.error } : {}),
1243
+ bytes: size,
1244
+ });
1245
+ return stored === "refused" ? undefined : size;
1246
+ } catch (error) {
1247
+ return skip(asSkip(error, "upload_failed").reason);
1248
+ } finally {
1249
+ await removeTemp(context.workspace, archive);
1250
+ }
1251
+ }
1252
+
1253
+ /** What is left of the disk budget now: the budget less what the workspace
1254
+ * grew by since it was measured (the same bounded walk as
1255
+ * dependencyCacheBudget), and never more than the file system has free.
1256
+ * Falls back to the running account when the walk can't finish. Updates
1257
+ * the shared budget, so the next project starts from what is really left. */
1258
+ async function measureRemaining(context) {
1259
+ const { budget, workspace, signal } = context;
1260
+ let room = budget.remaining;
1261
+ if (budget.limit !== undefined && budget.baseUsed !== undefined) {
1262
+ const used = await workspaceUsage(workspace, { signal });
1263
+ if (used !== undefined) room = budget.limit - (used - budget.baseUsed);
1264
+ }
1265
+ try {
1266
+ const fs = await statfs(workspace);
1267
+ room = Math.min(room, Number(fs.bavail) * Number(fs.bsize));
1268
+ } catch {
1269
+ // unknown: the budget alone
1270
+ }
1271
+ budget.remaining = Math.max(0, room);
1272
+ return budget.remaining;
1273
+ }
1274
+
1275
+ /** The archive's expected size: this project's tree at the compression its
1276
+ * last snapshot had (a nearest or rejected hit, when one was scanned), else
1277
+ * ARCHIVE_RATIO_ESTIMATE. A low guess only costs a tar that is stopped. */
1278
+ function archiveEstimate(context) {
1279
+ const installed = context.installedBytes ?? 0;
1280
+ const known = context.known;
1281
+ if (known && known.unpackedBytes > 0) return Math.ceil(installed * (known.archiveBytes / known.unpackedBytes));
1282
+ return Math.ceil(installed / ARCHIVE_RATIO_ESTIMATE);
1283
+ }
1284
+
1285
+ /** Room an install needs before it starts: twice this project's known tree
1286
+ * (node_modules plus npm's cache) when a snapshot of it was scanned, else
1287
+ * `minInstallBytes` (MIN_INSTALL_BUDGET_BYTES). */
1288
+ function installNeed(context) {
1289
+ const known = context.known?.unpackedBytes;
1290
+ if (known !== undefined) return Math.ceil(known * INSTALL_NEED_FACTOR);
1291
+ return context.minInstallBytes ?? MIN_INSTALL_BUDGET_BYTES;
1292
+ }
1293
+
1294
+ /**
1295
+ * Removes npm's content cache (`<npm_config_cache>/_cacache`) that this
1296
+ * module's own install filled, before the snapshot is archived: otherwise it
1297
+ * shares the disk with node_modules and the archive. Only that folder, only
1298
+ * when it is under <workspace>/.cache and reached through real folders; the
1299
+ * npmrc and the rest of npm's folder stay (the agent's npm reads them). The
1300
+ * agent's npm downloads again whatever it needs. Never throws; logs only.
1301
+ */
1302
+ async function removeNpmContentCache(context, project) {
1303
+ const say = (status, fields = {}) => context.log("npm_cache_cleared", { status, ...fields });
1304
+ if (context.signal.aborted || context.session.closed) return;
1305
+ const configured = context.env.npm_config_cache ?? context.env.NPM_CONFIG_CACHE;
1306
+ if (typeof configured !== "string" || !configured) return say("not_configured");
1307
+ const cacheDir = join(context.workspace, ".cache");
1308
+ const npmCache = resolve(project.dir, configured);
1309
+ if (npmCache !== cacheDir && !npmCache.startsWith(cacheDir + sep)) return say("outside_workspace");
1310
+ try {
1311
+ const target = join(npmCache, "_cacache");
1312
+ let current = cacheDir;
1313
+ for (const segment of ["", ...target.slice(cacheDir.length + 1).split(sep)]) {
1314
+ if (segment) current = join(current, segment);
1315
+ let info;
1316
+ try {
1317
+ info = await lstat(current);
1318
+ } catch (error) {
1319
+ if (error?.code === "ENOENT") return say("absent");
1320
+ throw error;
1321
+ }
1322
+ if (info.isSymbolicLink() || !info.isDirectory()) return say("unsafe");
1323
+ }
1324
+ await context.testHooks?.beforeNpmCacheMove?.();
1325
+ if (!(await cacheDirIntact(context.workspace))) return say("unsafe");
1326
+ // Nothing once the work is over: the workspace may already be the agent's.
1327
+ // Checked here, with no await before the rename.
1328
+ if (context.signal.aborted || context.session.closed) return say("stopped");
1329
+ // Moved out of npm's folder in one step and only then removed: a removal
1330
+ // still running after the caller stopped waiting touches only this
1331
+ // module's own temp (which the session's cleanup also removes), never a
1332
+ // `_cacache` the agent's npm has started to fill.
1333
+ const doomed = join(cacheDir, randomName("dep-npmcache"));
1334
+ await rename(target, doomed);
1335
+ track(context, doomed);
1336
+ await context.testHooks?.beforeNpmCacheRemove?.();
1337
+ await removeTemp(context.workspace, doomed);
1338
+ say("removed");
1339
+ } catch {
1340
+ say("failed");
1341
+ }
1342
+ }
1343
+
1344
+ /** The proxy's answer when it stopped at its own plan time bound, keeping
1345
+ * what it verified: sending the plan again resumes there. */
1346
+ const isPlanIncomplete = (plan) =>
1347
+ plan.status === 503 && typeof plan.body?.error === "string" && plan.body.error.startsWith("wardby_plan_incomplete");
1348
+
1349
+ /** Most plan requests for one project, and for the whole run: the proxy
1350
+ * allows REGISTRY_PLAN_MAX_PER_RUN (20 by default) per run, and the agent's
1351
+ * own npm needs some of them. */
1352
+ export const MAX_PLAN_ATTEMPTS = 5;
1353
+ export const MAX_PLAN_REQUESTS_PER_RUN = 8;
1354
+
1355
+ /** POST -/plan, sent again after `planRetryPauseMs` while the proxy answers
1356
+ * that it is incomplete, all within `planMs` (and the overall deadline,
1357
+ * through the signal), at most MAX_PLAN_ATTEMPTS times. Every other answer
1358
+ * is returned as it is; running out of time or attempts is `timeout`, and
1359
+ * the run's MAX_PLAN_REQUESTS_PER_RUN used up is `plan_failed`. */
1360
+ async function requestPlan(context, project) {
1361
+ const { auth, timeouts, signal, log, session } = context;
1362
+ const planMs = Math.min(timeouts.planMs ?? DEFAULT_TIMEOUTS.planMs, timeouts.totalMs ?? DEFAULT_TIMEOUTS.totalMs);
1363
+ const pauseMs = timeouts.planRetryPauseMs ?? DEFAULT_TIMEOUTS.planRetryPauseMs;
1364
+ const until = Date.now() + planMs;
1365
+ for (let attempt = 1; ; attempt += 1) {
1366
+ if ((session.planRequests ?? 0) >= MAX_PLAN_REQUESTS_PER_RUN) {
1367
+ log("plan_limit_reached", { requests: session.planRequests });
1368
+ throw new Skip("plan_failed");
1369
+ }
1370
+ session.planRequests = (session.planRequests ?? 0) + 1;
1371
+ const plan = await callJson(
1372
+ new URL("-/plan", auth.base),
1373
+ {
1374
+ method: "POST",
1375
+ headers: { authorization: `Bearer ${auth.token}`, "content-type": "application/json" },
1376
+ body: project.bytes,
1377
+ signal,
1378
+ },
1379
+ Math.max(1, until - Date.now()),
1380
+ "plan_failed",
1381
+ );
1382
+ if (!isPlanIncomplete(plan)) return plan;
1383
+ log("plan_incomplete", { attempt });
1384
+ if (attempt >= MAX_PLAN_ATTEMPTS || until - Date.now() <= pauseMs) throw new Skip("timeout");
1385
+ try {
1386
+ await sleep(pauseMs, undefined, { signal });
1387
+ } catch (error) {
1388
+ throw asSkip(error, "timeout");
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * The flow for one npm project (`projectPath` relative to the workspace, as
1395
+ * discoverProjects returns it). Never throws.
1396
+ */
1397
+ export async function prepareProject(options) {
1398
+ const { workspace, projectPath = ".", log, session } = options;
1399
+ const project = { dir: projectPath === "." ? workspace : join(workspace, ...projectPath.split("/")) };
1400
+ const context = { ...options, temps: [], created: undefined };
1401
+ try {
1402
+ Object.assign(project, await readProject(project.dir));
1403
+ // Decided now, from the committed bytes, before any third-party code runs.
1404
+ if (options.facts) options.facts.needsRebuild = listsInstallScripts(project.lockfile);
1405
+ context.cacheDir = await cacheDirectory(workspace);
1406
+ if (!context.auth) throw new Skip("no_registry");
1407
+ const { auth, timeouts, signal } = context;
1408
+ context.token = auth.token;
1409
+ context.cacheBase = new URL("/cache/npm/", auth.base);
1410
+
1411
+ // The plan: the shim's request, with these exact bytes.
1412
+ const plan = await requestPlan(context, project);
1413
+ if (plan.status !== 200 || typeof plan.body?.approved !== "number" || !Array.isArray(plan.body?.refused))
1414
+ throw new Skip("plan_failed");
1415
+ log("plan", { approved: plan.body.approved, refused: plan.body.refused.length });
1416
+ if (plan.body.refused.length > 0) return { result: "refused", reason: "plan_refused" };
1417
+
1418
+ const lockHash = createHash("sha256").update(project.bytes).digest("hex");
1419
+ context.query = new URLSearchParams({ project: projectPath, lock: lockHash }).toString();
1420
+ const lookup = await callJson(
1421
+ new URL(`lookup?${context.query}`, context.cacheBase),
1422
+ { headers: { authorization: `Bearer ${auth.token}` }, signal },
1423
+ timeouts.httpMs,
1424
+ "lookup_failed",
1425
+ );
1426
+ if (lookup.status === 404) throw new Skip("cache_unavailable");
1427
+ if (lookup.status === 409 && typeof lookup.body?.error === "string" && /^[a-z_]{1,40}$/.test(lookup.body.error))
1428
+ throw new Skip(lookup.body.error);
1429
+ const answer = lookup.body;
1430
+ if (
1431
+ lookup.status !== 200 ||
1432
+ typeof answer?.key !== "string" ||
1433
+ !/^[a-z0-9]{1,64}$/.test(answer.key) ||
1434
+ typeof answer.hit !== "boolean" ||
1435
+ !Number.isSafeInteger(answer.maxBytes)
1436
+ )
1437
+ throw new Skip("lookup_failed");
1438
+ const nearestId =
1439
+ typeof answer.nearest?.id === "string" && /^[a-z0-9]{1,64}$/.test(answer.nearest.id)
1440
+ ? answer.nearest.id
1441
+ : undefined;
1442
+ log("lookup", { hit: answer.hit, nearest: Boolean(nearestId) });
1443
+ if (Number.isSafeInteger(answer.uploadExpiresInMs) && answer.uploadExpiresInMs >= 0) {
1444
+ const reported = Date.now() + answer.uploadExpiresInMs - timeouts.uploadMarginMs;
1445
+ context.uploadDeadline = Math.min(context.uploadDeadline ?? Infinity, reported);
1446
+ }
1447
+ const key = answer.key;
1448
+
1449
+ if (answer.hit) {
1450
+ const started = Date.now();
1451
+ try {
1452
+ const sizeBytes = await restore(context, project, key, answer.maxBytes, {
1453
+ consistency: true,
1454
+ manifest: archiveManifest(answer.manifest),
1455
+ });
1456
+ context.created = undefined; // a verified hit stays
1457
+ return { result: "hit", key, sizeBytes, restoreMs: Date.now() - started };
1458
+ } catch (error) {
1459
+ // A hit that fails to download, verify or check is only a miss: clean up and install cold.
1460
+ // Not one that would not fit: a cold install writes the same tree.
1461
+ const reason = await missedRestore(context, error);
1462
+ log("hit_rejected", { reason });
1463
+ if (reason === "over_budget") throw new Skip(reason);
1464
+ }
1465
+ }
1466
+
1467
+ let result = "cold";
1468
+ let restoreMs;
1469
+ let installMs;
1470
+ if (nearestId) {
1471
+ const started = Date.now();
1472
+ try {
1473
+ await restore(context, project, nearestId, answer.maxBytes, {
1474
+ consistency: false,
1475
+ manifest: archiveManifest(answer.nearest?.manifest),
1476
+ });
1477
+ restoreMs = Date.now() - started;
1478
+ result = "nearest";
1479
+ } catch (error) {
1480
+ // A rejected nearest is only a miss: clean up and install cold.
1481
+ log("nearest_rejected", { reason: await missedRestore(context, error) });
1482
+ }
1483
+ }
1484
+ // Not enough room for an install: leave the workspace as it was; the agent installs for itself.
1485
+ // A nearest already passed its own check (NEAREST_NEED_FACTOR) before it was extracted.
1486
+ if (result === "cold" && context.budget.remaining < installNeed(context)) {
1487
+ log("install_skipped", {
1488
+ reason: "budget_too_small",
1489
+ needBytes: installNeed(context),
1490
+ roomBytes: context.budget.remaining,
1491
+ });
1492
+ throw new Skip("budget_too_small");
1493
+ }
1494
+ if (result === "nearest") {
1495
+ try {
1496
+ installMs = await install(context, project, [
1497
+ "install",
1498
+ "--no-save",
1499
+ "--ignore-scripts",
1500
+ "--no-audit",
1501
+ "--no-fund",
1502
+ ]);
1503
+ } finally {
1504
+ await restoreCommitted(context, project);
1505
+ }
1506
+ } else installMs = await install(context, project, ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]);
1507
+
1508
+ const check = await checkConsistency(project.dir, project.lockfile, signal);
1509
+ if (check.ok || result === "cold") await chargeInstall(context, project);
1510
+ if (!check.ok) {
1511
+ log("inconsistent", { detail: check.detail });
1512
+ // A tree npm ci built from the committed lockfile is what the agent
1513
+ // would install itself: keep it, but never snapshot it. A tree built
1514
+ // over a restored snapshot is removed.
1515
+ if (result === "nearest") throw new Skip("inconsistent");
1516
+ context.created = undefined;
1517
+ return { result, key, installMs, reason: "inconsistent" };
1518
+ }
1519
+ context.created = undefined;
1520
+ await removeNpmContentCache(context, project);
1521
+ const sizeBytes = await snapshot(context, project, answer.maxBytes);
1522
+ return {
1523
+ result,
1524
+ key,
1525
+ ...(sizeBytes !== undefined ? { sizeBytes } : {}),
1526
+ ...(restoreMs !== undefined ? { restoreMs } : {}),
1527
+ installMs,
1528
+ };
1529
+ } catch (error) {
1530
+ const reason = asSkip(error, "internal_error").reason;
1531
+ await cleanup(context);
1532
+ return { result: "skipped", reason };
1533
+ } finally {
1534
+ if (!context.session.closed) for (const path of context.temps) await removeTemp(workspace, path);
1535
+ }
1536
+ }
1537
+
1538
+ /** A restore (hit or nearest) that failed: rethrows when the work itself is
1539
+ * over (the deadline, the run's cancellation or the seal), otherwise cleans
1540
+ * up and returns the reason, so the caller installs cold instead. */
1541
+ async function missedRestore(context, error) {
1542
+ const reason = asSkip(error, "internal_error").reason;
1543
+ if (STOPPED.has(reason) || context.signal.aborted || context.session.closed) throw error;
1544
+ // A .cache that is no longer a real folder: stop here rather than work through it.
1545
+ if (!(await cacheDirIntact(context.workspace))) throw new Skip("cache_dir_unsafe");
1546
+ await cleanup(context);
1547
+ return reason;
1548
+ }
1549
+ const STOPPED = new Set(["timeout", "cancelled", "sealed"]);
1550
+
1551
+ /** Removes a node_modules this run created and every temp file. */
1552
+ async function cleanup(context) {
1553
+ // Once the caller stopped waiting, the workspace is the agent's: touch nothing.
1554
+ if (context.session.closed) return;
1555
+ if (context.created) {
1556
+ await rm(context.created, { recursive: true, force: true }).catch(() => undefined);
1557
+ context.created = undefined;
1558
+ }
1559
+ for (const path of context.temps.splice(0)) await removeTemp(context.workspace, path);
1560
+ }
1561
+
1562
+ /** Removes one of this module's temp paths, only while <workspace>/.cache is
1563
+ * still a real folder (never through a .cache swapped for a symlink). */
1564
+ async function removeTemp(workspace, path) {
1565
+ if (!(await cacheDirIntact(workspace))) return;
1566
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
1567
+ }
1568
+
1569
+ async function cacheDirIntact(workspace) {
1570
+ try {
1571
+ const info = await lstat(join(workspace, ".cache"));
1572
+ return info.isDirectory() && !info.isSymbolicLink();
1573
+ } catch {
1574
+ return false;
1575
+ }
1576
+ }
1577
+
1578
+ // ---------------------------------------------------------------- discovery
1579
+
1580
+ async function isFile(path) {
1581
+ try {
1582
+ return (await lstat(path)).isFile();
1583
+ } catch {
1584
+ return false;
1585
+ }
1586
+ }
1587
+
1588
+ /** Root first, then by path (code point order, the same on every machine). */
1589
+ const byProjectOrder = (a, b) => (a === "." ? -1 : b === "." ? 1 : a < b ? -1 : a > b ? 1 : 0);
1590
+
1591
+ /**
1592
+ * The folders holding a committed package-lock.json (a regular file): the
1593
+ * root ("."), then folders up to MAX_PROJECT_DEPTH levels down, root first
1594
+ * and then sorted by path. Never enters node_modules, dot-folders, symlinked
1595
+ * folders or a folder whose name the proxy would refuse as a project path,
1596
+ * so every path returned passes the proxy's projectPath rule.
1597
+ */
1598
+ export async function discoverProjects(workspace, signal) {
1599
+ const found = [];
1600
+ if (await isFile(join(workspace, "package-lock.json"))) found.push(".");
1601
+ let visited = 0;
1602
+ const queue = [[]];
1603
+ while (queue.length) {
1604
+ signal?.throwIfAborted();
1605
+ const segments = queue.shift();
1606
+ if (segments.length >= MAX_PROJECT_DEPTH) continue;
1607
+ let entries;
1608
+ try {
1609
+ entries = await readdir(join(workspace, ...segments), { withFileTypes: true });
1610
+ } catch {
1611
+ continue;
1612
+ }
1613
+ for (const entry of entries) {
1614
+ // Dirent types come from lstat: a symlinked folder is not a directory here.
1615
+ if (!entry.isDirectory() || entry.name === ROOT || !PROJECT_SEGMENT.test(entry.name)) continue;
1616
+ visited += 1;
1617
+ if (visited > MAX_DISCOVERY_DIRS) return found.sort(byProjectOrder);
1618
+ const child = [...segments, entry.name];
1619
+ if (await isFile(join(workspace, ...child, "package-lock.json"))) found.push(child.join("/"));
1620
+ queue.push(child);
1621
+ }
1622
+ }
1623
+ return found.sort(byProjectOrder);
1624
+ }
1625
+
1626
+ /** One glob segment as a regular expression (`*` and `?` within a segment). */
1627
+ function segmentPattern(segment) {
1628
+ let source = "";
1629
+ for (const char of segment) {
1630
+ if (char === "*") source += "[^/]*";
1631
+ else if (char === "?") source += "[^/]";
1632
+ else source += char.replace(/[\\^$.|+(){}[\]]/g, "\\$&");
1633
+ }
1634
+ return new RegExp(`^${source}$`);
1635
+ }
1636
+
1637
+ /** Whether `segments` (or a folder above them) matches a workspaces glob. */
1638
+ function globCovers(glob, segments) {
1639
+ const parts = glob
1640
+ .replace(/^(\.\/)+/, "")
1641
+ .replace(/\/+$/, "")
1642
+ .split("/")
1643
+ .filter((part) => part !== "" && part !== ".");
1644
+ if (!parts.length || parts.some((part) => part.startsWith("!") || part === "..")) return false;
1645
+ // Braces, classes and extglobs are not interpreted: fail toward skipping.
1646
+ if (/[{}()[\]]/.test(glob)) return true;
1647
+ const match = (p, s) => {
1648
+ if (p === parts.length) return true; // the glob matched a folder at or above this one
1649
+ if (parts[p] === "**") {
1650
+ for (let skip = s; skip <= segments.length; skip += 1) if (match(p + 1, skip)) return true;
1651
+ return false;
1652
+ }
1653
+ return s < segments.length && segmentPattern(parts[p]).test(segments[s]) && match(p + 1, s + 1);
1654
+ };
1655
+ return match(0, 0);
1656
+ }
1657
+
1658
+ /** Reads a regular file of at most `maxBytes` through one no-follow handle, so
1659
+ * the file checked is the file read (no check-then-read race, no symlink
1660
+ * followed). Throws when it is a symlink, not a regular file, or too large. */
1661
+ export async function readRegularFile(path, maxBytes, encoding) {
1662
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1663
+ try {
1664
+ const info = await handle.stat();
1665
+ if (!info.isFile() || info.size > maxBytes) throw new Error("not_readable_file");
1666
+ const buffer = Buffer.alloc(maxBytes + 1);
1667
+ let length = 0;
1668
+ while (length < buffer.length) {
1669
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
1670
+ if (bytesRead === 0) break;
1671
+ length += bytesRead;
1672
+ }
1673
+ if (length > maxBytes) throw new Error("not_readable_file");
1674
+ const bytes = buffer.subarray(0, length);
1675
+ return encoding ? bytes.toString(encoding) : bytes;
1676
+ } finally {
1677
+ await handle.close();
1678
+ }
1679
+ }
1680
+
1681
+ /** The `workspaces` globs a project's package.json declares (array or { packages }). */
1682
+ async function workspaceGlobs(workspace, projectPath) {
1683
+ const file = join(workspace, ...(projectPath === "." ? [] : projectPath.split("/")), "package.json");
1684
+ try {
1685
+ const manifest = JSON.parse(await readRegularFile(file, MAX_JSON_BYTES, "utf8"));
1686
+ const declared = manifest?.workspaces;
1687
+ const list = Array.isArray(declared) ? declared : Array.isArray(declared?.packages) ? declared.packages : [];
1688
+ return list.filter((glob) => typeof glob === "string");
1689
+ } catch {
1690
+ return [];
1691
+ }
1692
+ }
1693
+
1694
+ /** The projects to prepare (at most MAX_PROJECTS) and those skipped up front:
1695
+ * a folder inside another project's workspaces globs is `workspace_member`
1696
+ * (npm-workspaces monorepos are out of scope), extras are `too_many_projects`. */
1697
+ async function planProjects(workspace, signal) {
1698
+ const found = await discoverProjects(workspace, signal);
1699
+ const globs = new Map();
1700
+ for (const path of found) globs.set(path, await workspaceGlobs(workspace, path));
1701
+ const run = [];
1702
+ const skipped = [];
1703
+ for (const path of found) {
1704
+ const member = found.some((owner) => {
1705
+ if (owner === path) return false;
1706
+ const inside = owner === "." ? path : path.startsWith(`${owner}/`) ? path.slice(owner.length + 1) : undefined;
1707
+ return inside !== undefined && globs.get(owner).some((glob) => globCovers(glob, inside.split("/")));
1708
+ });
1709
+ if (member) skipped.push({ path, result: "skipped", reason: "workspace_member" });
1710
+ else if (run.length < MAX_PROJECTS) run.push(path);
1711
+ else skipped.push({ path, result: "skipped", reason: "too_many_projects" });
1712
+ }
1713
+ return { run, skipped };
1714
+ }
1715
+
1716
+ // ---------------------------------------------------------------- install scripts
1717
+
1718
+ /** Whether the committed lockfile lists an installed package (a key under
1719
+ * node_modules/, not a link) with an install script. */
1720
+ function listsInstallScripts(lockfile) {
1721
+ return Object.entries(lockfile?.packages ?? {}).some(
1722
+ ([key, entry]) =>
1723
+ key.startsWith(`${ROOT}/`) &&
1724
+ entry &&
1725
+ typeof entry === "object" &&
1726
+ !entry.link &&
1727
+ entry.hasInstallScript === true,
1728
+ );
1729
+ }
1730
+
1731
+ /** The only variables a rebuild sees: what npm needs to reach the registry
1732
+ * through the shim, never the driver's secrets. */
1733
+ const REBUILD_ENV_NAMES = new Set(["PATH", "HOME", "LANG", "TMPDIR"]);
1734
+ const REBUILD_ENV_PREFIX = /^(?:npm_config_|NPM_CONFIG_|WARDBY_NPM_)/;
1735
+ function rebuildEnv(env) {
1736
+ const out = {};
1737
+ for (const [name, value] of Object.entries(env))
1738
+ if (typeof value === "string" && (REBUILD_ENV_NAMES.has(name) || REBUILD_ENV_PREFIX.test(name))) out[name] = value;
1739
+ return out;
1740
+ }
1741
+
1742
+ /**
1743
+ * After the seal: `npm rebuild` through the shim for a project whose
1744
+ * lockfile lists install scripts (snapshots are built with
1745
+ * --ignore-scripts). Output is never captured; a failure only logs.
1746
+ */
1747
+ async function rebuild({ workspace, path, env, npmPath, timeouts, signal, session, log }) {
1748
+ const dir = path === "." ? workspace : join(workspace, ...path.split("/"));
1749
+ const started = Date.now();
1750
+ let outcome;
1751
+ try {
1752
+ const code = await run(npmPath, ["rebuild"], {
1753
+ cwd: dir,
1754
+ env: rebuildEnv(env),
1755
+ timeoutMs: timeouts.rebuildMs,
1756
+ killGraceMs: timeouts.killGraceMs,
1757
+ signal,
1758
+ session,
1759
+ });
1760
+ outcome = code === 0 ? "ok" : "failed";
1761
+ } catch (error) {
1762
+ outcome = asSkip(error, "failed").reason === "timeout" ? "timeout" : "failed";
1763
+ }
1764
+ log("rebuild", { project: path, status: outcome, ms: Date.now() - started });
1765
+ return outcome;
1766
+ }
1767
+
1768
+ /** The registry auth `prepareDependencies` and `sealUploadWindow` use: the
1769
+ * environment's (registryAuth), with `proxyUrl` / `token` overriding it. */
1770
+ async function resolveAuth(env, proxyUrl, token) {
1771
+ let auth = await registryAuth(env);
1772
+ if (proxyUrl && (auth || token)) auth = { token: auth?.token, base: new URL("/registry/npm/", proxyUrl).toString() };
1773
+ if (auth && token) auth = { ...auth, token };
1774
+ return auth?.token ? auth : undefined;
1775
+ }
1776
+
1777
+ /** Pauses between seal attempts: up to three attempts in all. */
1778
+ const SEAL_RETRY_PAUSES_MS = Object.freeze([250, 1_000]);
1779
+
1780
+ /** POST /cache/npm/seal, retried (after a network failure or a 5xx, not a
1781
+ * 4xx) while `timeoutMs` lasts: every attempt and pause fits inside it.
1782
+ * Confirmed only on the proxy's one success answer (200 `{ sealed: true }`);
1783
+ * never throws. The proxy also seals on the run's first model request. */
1784
+ async function postSeal(auth, timeoutMs) {
1785
+ const until = Date.now() + timeoutMs;
1786
+ let last = { status: 0, confirmed: false };
1787
+ for (let attempt = 0; ; attempt += 1) {
1788
+ last = await postSealOnce(auth, until - Date.now());
1789
+ if (last.confirmed || (last.status >= 400 && last.status < 500)) return last;
1790
+ const pause = SEAL_RETRY_PAUSES_MS[attempt];
1791
+ if (pause === undefined || until - Date.now() <= pause) return last;
1792
+ await new Promise((resolve) => setTimeout(resolve, pause));
1793
+ }
1794
+ }
1795
+
1796
+ async function postSealOnce(auth, timeoutMs) {
1797
+ if (timeoutMs <= 0) return { status: 0, confirmed: false };
1798
+ try {
1799
+ const sealed = await callJson(
1800
+ new URL("/cache/npm/seal", auth.base),
1801
+ { method: "POST", headers: { authorization: `Bearer ${auth.token}` } },
1802
+ timeoutMs,
1803
+ "seal_failed",
1804
+ );
1805
+ return { status: sealed.status, confirmed: sealed.status === 200 && sealed.body?.sealed === true };
1806
+ } catch {
1807
+ // an unsealed session expires with its upload window
1808
+ return { status: 0, confirmed: false };
1809
+ }
1810
+ }
1811
+
1812
+ /**
1813
+ * Seals the run's upload window without preparing anything: for a caller
1814
+ * that is about to hand the workspace to the agent before (or instead of)
1815
+ * `prepareDependencies` (the Claude Code tool runner's first command). The
1816
+ * proxy's seal is idempotent. Same auth as `prepareDependencies`; never throws.
1817
+ */
1818
+ export async function sealUploadWindow({ env, proxyUrl, token, timeoutMs = DEFAULT_TIMEOUTS.httpMs } = {}) {
1819
+ let auth;
1820
+ try {
1821
+ auth = await resolveAuth(env ?? {}, proxyUrl, token);
1822
+ } catch {
1823
+ return { status: 0, confirmed: false };
1824
+ }
1825
+ if (!auth) return { status: 0, confirmed: false };
1826
+ return postSeal(auth, timeoutMs);
1827
+ }
1828
+
1829
+ /** The most of a run's remaining time the cache may take, as a share. */
1830
+ export const DEADLINE_SHARE = 0.5;
1831
+
1832
+ /**
1833
+ * The cache's whole time bound in ms (the budget walk and prepareDependencies
1834
+ * together, which the caller splits): DEFAULT_TIMEOUTS.totalMs, or half the
1835
+ * time left before the run's deadline when that is sooner, so a short run
1836
+ * keeps most of its time for the agent. `deadlineAt` is the run's deadline
1837
+ * (an ISO string, Date or epoch ms); a missing or unreadable one leaves the
1838
+ * default. 0 when the deadline has passed.
1839
+ */
1840
+ export function dependencyCacheTimeLimit(deadlineAt, now = Date.now()) {
1841
+ const at =
1842
+ deadlineAt instanceof Date
1843
+ ? deadlineAt.getTime()
1844
+ : typeof deadlineAt === "string"
1845
+ ? Date.parse(deadlineAt)
1846
+ : deadlineAt;
1847
+ if (typeof at !== "number" || !Number.isFinite(at)) return DEFAULT_TIMEOUTS.totalMs;
1848
+ return Math.max(0, Math.min(DEFAULT_TIMEOUTS.totalMs, Math.floor(DEADLINE_SHARE * (at - now))));
1849
+ }
1850
+
1851
+ /** What the budget check keeps back from the volume: max(64 MiB, 5%). */
1852
+ const BUDGET_MARGIN_MIN_BYTES = 64 * MIB;
1853
+ const BUDGET_MARGIN_FRACTION = 0.05;
1854
+ /** Below this the cache is skipped (`budget_too_small`): no restore is worth trying. */
1855
+ export const MIN_BUDGET_BYTES = 64 * MIB;
1856
+ /** Bounds on measuring the workspace: time and entries. Past either, `budget_unknown`. */
1857
+ export const BUDGET_MEASURE_TIMEOUT_MS = 30_000;
1858
+ const BUDGET_MEASURE_MAX_ENTRIES = 500_000;
1859
+
1860
+ /**
1861
+ * The bytes the cache may restore or install into the workspace volume: the
1862
+ * run's volume size (`diskMb`, the Kubernetes emptyDir sizeLimit / Docker
1863
+ * tmpfs size, which statfs cannot see on Kubernetes) minus what the
1864
+ * workspace already uses minus a margin. Usage is a bounded walk of the whole
1865
+ * workspace (nothing excluded: the volume's limit counts everything),
1866
+ * allocated blocks where the platform reports them, links not followed.
1867
+ * Never throws: `{ reason: "budget_unknown" }` when diskMb is missing or the
1868
+ * walk can't finish in time, `{ reason: "budget_too_small" }` when what is
1869
+ * left is under MIN_BUDGET_BYTES; the caller then skips the cache.
1870
+ */
1871
+ export async function dependencyCacheBudget({
1872
+ workspace,
1873
+ diskMb,
1874
+ signal,
1875
+ timeoutMs = BUDGET_MEASURE_TIMEOUT_MS,
1876
+ maxEntries = BUDGET_MEASURE_MAX_ENTRIES,
1877
+ }) {
1878
+ if (!Number.isSafeInteger(diskMb) || diskMb <= 0) return { reason: "budget_unknown" };
1879
+ const used = await workspaceUsage(workspace, { signal, timeoutMs, maxEntries });
1880
+ if (used === undefined) return { reason: "budget_unknown" };
1881
+ const total = diskMb * MIB;
1882
+ const margin = Math.max(BUDGET_MARGIN_MIN_BYTES, Math.floor(total * BUDGET_MARGIN_FRACTION));
1883
+ const budgetBytes = total - used - margin;
1884
+ if (budgetBytes < MIN_BUDGET_BYTES) return { reason: "budget_too_small", usedBytes: used };
1885
+ return { budgetBytes, usedBytes: used };
1886
+ }
1887
+
1888
+ /** Bytes the workspace uses: a bounded walk of all of it (allocated blocks
1889
+ * where the platform reports them, links not followed). undefined past the
1890
+ * time or entry bound, or on any error. */
1891
+ async function workspaceUsage(
1892
+ workspace,
1893
+ { signal, timeoutMs = BUDGET_MEASURE_TIMEOUT_MS, maxEntries = BUDGET_MEASURE_MAX_ENTRIES } = {},
1894
+ ) {
1895
+ const deadlineAt = Date.now() + timeoutMs;
1896
+ let used = 0;
1897
+ let entries = 0;
1898
+ try {
1899
+ const stack = [workspace];
1900
+ while (stack.length) {
1901
+ signal?.throwIfAborted();
1902
+ if (Date.now() >= deadlineAt) return undefined;
1903
+ const directory = stack.pop();
1904
+ const names = await readdir(directory);
1905
+ for (const name of names) {
1906
+ if (++entries > maxEntries) return undefined;
1907
+ const info = await lstat(join(directory, name));
1908
+ used += Number.isFinite(info.blocks) && info.blocks > 0 ? info.blocks * 512 : info.size;
1909
+ if (info.isDirectory()) stack.push(join(directory, name));
1910
+ }
1911
+ }
1912
+ } catch {
1913
+ return undefined;
1914
+ }
1915
+ return used;
1916
+ }
1917
+
1918
+ // ---------------------------------------------------------------- entry point
1919
+
1920
+ const KEPT = new Set(["hit", "nearest", "cold"]);
1921
+
1922
+ /**
1923
+ * Restores or installs the dependencies of every npm project in the
1924
+ * workspace (see discoverProjects), seals the run's upload window once, then
1925
+ * rebuilds install scripts. Never throws; nothing in the workspace changes
1926
+ * after it returns. `proxyUrl`/`token` override what the registry
1927
+ * environment names (the npm shim's npm_config_registry,
1928
+ * WARDBY_NPM_PLAN_REGISTRY and npmrc token).
1929
+ */
1930
+ export async function prepareDependencies({
1931
+ workspace,
1932
+ env: givenEnv,
1933
+ proxyUrl,
1934
+ token,
1935
+ npmPath = "/opt/wardby/bin/npm",
1936
+ log = () => {},
1937
+ budgetBytes,
1938
+ usedBytes,
1939
+ minInstallBytes = MIN_INSTALL_BUDGET_BYTES,
1940
+ timeouts: overrides = {},
1941
+ signal: cancel,
1942
+ testHooks,
1943
+ }) {
1944
+ const timeouts = { ...DEFAULT_TIMEOUTS, ...overrides };
1945
+ // Required: the agent-equivalent shell env. No process.env fallback, so a
1946
+ // caller can't hand the driver's secrets to npm by omission.
1947
+ const env = givenEnv ?? {};
1948
+ const uploadDeadline = Date.now() + timeouts.uploadWindowMs - timeouts.uploadMarginMs;
1949
+ const say = (event, fields = {}) => {
1950
+ try {
1951
+ log(event, fields);
1952
+ } catch {
1953
+ // a logger never fails the cache
1954
+ }
1955
+ };
1956
+ const session = { sealed: false, closed: false, children: new Set(), temps: new Set(), planRequests: 0 };
1957
+ // `deadline` bounds everything (projects and rebuilds); `sealing` stops the
1958
+ // project work (and any upload) at the seal.
1959
+ const deadline = new AbortController();
1960
+ const sealing = new AbortController();
1961
+ const deadlineTimer = setTimeout(() => deadline.abort(new Skip("timeout")), timeouts.totalMs);
1962
+ const workSignal = AbortSignal.any([deadline.signal, sealing.signal]);
1963
+ let auth;
1964
+ let planned; // set once planning succeeded
1965
+ const done = new Map(); // path -> result, filled as projects finish
1966
+ const facts = new Map(); // path -> { needsRebuild }, from the committed lockfile
1967
+ let failure;
1968
+ let sealConfirmed = false;
1969
+
1970
+ let sealing$;
1971
+ const seal = () =>
1972
+ (sealing$ ??= (async () => {
1973
+ session.sealed = true;
1974
+ sealing.abort(new Skip("sealed"));
1975
+ if (!auth) return;
1976
+ const { status, confirmed } = await postSeal(auth, timeouts.httpMs);
1977
+ sealConfirmed = confirmed;
1978
+ say("seal", { status, confirmed: sealConfirmed });
1979
+ })());
1980
+
1981
+ const phases = (async () => {
1982
+ try {
1983
+ auth = await resolveAuth(env, proxyUrl, token);
1984
+ const budget = await workspaceBudget(workspace, env, budgetBytes);
1985
+ // What the workspace used when the budget was set: later measurements count the growth from it.
1986
+ budget.baseUsed =
1987
+ Number.isSafeInteger(usedBytes) && usedBytes >= 0
1988
+ ? usedBytes
1989
+ : await workspaceUsage(workspace, { signal: workSignal });
1990
+ await testHooks?.beforeDiscovery?.();
1991
+ const { run: toRun, skipped } = await planProjects(workspace, workSignal);
1992
+ for (const entry of skipped) done.set(entry.path, { result: entry.result, reason: entry.reason });
1993
+ if (!toRun.length && !skipped.length) done.set(".", { result: "skipped", reason: "no_lockfile" });
1994
+ planned = toRun.length || skipped.length ? [...toRun, ...skipped.map((entry) => entry.path)] : ["."];
1995
+ for (const path of toRun) {
1996
+ if (workSignal.aborted) break;
1997
+ const projectFacts = {};
1998
+ facts.set(path, projectFacts);
1999
+ const result = await prepareProject({
2000
+ workspace,
2001
+ projectPath: path,
2002
+ env,
2003
+ npmPath,
2004
+ log: (event, fields) => say(event, { project: path, ...fields }),
2005
+ auth,
2006
+ budget,
2007
+ timeouts,
2008
+ signal: workSignal,
2009
+ session,
2010
+ testHooks,
2011
+ facts: projectFacts,
2012
+ uploadDeadline,
2013
+ minInstallBytes,
2014
+ });
2015
+ if (!session.closed) done.set(path, result);
2016
+ }
2017
+ } catch {
2018
+ failure = "internal_error";
2019
+ } finally {
2020
+ await seal();
2021
+ }
2022
+ // After the seal: nothing below can be uploaded. Still bounded by the
2023
+ // deadline. Install scripts are third-party code holding the run token:
2024
+ // they run only once the proxy confirmed the upload window is shut.
2025
+ if (!sealConfirmed && auth) say("rebuild_skipped", { reason: "unsealed" });
2026
+ for (const path of planned ?? []) {
2027
+ const result = done.get(path);
2028
+ if (!result || !KEPT.has(result.result) || session.closed) continue;
2029
+ if (!facts.get(path)?.needsRebuild) {
2030
+ done.set(path, { ...result, rebuild: "skipped" });
2031
+ continue;
2032
+ }
2033
+ if (!sealConfirmed) {
2034
+ done.set(path, { ...result, rebuild: "unsealed" });
2035
+ continue;
2036
+ }
2037
+ const outcome = await rebuild({
2038
+ workspace,
2039
+ path,
2040
+ env,
2041
+ npmPath,
2042
+ timeouts,
2043
+ signal: deadline.signal,
2044
+ session,
2045
+ log: say,
2046
+ });
2047
+ if (!session.closed) done.set(path, { ...result, rebuild: outcome });
2048
+ }
2049
+ })();
2050
+
2051
+ // The deadline aborts the work, which then has settleMs to clean up. If it
2052
+ // still has not settled, the session is closed (below) so it can no longer
2053
+ // rename, remove, install or rebuild anything, nor upload.
2054
+ let grace;
2055
+ let endLate;
2056
+ const late = new Promise((resolve) => {
2057
+ endLate = resolve;
2058
+ grace = setTimeout(resolve, timeouts.totalMs + timeouts.settleMs);
2059
+ });
2060
+ // The caller's cancellation (the run was cancelled): the same as the
2061
+ // deadline arriving now, so the work stops and has settleMs to clean up.
2062
+ const onCancel = () => {
2063
+ deadline.abort(new Skip("cancelled"));
2064
+ clearTimeout(grace);
2065
+ grace = setTimeout(endLate, timeouts.settleMs);
2066
+ };
2067
+ if (cancel?.aborted) onCancel();
2068
+ else cancel?.addEventListener("abort", onCancel, { once: true });
2069
+ await Promise.race([phases, late]);
2070
+ cancel?.removeEventListener("abort", onCancel);
2071
+ clearTimeout(grace);
2072
+ clearTimeout(deadlineTimer);
2073
+ session.closed = true;
2074
+ deadline.abort(new Skip("timeout"));
2075
+ sealing.abort(new Skip("sealed"));
2076
+ for (const pid of session.children) {
2077
+ try {
2078
+ process.kill(-pid, "SIGKILL");
2079
+ } catch {
2080
+ // already gone
2081
+ }
2082
+ }
2083
+ await seal();
2084
+ await removeOwnTemps(workspace, session.temps);
2085
+
2086
+ if (!planned) {
2087
+ const reason = failure ?? "timeout";
2088
+ say("result", { reason });
2089
+ return { projects: [], reason };
2090
+ }
2091
+ const projects = planned
2092
+ .map((path) => {
2093
+ const result = done.get(path) ?? { result: "skipped", reason: failure ?? "timeout" };
2094
+ // A kept tree whose rebuild never got its turn before the deadline.
2095
+ const rebuilt =
2096
+ KEPT.has(result.result) && !result.rebuild
2097
+ ? { rebuild: facts.get(path)?.needsRebuild ? "timeout" : "skipped" }
2098
+ : {};
2099
+ return { path, ...result, ...rebuilt };
2100
+ })
2101
+ .sort((a, b) => byProjectOrder(a.path, b.path));
2102
+ for (const project of projects)
2103
+ say("result", {
2104
+ project: project.path,
2105
+ result: project.result,
2106
+ ...(project.reason ? { reason: project.reason } : {}),
2107
+ ...(project.rebuild ? { rebuild: project.rebuild } : {}),
2108
+ });
2109
+ return { projects };
2110
+ }
2111
+
2112
+ /** Removes this module's own temp files under <workspace>/.cache, and
2113
+ * nothing else: called once the session is closed, so a project that never
2114
+ * settled does not leave its download or extraction folder behind. */
2115
+ async function removeOwnTemps(workspace, temps) {
2116
+ const cacheDir = join(workspace, ".cache");
2117
+ if (!(await cacheDirIntact(workspace))) return;
2118
+ for (const path of temps) {
2119
+ if (!path.startsWith(cacheDir + sep) || path.slice(cacheDir.length + 1) !== basename(path)) continue;
2120
+ if (!OWN_TEMP.test(basename(path))) continue;
2121
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
2122
+ }
2123
+ }