patchwork-os 0.2.0-beta.0 → 0.2.0-beta.10

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 (673) hide show
  1. package/README.bridge.md +14 -14
  2. package/README.md +204 -34
  3. package/deploy/README.md +1 -1
  4. package/deploy/bootstrap-vps.sh +14 -9
  5. package/deploy/deploy-dashboard.sh +25 -1
  6. package/deploy/macos/README.md +153 -0
  7. package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
  8. package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
  9. package/deploy/macos/install-mac-bridge.sh +244 -0
  10. package/deploy/macos/uninstall-mac-bridge.sh +22 -0
  11. package/dist/activityLog.d.ts +6 -0
  12. package/dist/activityLog.js +63 -26
  13. package/dist/activityLog.js.map +1 -1
  14. package/dist/adapters/claude.js +22 -16
  15. package/dist/adapters/claude.js.map +1 -1
  16. package/dist/adapters/gemini.js +15 -11
  17. package/dist/adapters/gemini.js.map +1 -1
  18. package/dist/adapters/openai.js +9 -9
  19. package/dist/adapters/openai.js.map +1 -1
  20. package/dist/ajv2020.d.ts +25 -0
  21. package/dist/ajv2020.js +33 -0
  22. package/dist/ajv2020.js.map +1 -0
  23. package/dist/analyticsAggregator.d.ts +5 -1
  24. package/dist/analyticsAggregator.js +16 -5
  25. package/dist/analyticsAggregator.js.map +1 -1
  26. package/dist/analyticsConfig.d.ts +29 -0
  27. package/dist/analyticsConfig.js +102 -0
  28. package/dist/analyticsConfig.js.map +1 -0
  29. package/dist/analyticsPrefs.d.ts +49 -2
  30. package/dist/analyticsPrefs.js +177 -19
  31. package/dist/analyticsPrefs.js.map +1 -1
  32. package/dist/analyticsSend.d.ts +17 -1
  33. package/dist/analyticsSend.js +67 -5
  34. package/dist/analyticsSend.js.map +1 -1
  35. package/dist/approvalHttp.d.ts +14 -0
  36. package/dist/approvalHttp.js +212 -9
  37. package/dist/approvalHttp.js.map +1 -1
  38. package/dist/approvalInsights.js +13 -5
  39. package/dist/approvalInsights.js.map +1 -1
  40. package/dist/approvalQueue.d.ts +97 -3
  41. package/dist/approvalQueue.js +193 -19
  42. package/dist/approvalQueue.js.map +1 -1
  43. package/dist/approvalSignals.js +19 -11
  44. package/dist/approvalSignals.js.map +1 -1
  45. package/dist/automation.d.ts +35 -10
  46. package/dist/automation.js +133 -37
  47. package/dist/automation.js.map +1 -1
  48. package/dist/automationSuggestions.js +10 -8
  49. package/dist/automationSuggestions.js.map +1 -1
  50. package/dist/bridge.d.ts +2 -0
  51. package/dist/bridge.js +238 -51
  52. package/dist/bridge.js.map +1 -1
  53. package/dist/bridgeLockDiscovery.d.ts +27 -1
  54. package/dist/bridgeLockDiscovery.js +38 -11
  55. package/dist/bridgeLockDiscovery.js.map +1 -1
  56. package/dist/bridgeToken.js +3 -2
  57. package/dist/bridgeToken.js.map +1 -1
  58. package/dist/claudeDriver.d.ts +0 -16
  59. package/dist/claudeDriver.js +42 -28
  60. package/dist/claudeDriver.js.map +1 -1
  61. package/dist/claudeMdPatch.d.ts +9 -3
  62. package/dist/claudeMdPatch.js +79 -13
  63. package/dist/claudeMdPatch.js.map +1 -1
  64. package/dist/claudeOrchestrator.d.ts +13 -1
  65. package/dist/claudeOrchestrator.js +89 -44
  66. package/dist/claudeOrchestrator.js.map +1 -1
  67. package/dist/commands/analytics.d.ts +8 -0
  68. package/dist/commands/analytics.js +134 -0
  69. package/dist/commands/analytics.js.map +1 -0
  70. package/dist/commands/auditEnv.d.ts +36 -0
  71. package/dist/commands/auditEnv.js +202 -0
  72. package/dist/commands/auditEnv.js.map +1 -0
  73. package/dist/commands/dashboard.js +8 -1
  74. package/dist/commands/dashboard.js.map +1 -1
  75. package/dist/commands/doctor.d.ts +35 -0
  76. package/dist/commands/doctor.js +51 -0
  77. package/dist/commands/doctor.js.map +1 -0
  78. package/dist/commands/install.js +3 -0
  79. package/dist/commands/install.js.map +1 -1
  80. package/dist/commands/launchd.js +15 -16
  81. package/dist/commands/launchd.js.map +1 -1
  82. package/dist/commands/patchworkInit.d.ts +5 -0
  83. package/dist/commands/patchworkInit.js +89 -7
  84. package/dist/commands/patchworkInit.js.map +1 -1
  85. package/dist/commands/recipe.d.ts +110 -0
  86. package/dist/commands/recipe.js +512 -9
  87. package/dist/commands/recipe.js.map +1 -1
  88. package/dist/commands/recipeInstall.js +11 -4
  89. package/dist/commands/recipeInstall.js.map +1 -1
  90. package/dist/commands/shadowScan.d.ts +34 -0
  91. package/dist/commands/shadowScan.js +142 -0
  92. package/dist/commands/shadowScan.js.map +1 -0
  93. package/dist/commands/task.js +2 -2
  94. package/dist/commands/task.js.map +1 -1
  95. package/dist/commands/tools.d.ts +20 -1
  96. package/dist/commands/tools.js +112 -3
  97. package/dist/commands/tools.js.map +1 -1
  98. package/dist/commands/tracesImport.js +21 -4
  99. package/dist/commands/tracesImport.js.map +1 -1
  100. package/dist/commitIssueLinkLog.d.ts +24 -0
  101. package/dist/commitIssueLinkLog.js +160 -22
  102. package/dist/commitIssueLinkLog.js.map +1 -1
  103. package/dist/companions/registry.js +15 -7
  104. package/dist/companions/registry.js.map +1 -1
  105. package/dist/config.d.ts +49 -5
  106. package/dist/config.js +123 -22
  107. package/dist/config.js.map +1 -1
  108. package/dist/connectorRoutes.js +913 -413
  109. package/dist/connectorRoutes.js.map +1 -1
  110. package/dist/connectors/airtable.d.ts +230 -0
  111. package/dist/connectors/airtable.js +700 -0
  112. package/dist/connectors/airtable.js.map +1 -0
  113. package/dist/connectors/asana.js +13 -9
  114. package/dist/connectors/asana.js.map +1 -1
  115. package/dist/connectors/baseConnector.js +25 -3
  116. package/dist/connectors/baseConnector.js.map +1 -1
  117. package/dist/connectors/caldiy.d.ts +137 -0
  118. package/dist/connectors/caldiy.js +424 -0
  119. package/dist/connectors/caldiy.js.map +1 -0
  120. package/dist/connectors/circleci.d.ts +162 -0
  121. package/dist/connectors/circleci.js +576 -0
  122. package/dist/connectors/circleci.js.map +1 -0
  123. package/dist/connectors/cloudflare.d.ts +132 -0
  124. package/dist/connectors/cloudflare.js +622 -0
  125. package/dist/connectors/cloudflare.js.map +1 -0
  126. package/dist/connectors/confluence.js +35 -0
  127. package/dist/connectors/confluence.js.map +1 -1
  128. package/dist/connectors/connectorRedirectUri.d.ts +30 -0
  129. package/dist/connectors/connectorRedirectUri.js +38 -0
  130. package/dist/connectors/connectorRedirectUri.js.map +1 -0
  131. package/dist/connectors/connectorRegistry.d.ts +63 -0
  132. package/dist/connectors/connectorRegistry.js +354 -0
  133. package/dist/connectors/connectorRegistry.js.map +1 -0
  134. package/dist/connectors/datadog.js +33 -4
  135. package/dist/connectors/datadog.js.map +1 -1
  136. package/dist/connectors/discord.js +14 -10
  137. package/dist/connectors/discord.js.map +1 -1
  138. package/dist/connectors/elasticsearch.d.ts +141 -0
  139. package/dist/connectors/elasticsearch.js +601 -0
  140. package/dist/connectors/elasticsearch.js.map +1 -0
  141. package/dist/connectors/figma.d.ts +130 -0
  142. package/dist/connectors/figma.js +387 -0
  143. package/dist/connectors/figma.js.map +1 -0
  144. package/dist/connectors/github.d.ts +17 -0
  145. package/dist/connectors/github.js +53 -2
  146. package/dist/connectors/github.js.map +1 -1
  147. package/dist/connectors/gitlab.js +12 -6
  148. package/dist/connectors/gitlab.js.map +1 -1
  149. package/dist/connectors/gmail.js +72 -21
  150. package/dist/connectors/gmail.js.map +1 -1
  151. package/dist/connectors/googleCalendar.js +8 -8
  152. package/dist/connectors/googleCalendar.js.map +1 -1
  153. package/dist/connectors/googleDocs.d.ts +103 -0
  154. package/dist/connectors/googleDocs.js +409 -0
  155. package/dist/connectors/googleDocs.js.map +1 -0
  156. package/dist/connectors/googleDrive.d.ts +12 -0
  157. package/dist/connectors/googleDrive.js +35 -8
  158. package/dist/connectors/googleDrive.js.map +1 -1
  159. package/dist/connectors/grafana.d.ts +133 -0
  160. package/dist/connectors/grafana.js +478 -0
  161. package/dist/connectors/grafana.js.map +1 -0
  162. package/dist/connectors/jira.d.ts +25 -0
  163. package/dist/connectors/jira.js +245 -2
  164. package/dist/connectors/jira.js.map +1 -1
  165. package/dist/connectors/linear.js +1 -1
  166. package/dist/connectors/linear.js.map +1 -1
  167. package/dist/connectors/mcpOAuth.js +78 -16
  168. package/dist/connectors/mcpOAuth.js.map +1 -1
  169. package/dist/connectors/monday.d.ts +217 -0
  170. package/dist/connectors/monday.js +655 -0
  171. package/dist/connectors/monday.js.map +1 -0
  172. package/dist/connectors/mongodb.d.ts +139 -0
  173. package/dist/connectors/mongodb.js +455 -0
  174. package/dist/connectors/mongodb.js.map +1 -0
  175. package/dist/connectors/oauthStateStore.d.ts +20 -0
  176. package/dist/connectors/oauthStateStore.js +94 -4
  177. package/dist/connectors/oauthStateStore.js.map +1 -1
  178. package/dist/connectors/obsidian.d.ts +83 -0
  179. package/dist/connectors/obsidian.js +441 -0
  180. package/dist/connectors/obsidian.js.map +1 -0
  181. package/dist/connectors/paystack.d.ts +159 -0
  182. package/dist/connectors/paystack.js +607 -0
  183. package/dist/connectors/paystack.js.map +1 -0
  184. package/dist/connectors/pipedrive.d.ts +189 -0
  185. package/dist/connectors/pipedrive.js +559 -0
  186. package/dist/connectors/pipedrive.js.map +1 -0
  187. package/dist/connectors/postgres.d.ts +127 -0
  188. package/dist/connectors/postgres.js +512 -0
  189. package/dist/connectors/postgres.js.map +1 -0
  190. package/dist/connectors/posthog.d.ts +119 -0
  191. package/dist/connectors/posthog.js +479 -0
  192. package/dist/connectors/posthog.js.map +1 -0
  193. package/dist/connectors/redis.d.ts +140 -0
  194. package/dist/connectors/redis.js +571 -0
  195. package/dist/connectors/redis.js.map +1 -0
  196. package/dist/connectors/resend.d.ts +131 -0
  197. package/dist/connectors/resend.js +529 -0
  198. package/dist/connectors/resend.js.map +1 -0
  199. package/dist/connectors/salesforce.d.ts +136 -0
  200. package/dist/connectors/salesforce.js +603 -0
  201. package/dist/connectors/salesforce.js.map +1 -0
  202. package/dist/connectors/secrets.d.ts +51 -0
  203. package/dist/connectors/secrets.js +102 -0
  204. package/dist/connectors/secrets.js.map +1 -0
  205. package/dist/connectors/sendgrid.d.ts +102 -0
  206. package/dist/connectors/sendgrid.js +423 -0
  207. package/dist/connectors/sendgrid.js.map +1 -0
  208. package/dist/connectors/shopify.d.ts +145 -0
  209. package/dist/connectors/shopify.js +502 -0
  210. package/dist/connectors/shopify.js.map +1 -0
  211. package/dist/connectors/slack.d.ts +1 -1
  212. package/dist/connectors/slack.js +61 -9
  213. package/dist/connectors/slack.js.map +1 -1
  214. package/dist/connectors/snowflake.d.ts +119 -0
  215. package/dist/connectors/snowflake.js +615 -0
  216. package/dist/connectors/snowflake.js.map +1 -0
  217. package/dist/connectors/supabase.d.ts +92 -0
  218. package/dist/connectors/supabase.js +630 -0
  219. package/dist/connectors/supabase.js.map +1 -0
  220. package/dist/connectors/todoist.d.ts +117 -0
  221. package/dist/connectors/todoist.js +485 -0
  222. package/dist/connectors/todoist.js.map +1 -0
  223. package/dist/connectors/tokenStorage.js +56 -14
  224. package/dist/connectors/tokenStorage.js.map +1 -1
  225. package/dist/connectors/twilio.d.ts +118 -0
  226. package/dist/connectors/twilio.js +475 -0
  227. package/dist/connectors/twilio.js.map +1 -0
  228. package/dist/connectors/vercel.d.ts +110 -0
  229. package/dist/connectors/vercel.js +479 -0
  230. package/dist/connectors/vercel.js.map +1 -0
  231. package/dist/connectors/webflow.d.ts +118 -0
  232. package/dist/connectors/webflow.js +393 -0
  233. package/dist/connectors/webflow.js.map +1 -0
  234. package/dist/connectors/woocommerce.d.ts +220 -0
  235. package/dist/connectors/woocommerce.js +464 -0
  236. package/dist/connectors/woocommerce.js.map +1 -0
  237. package/dist/crypto.js +7 -2
  238. package/dist/crypto.js.map +1 -1
  239. package/dist/decisionReplay.js +15 -6
  240. package/dist/decisionReplay.js.map +1 -1
  241. package/dist/decisionTraceLog.d.ts +28 -0
  242. package/dist/decisionTraceLog.js +156 -29
  243. package/dist/decisionTraceLog.js.map +1 -1
  244. package/dist/drivers/claude/api.js +5 -4
  245. package/dist/drivers/claude/api.js.map +1 -1
  246. package/dist/drivers/claude/envSanitizer.d.ts +0 -6
  247. package/dist/drivers/claude/envSanitizer.js +17 -2
  248. package/dist/drivers/claude/envSanitizer.js.map +1 -1
  249. package/dist/drivers/claude/streamParser.js +5 -4
  250. package/dist/drivers/claude/streamParser.js.map +1 -1
  251. package/dist/drivers/claude/subprocess.d.ts +12 -2
  252. package/dist/drivers/claude/subprocess.js +100 -8
  253. package/dist/drivers/claude/subprocess.js.map +1 -1
  254. package/dist/drivers/gemini/api.d.ts +18 -0
  255. package/dist/drivers/gemini/api.js +29 -0
  256. package/dist/drivers/gemini/api.js.map +1 -0
  257. package/dist/drivers/gemini/index.d.ts +22 -0
  258. package/dist/drivers/gemini/index.js +256 -129
  259. package/dist/drivers/gemini/index.js.map +1 -1
  260. package/dist/drivers/index.d.ts +3 -1
  261. package/dist/drivers/index.js +9 -1
  262. package/dist/drivers/index.js.map +1 -1
  263. package/dist/drivers/local/index.d.ts +43 -0
  264. package/dist/drivers/local/index.js +140 -0
  265. package/dist/drivers/local/index.js.map +1 -0
  266. package/dist/drivers/openai/index.js +30 -2
  267. package/dist/drivers/openai/index.js.map +1 -1
  268. package/dist/extensionClient.d.ts +37 -4
  269. package/dist/extensionClient.js +58 -16
  270. package/dist/extensionClient.js.map +1 -1
  271. package/dist/featureFlags.d.ts +91 -0
  272. package/dist/featureFlags.js +174 -3
  273. package/dist/featureFlags.js.map +1 -1
  274. package/dist/fileLock.js +21 -12
  275. package/dist/fileLock.js.map +1 -1
  276. package/dist/fileLockSync.d.ts +67 -0
  277. package/dist/fileLockSync.js +126 -0
  278. package/dist/fileLockSync.js.map +1 -0
  279. package/dist/fp/activityAnalytics.js +26 -12
  280. package/dist/fp/activityAnalytics.js.map +1 -1
  281. package/dist/fp/automationInterpreter.d.ts +15 -1
  282. package/dist/fp/automationInterpreter.js +217 -82
  283. package/dist/fp/automationInterpreter.js.map +1 -1
  284. package/dist/fp/automationProgram.d.ts +30 -0
  285. package/dist/fp/automationProgram.js.map +1 -1
  286. package/dist/fp/automationState.d.ts +24 -5
  287. package/dist/fp/automationState.js +56 -9
  288. package/dist/fp/automationState.js.map +1 -1
  289. package/dist/fp/automationUtils.js +1 -1
  290. package/dist/fp/automationUtils.js.map +1 -1
  291. package/dist/fp/commandDescription.js +7 -1
  292. package/dist/fp/commandDescription.js.map +1 -1
  293. package/dist/fp/extensionSnapshot.js +8 -2
  294. package/dist/fp/extensionSnapshot.js.map +1 -1
  295. package/dist/fp/interpreterContext.d.ts +66 -1
  296. package/dist/fp/interpreterContext.js +91 -1
  297. package/dist/fp/interpreterContext.js.map +1 -1
  298. package/dist/fp/policyParser.js +29 -1
  299. package/dist/fp/policyParser.js.map +1 -1
  300. package/dist/fsWatchWithFallback.d.ts +36 -0
  301. package/dist/fsWatchWithFallback.js +128 -0
  302. package/dist/fsWatchWithFallback.js.map +1 -0
  303. package/dist/haltPushDispatch.d.ts +33 -0
  304. package/dist/haltPushDispatch.js +103 -0
  305. package/dist/haltPushDispatch.js.map +1 -0
  306. package/dist/httpBodyValidation.d.ts +41 -0
  307. package/dist/httpBodyValidation.js +45 -0
  308. package/dist/httpBodyValidation.js.map +1 -0
  309. package/dist/httpErrorResponse.d.ts +36 -0
  310. package/dist/httpErrorResponse.js +46 -0
  311. package/dist/httpErrorResponse.js.map +1 -0
  312. package/dist/inboxRoutes.d.ts +22 -0
  313. package/dist/inboxRoutes.js +151 -12
  314. package/dist/inboxRoutes.js.map +1 -1
  315. package/dist/index.d.ts +1 -1
  316. package/dist/index.js +1054 -76
  317. package/dist/index.js.map +1 -1
  318. package/dist/installGuard.js +6 -2
  319. package/dist/installGuard.js.map +1 -1
  320. package/dist/lockfile.js +31 -4
  321. package/dist/lockfile.js.map +1 -1
  322. package/dist/oauth.d.ts +22 -0
  323. package/dist/oauth.js +46 -0
  324. package/dist/oauth.js.map +1 -1
  325. package/dist/oauthRoutes.d.ts +1 -1
  326. package/dist/oauthRoutes.js +8 -11
  327. package/dist/oauthRoutes.js.map +1 -1
  328. package/dist/orchestrator/childBridgeRegistry.js +29 -11
  329. package/dist/orchestrator/childBridgeRegistry.js.map +1 -1
  330. package/dist/patchworkConfig.d.ts +57 -1
  331. package/dist/patchworkConfig.js +125 -5
  332. package/dist/patchworkConfig.js.map +1 -1
  333. package/dist/pluginLoader.js +10 -1
  334. package/dist/pluginLoader.js.map +1 -1
  335. package/dist/pluginWatcher.js +12 -14
  336. package/dist/pluginWatcher.js.map +1 -1
  337. package/dist/preToolUseHook.js +10 -3
  338. package/dist/preToolUseHook.js.map +1 -1
  339. package/dist/processTree.d.ts +34 -0
  340. package/dist/processTree.js +105 -0
  341. package/dist/processTree.js.map +1 -0
  342. package/dist/prompts.js +7 -3
  343. package/dist/prompts.js.map +1 -1
  344. package/dist/recipeOrchestration.d.ts +9 -0
  345. package/dist/recipeOrchestration.js +354 -27
  346. package/dist/recipeOrchestration.js.map +1 -1
  347. package/dist/recipeRoutes.d.ts +63 -2
  348. package/dist/recipeRoutes.js +790 -137
  349. package/dist/recipeRoutes.js.map +1 -1
  350. package/dist/recipes/RecipeOrchestrator.d.ts +2 -0
  351. package/dist/recipes/RecipeOrchestrator.js +6 -1
  352. package/dist/recipes/RecipeOrchestrator.js.map +1 -1
  353. package/dist/recipes/agentExecutor.d.ts +34 -5
  354. package/dist/recipes/agentExecutor.js +5 -4
  355. package/dist/recipes/agentExecutor.js.map +1 -1
  356. package/dist/recipes/chainedRunner.d.ts +2 -0
  357. package/dist/recipes/chainedRunner.js +142 -5
  358. package/dist/recipes/chainedRunner.js.map +1 -1
  359. package/dist/recipes/connectorPreflight.d.ts +66 -0
  360. package/dist/recipes/connectorPreflight.js +169 -0
  361. package/dist/recipes/connectorPreflight.js.map +1 -0
  362. package/dist/recipes/dependencyGraph.js +13 -5
  363. package/dist/recipes/dependencyGraph.js.map +1 -1
  364. package/dist/recipes/githubInstallSource.d.ts +128 -0
  365. package/dist/recipes/githubInstallSource.js +206 -0
  366. package/dist/recipes/githubInstallSource.js.map +1 -0
  367. package/dist/recipes/haltCategory.d.ts +119 -0
  368. package/dist/recipes/haltCategory.js +188 -0
  369. package/dist/recipes/haltCategory.js.map +1 -0
  370. package/dist/recipes/idempotencyKey.d.ts +134 -0
  371. package/dist/recipes/idempotencyKey.js +322 -0
  372. package/dist/recipes/idempotencyKey.js.map +1 -0
  373. package/dist/recipes/installer.js +48 -2
  374. package/dist/recipes/installer.js.map +1 -1
  375. package/dist/recipes/judgeSummary.d.ts +50 -0
  376. package/dist/recipes/judgeSummary.js +47 -0
  377. package/dist/recipes/judgeSummary.js.map +1 -0
  378. package/dist/recipes/judgeVerdict.d.ts +48 -0
  379. package/dist/recipes/judgeVerdict.js +174 -0
  380. package/dist/recipes/judgeVerdict.js.map +1 -0
  381. package/dist/recipes/migrations/index.d.ts +9 -0
  382. package/dist/recipes/migrations/index.js +133 -0
  383. package/dist/recipes/migrations/index.js.map +1 -1
  384. package/dist/recipes/names.d.ts +20 -0
  385. package/dist/recipes/names.js +25 -0
  386. package/dist/recipes/names.js.map +1 -1
  387. package/dist/recipes/parser.js +88 -5
  388. package/dist/recipes/parser.js.map +1 -1
  389. package/dist/recipes/replayRun.js +1 -1
  390. package/dist/recipes/replayRun.js.map +1 -1
  391. package/dist/recipes/runBudget.d.ts +70 -0
  392. package/dist/recipes/runBudget.js +109 -0
  393. package/dist/recipes/runBudget.js.map +1 -0
  394. package/dist/recipes/scheduler.d.ts +30 -0
  395. package/dist/recipes/scheduler.js +69 -19
  396. package/dist/recipes/scheduler.js.map +1 -1
  397. package/dist/recipes/schema.d.ts +36 -0
  398. package/dist/recipes/schemaGenerator.js +103 -5
  399. package/dist/recipes/schemaGenerator.js.map +1 -1
  400. package/dist/recipes/stepObservation.js +9 -0
  401. package/dist/recipes/stepObservation.js.map +1 -1
  402. package/dist/recipes/toolRegistry.js +20 -3
  403. package/dist/recipes/toolRegistry.js.map +1 -1
  404. package/dist/recipes/tools/fanOut.d.ts +20 -0
  405. package/dist/recipes/tools/fanOut.js +199 -0
  406. package/dist/recipes/tools/fanOut.js.map +1 -0
  407. package/dist/recipes/tools/file.js +5 -2
  408. package/dist/recipes/tools/file.js.map +1 -1
  409. package/dist/recipes/tools/github.d.ts +1 -1
  410. package/dist/recipes/tools/github.js +75 -1
  411. package/dist/recipes/tools/github.js.map +1 -1
  412. package/dist/recipes/tools/gmail.js +45 -6
  413. package/dist/recipes/tools/gmail.js.map +1 -1
  414. package/dist/recipes/tools/googleDrive.js +64 -0
  415. package/dist/recipes/tools/googleDrive.js.map +1 -1
  416. package/dist/recipes/tools/http.d.ts +10 -0
  417. package/dist/recipes/tools/http.js +176 -0
  418. package/dist/recipes/tools/http.js.map +1 -0
  419. package/dist/recipes/tools/index.d.ts +2 -0
  420. package/dist/recipes/tools/index.js +2 -0
  421. package/dist/recipes/tools/index.js.map +1 -1
  422. package/dist/recipes/tools/slack.js +1 -1
  423. package/dist/recipes/validation.d.ts +17 -0
  424. package/dist/recipes/validation.js +29 -11
  425. package/dist/recipes/validation.js.map +1 -1
  426. package/dist/recipes/workspaceRoot.d.ts +37 -0
  427. package/dist/recipes/workspaceRoot.js +73 -0
  428. package/dist/recipes/workspaceRoot.js.map +1 -0
  429. package/dist/recipes/yamlPositions.d.ts +56 -0
  430. package/dist/recipes/yamlPositions.js +183 -0
  431. package/dist/recipes/yamlPositions.js.map +1 -0
  432. package/dist/recipes/yamlRunner.d.ts +195 -8
  433. package/dist/recipes/yamlRunner.js +877 -209
  434. package/dist/recipes/yamlRunner.js.map +1 -1
  435. package/dist/recipesHttp.d.ts +33 -3
  436. package/dist/recipesHttp.js +126 -12
  437. package/dist/recipesHttp.js.map +1 -1
  438. package/dist/resources.js +21 -13
  439. package/dist/resources.js.map +1 -1
  440. package/dist/runLog.d.ts +58 -5
  441. package/dist/runLog.js +98 -21
  442. package/dist/runLog.js.map +1 -1
  443. package/dist/sanitizeParsedJson.d.ts +39 -0
  444. package/dist/sanitizeParsedJson.js +55 -0
  445. package/dist/sanitizeParsedJson.js.map +1 -0
  446. package/dist/schemas/recipe.v1.json +885 -196
  447. package/dist/server.d.ts +166 -3
  448. package/dist/server.js +1313 -332
  449. package/dist/server.js.map +1 -1
  450. package/dist/sessionCheckpoint.d.ts +8 -0
  451. package/dist/sessionCheckpoint.js +33 -3
  452. package/dist/sessionCheckpoint.js.map +1 -1
  453. package/dist/ssrfGuard.d.ts +16 -0
  454. package/dist/ssrfGuard.js +87 -4
  455. package/dist/ssrfGuard.js.map +1 -1
  456. package/dist/streamableHttp.d.ts +9 -4
  457. package/dist/streamableHttp.js +76 -20
  458. package/dist/streamableHttp.js.map +1 -1
  459. package/dist/telemetry.js +19 -12
  460. package/dist/telemetry.js.map +1 -1
  461. package/dist/testing/shadowRun.d.ts +52 -0
  462. package/dist/testing/shadowRun.js +71 -0
  463. package/dist/testing/shadowRun.js.map +1 -0
  464. package/dist/tools/batchLsp.d.ts +3 -0
  465. package/dist/tools/bridgeDoctor.d.ts +11 -0
  466. package/dist/tools/bridgeDoctor.js +48 -2
  467. package/dist/tools/bridgeDoctor.js.map +1 -1
  468. package/dist/tools/bridgeStatus.d.ts +0 -12
  469. package/dist/tools/bridgeStatus.js +2 -28
  470. package/dist/tools/bridgeStatus.js.map +1 -1
  471. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  472. package/dist/tools/clipboard.d.ts +2 -0
  473. package/dist/tools/closeTabs.d.ts +1 -0
  474. package/dist/tools/codeLens.d.ts +1 -0
  475. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  476. package/dist/tools/ctxGetTaskContext.d.ts +9 -0
  477. package/dist/tools/ctxGetTaskContext.js +50 -2
  478. package/dist/tools/ctxGetTaskContext.js.map +1 -1
  479. package/dist/tools/ctxQueryTraces.js +32 -24
  480. package/dist/tools/ctxQueryTraces.js.map +1 -1
  481. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  482. package/dist/tools/debug.d.ts +4 -0
  483. package/dist/tools/decorations.d.ts +2 -0
  484. package/dist/tools/detectUnusedCode.js +9 -7
  485. package/dist/tools/detectUnusedCode.js.map +1 -1
  486. package/dist/tools/documentLinks.d.ts +1 -0
  487. package/dist/tools/editText.d.ts +1 -0
  488. package/dist/tools/editText.js +2 -1
  489. package/dist/tools/editText.js.map +1 -1
  490. package/dist/tools/enrichCommit.d.ts +1 -0
  491. package/dist/tools/enrichStackTrace.js +11 -5
  492. package/dist/tools/enrichStackTrace.js.map +1 -1
  493. package/dist/tools/explainDiagnostic.d.ts +1 -0
  494. package/dist/tools/explainSymbol.d.ts +1 -0
  495. package/dist/tools/fileOperations.d.ts +3 -0
  496. package/dist/tools/fileOperations.js +2 -1
  497. package/dist/tools/fileOperations.js.map +1 -1
  498. package/dist/tools/fileWatcher.d.ts +2 -0
  499. package/dist/tools/fileWatcher.js +8 -2
  500. package/dist/tools/fileWatcher.js.map +1 -1
  501. package/dist/tools/findFiles.d.ts +1 -0
  502. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  503. package/dist/tools/fixAllLintErrors.js +10 -5
  504. package/dist/tools/fixAllLintErrors.js.map +1 -1
  505. package/dist/tools/foldingRanges.d.ts +1 -0
  506. package/dist/tools/formatDocument.d.ts +1 -0
  507. package/dist/tools/formatDocument.js +10 -5
  508. package/dist/tools/formatDocument.js.map +1 -1
  509. package/dist/tools/generateTests.d.ts +1 -0
  510. package/dist/tools/getAIComments.d.ts +1 -0
  511. package/dist/tools/getAnalyticsReport.js +5 -1
  512. package/dist/tools/getAnalyticsReport.js.map +1 -1
  513. package/dist/tools/getBufferContent.d.ts +1 -0
  514. package/dist/tools/getChangeImpact.d.ts +1 -0
  515. package/dist/tools/getChangeImpact.js +23 -14
  516. package/dist/tools/getChangeImpact.js.map +1 -1
  517. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  518. package/dist/tools/getCodeCoverage.d.ts +1 -0
  519. package/dist/tools/getCodeCoverage.js +32 -14
  520. package/dist/tools/getCodeCoverage.js.map +1 -1
  521. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  522. package/dist/tools/getDebugState.d.ts +1 -0
  523. package/dist/tools/getDiagnostics.js +32 -29
  524. package/dist/tools/getDiagnostics.js.map +1 -1
  525. package/dist/tools/getDiffFromHandoff.js +8 -2
  526. package/dist/tools/getDiffFromHandoff.js.map +1 -1
  527. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  528. package/dist/tools/getGitHotspots.d.ts +1 -0
  529. package/dist/tools/getImportedSignatures.d.ts +1 -0
  530. package/dist/tools/getImportedSignatures.js +18 -14
  531. package/dist/tools/getImportedSignatures.js.map +1 -1
  532. package/dist/tools/getPRTemplate.d.ts +1 -0
  533. package/dist/tools/getProjectContext.js +23 -10
  534. package/dist/tools/getProjectContext.js.map +1 -1
  535. package/dist/tools/getSymbolHistory.d.ts +1 -0
  536. package/dist/tools/getToolCapabilities.d.ts +10 -5
  537. package/dist/tools/getToolCapabilities.js +79 -202
  538. package/dist/tools/getToolCapabilities.js.map +1 -1
  539. package/dist/tools/getTypeSignature.d.ts +1 -0
  540. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  541. package/dist/tools/gitWrite.d.ts +11 -0
  542. package/dist/tools/github/actions.d.ts +2 -0
  543. package/dist/tools/github/composite.d.ts +3 -0
  544. package/dist/tools/github/issues.d.ts +4 -0
  545. package/dist/tools/github/pr.d.ts +7 -0
  546. package/dist/tools/handoffNote.d.ts +1 -0
  547. package/dist/tools/handoffNote.js +2 -1
  548. package/dist/tools/handoffNote.js.map +1 -1
  549. package/dist/tools/headless/lspClient.js +3 -0
  550. package/dist/tools/headless/lspClient.js.map +1 -1
  551. package/dist/tools/hoverAtCursor.d.ts +1 -0
  552. package/dist/tools/httpClient.d.ts +2 -0
  553. package/dist/tools/httpClient.js +38 -71
  554. package/dist/tools/httpClient.js.map +1 -1
  555. package/dist/tools/inlayHints.d.ts +1 -0
  556. package/dist/tools/launchQuickTask.d.ts +1 -0
  557. package/dist/tools/listClaudeTasks.d.ts +1 -0
  558. package/dist/tools/listClaudeTasks.js +10 -8
  559. package/dist/tools/listClaudeTasks.js.map +1 -1
  560. package/dist/tools/listTerminals.d.ts +1 -0
  561. package/dist/tools/lsp.d.ts +15 -0
  562. package/dist/tools/lsp.js +17 -0
  563. package/dist/tools/lsp.js.map +1 -1
  564. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  565. package/dist/tools/openDiff.d.ts +1 -0
  566. package/dist/tools/openDiff.js +4 -1
  567. package/dist/tools/openDiff.js.map +1 -1
  568. package/dist/tools/openFile.d.ts +1 -0
  569. package/dist/tools/openFile.js +4 -1
  570. package/dist/tools/openFile.js.map +1 -1
  571. package/dist/tools/openInBrowser.js +6 -1
  572. package/dist/tools/openInBrowser.js.map +1 -1
  573. package/dist/tools/organizeImports.d.ts +1 -0
  574. package/dist/tools/organizeImports.js +5 -3
  575. package/dist/tools/organizeImports.js.map +1 -1
  576. package/dist/tools/performanceReport.js +5 -3
  577. package/dist/tools/performanceReport.js.map +1 -1
  578. package/dist/tools/planPersistence.d.ts +3 -0
  579. package/dist/tools/planPersistence.js +4 -1
  580. package/dist/tools/planPersistence.js.map +1 -1
  581. package/dist/tools/previewEdit.d.ts +1 -0
  582. package/dist/tools/previewEdit.js +15 -4
  583. package/dist/tools/previewEdit.js.map +1 -1
  584. package/dist/tools/recentTracesDigest.js +54 -11
  585. package/dist/tools/recentTracesDigest.js.map +1 -1
  586. package/dist/tools/refactorAnalyze.d.ts +1 -0
  587. package/dist/tools/refactorExtractFunction.js +4 -1
  588. package/dist/tools/refactorExtractFunction.js.map +1 -1
  589. package/dist/tools/refactorPreview.d.ts +1 -0
  590. package/dist/tools/refactorPreview.js +10 -2
  591. package/dist/tools/refactorPreview.js.map +1 -1
  592. package/dist/tools/replaceBlock.d.ts +1 -0
  593. package/dist/tools/replaceBlock.js +2 -1
  594. package/dist/tools/replaceBlock.js.map +1 -1
  595. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  596. package/dist/tools/runClaudeTask.d.ts +1 -0
  597. package/dist/tools/runCommand.js +5 -0
  598. package/dist/tools/runCommand.js.map +1 -1
  599. package/dist/tools/runTests.js +15 -4
  600. package/dist/tools/runTests.js.map +1 -1
  601. package/dist/tools/screenshot.d.ts +1 -0
  602. package/dist/tools/screenshotAndAnnotate.js +6 -2
  603. package/dist/tools/screenshotAndAnnotate.js.map +1 -1
  604. package/dist/tools/searchAndReplace.d.ts +1 -0
  605. package/dist/tools/searchAndReplace.js +17 -7
  606. package/dist/tools/searchAndReplace.js.map +1 -1
  607. package/dist/tools/searchTools.js +20 -19
  608. package/dist/tools/searchTools.js.map +1 -1
  609. package/dist/tools/searchWorkspace.d.ts +1 -0
  610. package/dist/tools/selectionRanges.d.ts +1 -0
  611. package/dist/tools/semanticTokens.d.ts +1 -0
  612. package/dist/tools/signatureHelp.d.ts +1 -0
  613. package/dist/tools/spawnWorkspace.js +15 -7
  614. package/dist/tools/spawnWorkspace.js.map +1 -1
  615. package/dist/tools/terminal.d.ts +6 -0
  616. package/dist/tools/terminal.js +4 -0
  617. package/dist/tools/terminal.js.map +1 -1
  618. package/dist/tools/testRunners/pytest.js +6 -2
  619. package/dist/tools/testRunners/pytest.js.map +1 -1
  620. package/dist/tools/testRunners/vitestJest.js +3 -1
  621. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  622. package/dist/tools/testTraceToSource.d.ts +1 -0
  623. package/dist/tools/transaction.d.ts +4 -0
  624. package/dist/tools/transaction.js +4 -1
  625. package/dist/tools/transaction.js.map +1 -1
  626. package/dist/tools/typeHierarchy.d.ts +1 -0
  627. package/dist/tools/utils.d.ts +22 -0
  628. package/dist/tools/utils.js +158 -14
  629. package/dist/tools/utils.js.map +1 -1
  630. package/dist/tools/vscodeCommands.d.ts +2 -0
  631. package/dist/tools/vscodeTasks.d.ts +2 -0
  632. package/dist/tools/workspaceSettings.d.ts +1 -0
  633. package/dist/transport.d.ts +3 -1
  634. package/dist/transport.js +103 -52
  635. package/dist/transport.js.map +1 -1
  636. package/dist/winShim.d.ts +34 -0
  637. package/dist/winShim.js +94 -0
  638. package/dist/winShim.js.map +1 -0
  639. package/dist/wireHaltPushDispatch.d.ts +38 -0
  640. package/dist/wireHaltPushDispatch.js +71 -0
  641. package/dist/wireHaltPushDispatch.js.map +1 -0
  642. package/dist/writeFileAtomic.d.ts +23 -0
  643. package/dist/writeFileAtomic.js +121 -0
  644. package/dist/writeFileAtomic.js.map +1 -0
  645. package/package.json +23 -7
  646. package/scripts/postinstall.mjs +42 -2
  647. package/scripts/smoke/run-all.mjs +213 -0
  648. package/scripts/start-all.mjs +572 -0
  649. package/scripts/start-all.ps1 +209 -0
  650. package/scripts/start-all.sh +77 -19
  651. package/scripts/start-orchestrator.ps1 +158 -0
  652. package/scripts/start-remote.mjs +122 -0
  653. package/templates/automation-policies/recipe-authoring.json +1 -1
  654. package/templates/automation-policies/security-first.json +1 -1
  655. package/templates/automation-policies/strict-lint.json +1 -1
  656. package/templates/automation-policies/test-driven.json +1 -1
  657. package/templates/automation-policy.example.json +1 -1
  658. package/templates/co.patchwork-os.bridge.plist +2 -2
  659. package/templates/recipes/approval-queue-ui-test.yaml +205 -0
  660. package/templates/recipes/ctx-loop-test.yaml +1 -1
  661. package/templates/recipes/fix-errors-on-save.yaml +71 -0
  662. package/templates/recipes/morning-brief.yaml +5 -2
  663. package/templates/recipes/project-health-check.yaml +4 -1
  664. package/templates/recipes/sentry-to-linear.yaml +72 -38
  665. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  666. package/templates/recipes/webhook/customer-escalation.yaml +8 -9
  667. package/templates/recipes/webhook/meeting-prep.yaml +11 -5
  668. package/dist/commands/marketplace.d.ts +0 -11
  669. package/dist/commands/marketplace.js +0 -120
  670. package/dist/commands/marketplace.js.map +0 -1
  671. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  672. package/dist/recipes/legacyRecipeCompat.js +0 -131
  673. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -29,12 +29,33 @@
29
29
  import os from "node:os";
30
30
  import path from "node:path";
31
31
  import { computeSummary as computeActivationSummary, loadMetrics as loadActivationMetrics, } from "./activationMetrics.js";
32
+ import { isWriteKillSwitchActive } from "./featureFlags.js";
32
33
  import { consumeToken, refillBucket, } from "./fp/tokenBucket.js";
34
+ import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
35
+ import { respond500 } from "./httpErrorResponse.js";
33
36
  import { validateSafeUrl } from "./ssrfGuard.js";
34
37
  // 5-minute cache of the public template registry from the patchworkos/recipes
35
38
  // GitHub repo. Process-wide; hoisted out of Server class state.
39
+ // Sentinel `false` marks a negative-cache entry (fetch failed) — distinct from
40
+ // `null` (never fetched) so the timestamp-based retry window is respected.
36
41
  let templatesCache = null;
37
42
  let templatesCacheTs = 0;
43
+ /**
44
+ * #605: shape-validate the upstream templates payload before caching.
45
+ * The minimal contract used by the dashboard marketplace is `{recipes:
46
+ * Array}` (other fields are optional). Anything else (an error page
47
+ * JSON, a tampered file with a flipped key, a future GitHub schema
48
+ * change) is rejected so we don't serve garbage for 5 minutes to every
49
+ * dashboard client.
50
+ */
51
+ function isWellFormedTemplatesPayload(raw) {
52
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
53
+ // Some legacy callers used a bare array; accept that too.
54
+ return Array.isArray(raw);
55
+ }
56
+ const r = raw;
57
+ return Array.isArray(r.recipes);
58
+ }
38
59
  /**
39
60
  * Per-process token bucket guarding `/recipes/generate`. Every call to the
40
61
  * route enqueues a Claude subprocess via the orchestrator — without a cap a
@@ -62,6 +83,26 @@ export function _resetGenerateRateLimitForTests() {
62
83
  lastRefill: 0,
63
84
  };
64
85
  }
86
+ /**
87
+ * Phase 2A: separate cooldown bucket for `/recipes/repair`. Repair
88
+ * costs the same per-call tokens as generate but a user dogfooding the
89
+ * "Fix with AI" button may legitimately click it 2-3× back-to-back
90
+ * (preview a fix, discard, ask again). Giving repair its own 10/min
91
+ * bucket avoids starving generate when a user is iterating on repair,
92
+ * and the loop-hazard cap I flagged in plan-review applies here
93
+ * separately.
94
+ */
95
+ const RECIPE_REPAIR_LIMIT_PER_MIN = 10;
96
+ let recipeRepairBucket = {
97
+ tokens: RECIPE_REPAIR_LIMIT_PER_MIN,
98
+ lastRefill: 0,
99
+ };
100
+ export function _resetRepairRateLimitForTests() {
101
+ recipeRepairBucket = {
102
+ tokens: RECIPE_REPAIR_LIMIT_PER_MIN,
103
+ lastRefill: 0,
104
+ };
105
+ }
65
106
  // G-security R2 C-3 / I-3 / F-02: HTTP `vars` validation.
66
107
  //
67
108
  // The post-render path jail in `src/recipes/resolveRecipePath.ts` is the
@@ -141,6 +182,12 @@ export const RECIPE_ROUTE_BODY_CAPS = {
141
182
  run: 32 * 1024,
142
183
  /** /recipes (POST), PUT/PATCH /recipes/:name, /recipes/lint — yaml content. */
143
184
  content: 256 * 1024,
185
+ /**
186
+ * /recipes/repair — `{ currentYaml: string, lintIssues: LintIssue[] }`.
187
+ * Cap matches `content` since the body carries the full recipe YAML
188
+ * (256 KB matches the existing PUT/POST/lint caps).
189
+ */
190
+ repair: 256 * 1024,
144
191
  };
145
192
  /**
146
193
  * Read an HTTP request body up to `max` bytes. Returns the raw string or
@@ -187,7 +234,11 @@ export function readBodyWithCap(req, max) {
187
234
  const onEnd = () => {
188
235
  if (aborted)
189
236
  return;
190
- resolve({ ok: true, body: Buffer.concat(chunks).toString("utf-8") });
237
+ const bytes = Buffer.concat(chunks);
238
+ // `bytes` is the raw on-the-wire body; `body` is the utf-8 decode used
239
+ // by JSON parsers. HMAC consumers must use `bytes` to avoid the
240
+ // utf-8 round-trip changing the signed payload.
241
+ resolve({ ok: true, body: bytes.toString("utf-8"), bytes });
191
242
  };
192
243
  const onError = () => {
193
244
  if (aborted)
@@ -253,6 +304,24 @@ function respondInvalidJson(res) {
253
304
  res.writeHead(400, { "Content-Type": "application/json" });
254
305
  res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
255
306
  }
307
+ /**
308
+ * Best-effort fire of the recipe-changed notification. Wraps the
309
+ * callback in try/catch + console.error so a misbehaving notifier
310
+ * (most likely scheduler.start() throwing) cannot turn a successful
311
+ * disk-write into a failed-looking HTTP response. Used by install /
312
+ * save / delete / archive / duplicate / setEnabled / saveContent
313
+ * routes after their respective success paths.
314
+ */
315
+ function fireOnRecipesChanged(deps) {
316
+ if (!deps.onRecipesChangedFn)
317
+ return;
318
+ try {
319
+ deps.onRecipesChangedFn();
320
+ }
321
+ catch (err) {
322
+ console.error(`[recipeRoutes] onRecipesChangedFn threw:`, err);
323
+ }
324
+ }
256
325
  /**
257
326
  * Try to handle a recipe / run-audit / template route. Returns true if
258
327
  * the route was dispatched (caller should `return` from the request
@@ -280,6 +349,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
280
349
  }
281
350
  try {
282
351
  const parsed = parsedBody.value ?? {};
352
+ if (respondIfUnknownBodyKeys(res, parsed, ["vars", "inputs"]))
353
+ return;
283
354
  const varsRaw = parsed.vars ?? parsed.inputs;
284
355
  const varsErr = validateRecipeVars(varsRaw);
285
356
  if (varsErr) {
@@ -294,7 +365,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
294
365
  res.writeHead(503, { "Content-Type": "application/json" });
295
366
  res.end(JSON.stringify({
296
367
  ok: false,
297
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
368
+ error: "Recipe execution unavailable — requires --driver subprocess",
298
369
  }));
299
370
  return;
300
371
  }
@@ -325,17 +396,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
325
396
  }
326
397
  try {
327
398
  const parsed = parsedBody.value ?? {};
399
+ if (respondIfUnknownBodyKeys(res, parsed, ["name", "vars", "inputs"]))
400
+ return;
328
401
  const name = parsed.name;
329
- const varsErr = validateRecipeVars(parsed.vars);
402
+ // #605 symmetry — accept `inputs` here like /recipes/:name/run does.
403
+ const varsRaw = parsed.vars ?? parsed.inputs;
404
+ const varsErr = validateRecipeVars(varsRaw);
330
405
  if (varsErr) {
331
406
  res.writeHead(400, { "Content-Type": "application/json" });
332
407
  res.end(JSON.stringify(varsErr));
333
408
  return;
334
409
  }
335
- const vars = parsed.vars &&
336
- typeof parsed.vars === "object" &&
337
- !Array.isArray(parsed.vars)
338
- ? parsed.vars
410
+ const vars = varsRaw && typeof varsRaw === "object" && !Array.isArray(varsRaw)
411
+ ? varsRaw
339
412
  : undefined;
340
413
  if (typeof name !== "string" || !name) {
341
414
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -346,7 +419,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
346
419
  res.writeHead(503, { "Content-Type": "application/json" });
347
420
  res.end(JSON.stringify({
348
421
  ok: false,
349
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
422
+ error: "Recipe execution unavailable — requires --driver subprocess",
350
423
  }));
351
424
  return;
352
425
  }
@@ -370,10 +443,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
370
443
  res.end(JSON.stringify({ metrics, summary }));
371
444
  }
372
445
  catch (err) {
373
- res.writeHead(500, { "Content-Type": "application/json" });
374
- res.end(JSON.stringify({
375
- error: err instanceof Error ? err.message : String(err),
376
- }));
446
+ respond500(res, err);
377
447
  }
378
448
  return true;
379
449
  }
@@ -385,6 +455,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
385
455
  const trigger = sp.get("trigger");
386
456
  const status = sp.get("status");
387
457
  const recipe = sp.get("recipe");
458
+ const manualRunId = sp.get("manualRunId");
388
459
  const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
389
460
  const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
390
461
  const runs = deps.runsFn?.({
@@ -392,19 +463,127 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
392
463
  ...(trigger && { trigger }),
393
464
  ...(status && { status }),
394
465
  ...(recipe && { recipe }),
466
+ ...(manualRunId && { manualRunId }),
395
467
  ...(Number.isFinite(after) && { after }),
396
468
  }) ?? [];
397
469
  res.writeHead(200, { "Content-Type": "application/json" });
398
470
  res.end(JSON.stringify({ runs }));
399
471
  }
400
472
  catch (err) {
401
- res.writeHead(500, { "Content-Type": "application/json" });
402
- res.end(JSON.stringify({
403
- error: err instanceof Error ? err.message : String(err),
404
- }));
473
+ respond500(res, err);
474
+ }
475
+ return true;
476
+ }
477
+ // GET /runs/halt-summary — aggregated halt categories over recent runs.
478
+ // Drives the dashboard /runs page header widget that answers "is the
479
+ // haltReason work surfacing real signal, or is everything 'unknown'?".
480
+ if (parsedUrl.pathname === "/runs/halt-summary" && req.method === "GET") {
481
+ try {
482
+ const sp = parsedUrl.searchParams;
483
+ const sinceMsRaw = sp.get("sinceMs");
484
+ const limitRaw = sp.get("limit");
485
+ const recipe = sp.get("recipe");
486
+ const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
487
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
488
+ const summary = deps.haltSummaryFn?.({
489
+ ...(Number.isFinite(sinceMs) && { sinceMs }),
490
+ ...(Number.isFinite(limit) && { limit }),
491
+ ...(recipe && { recipe }),
492
+ }) ?? { total: 0, byCategory: {}, recent: [] };
493
+ res.writeHead(200, { "Content-Type": "application/json" });
494
+ res.end(JSON.stringify(summary));
495
+ }
496
+ catch (err) {
497
+ respond500(res, err);
498
+ }
499
+ return true;
500
+ }
501
+ // GET /runs/judge-summary — PR3b sibling of /runs/halt-summary.
502
+ // Same query shape (sinceMs, limit, recipe), returns JudgeSummary.
503
+ if (parsedUrl.pathname === "/runs/judge-summary" && req.method === "GET") {
504
+ try {
505
+ const sp = parsedUrl.searchParams;
506
+ const sinceMsRaw = sp.get("sinceMs");
507
+ const limitRaw = sp.get("limit");
508
+ const recipe = sp.get("recipe");
509
+ const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
510
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
511
+ const summary = deps.judgeSummaryFn?.({
512
+ ...(Number.isFinite(sinceMs) && { sinceMs }),
513
+ ...(Number.isFinite(limit) && { limit }),
514
+ ...(recipe && { recipe }),
515
+ }) ?? { total: 0, byVerdict: {}, recent: [] };
516
+ res.writeHead(200, { "Content-Type": "application/json" });
517
+ res.end(JSON.stringify(summary));
518
+ }
519
+ catch (err) {
520
+ respond500(res, err);
405
521
  }
406
522
  return true;
407
523
  }
524
+ // GET /recipes/doctor?recipe=<name> — one-call recipe health diagnosis.
525
+ // Server-side home for the `recipe doctor` CLI: composes the static
526
+ // preflight check (lint + write-policy + plan) with the recipe-scoped
527
+ // runtime halt summary (reusing deps.haltSummaryFn in-process — no lock
528
+ // walk), each finding mapped to a fix hint. Read-only; fail-soft.
529
+ if (parsedUrl.pathname === "/recipes/doctor" && req.method === "GET") {
530
+ void (async () => {
531
+ try {
532
+ const recipe = parsedUrl.searchParams.get("recipe");
533
+ if (!recipe) {
534
+ res.writeHead(400, { "Content-Type": "application/json" });
535
+ res.end(JSON.stringify({
536
+ error: "missing_recipe",
537
+ message: "?recipe= is required",
538
+ }));
539
+ return;
540
+ }
541
+ // Name-only over HTTP — the dashboard always sends an installed
542
+ // recipe name. Reject path separators / traversal so this endpoint
543
+ // can't be coaxed into linting an arbitrary file off disk (the CLI
544
+ // path-resolution affordance stays CLI-only).
545
+ if (/[\\/]/.test(recipe) || recipe.includes("..")) {
546
+ res.writeHead(400, { "Content-Type": "application/json" });
547
+ res.end(JSON.stringify({
548
+ error: "invalid_recipe",
549
+ message: "recipe must be a bare name",
550
+ }));
551
+ return;
552
+ }
553
+ const { runRecipeDoctor } = await import("./commands/recipe.js");
554
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
555
+ const fetchHalts = deps.haltSummaryFn
556
+ ? async (recipeName) =>
557
+ // deps.haltSummaryFn is sync; the doctor contract is async.
558
+ deps.haltSummaryFn?.({
559
+ sinceMs: Date.now() - SEVEN_DAYS_MS,
560
+ recipe: recipeName,
561
+ }) ?? null
562
+ : undefined;
563
+ const result = await runRecipeDoctor(recipe, { fetchHalts });
564
+ res.writeHead(200, { "Content-Type": "application/json" });
565
+ res.end(JSON.stringify(result));
566
+ }
567
+ catch (err) {
568
+ // resolveRecipePath throws "recipe ... not found" for unknown
569
+ // names — map that to 404; anything else is a real 500. Never echo
570
+ // err.message back to the client: it embeds the absolute recipes
571
+ // path (info-exposure / js/stack-trace-exposure). Return a static
572
+ // message; respond500 handles logging the detail server-side.
573
+ const msg = err instanceof Error ? err.message : String(err);
574
+ if (/not found/i.test(msg)) {
575
+ res.writeHead(404, { "Content-Type": "application/json" });
576
+ res.end(JSON.stringify({
577
+ error: "recipe_not_found",
578
+ message: "recipe not found",
579
+ }));
580
+ return;
581
+ }
582
+ respond500(res, err);
583
+ }
584
+ })();
585
+ return true;
586
+ }
408
587
  // GET /runs/:seq — single run detail (includes stepResults if present)
409
588
  const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
410
589
  if (runDetailMatch?.[1]) {
@@ -421,10 +600,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
421
600
  }
422
601
  }
423
602
  catch (err) {
424
- res.writeHead(500, { "Content-Type": "application/json" });
425
- res.end(JSON.stringify({
426
- error: err instanceof Error ? err.message : String(err),
427
- }));
603
+ respond500(res, err);
428
604
  }
429
605
  return true;
430
606
  }
@@ -458,9 +634,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
458
634
  res.end(JSON.stringify(result));
459
635
  }
460
636
  catch (err) {
461
- const msg = err instanceof Error ? err.message : String(err);
462
- res.writeHead(500, { "Content-Type": "application/json" });
463
- res.end(JSON.stringify({ error: msg }));
637
+ respond500(res, err, "runs/:seq detail");
464
638
  }
465
639
  })();
466
640
  return true;
@@ -491,10 +665,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
491
665
  res.end(JSON.stringify({ plan }));
492
666
  }
493
667
  catch (err) {
494
- const msg = err instanceof Error ? err.message : String(err);
495
- const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
496
- res.writeHead(status, { "Content-Type": "application/json" });
497
- res.end(JSON.stringify({ error: msg }));
668
+ // #605: classify by error code, not message substring.
669
+ // Previously `msg.includes("not found")` would mis-map any
670
+ // deep error coincidentally containing that phrase (e.g. a
671
+ // connector returning "credential not found") to 404. Use the
672
+ // structured `code` on Node fs / our explicit error.code.
673
+ const code = err?.code;
674
+ if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
675
+ res.writeHead(404, { "Content-Type": "application/json" });
676
+ res.end(JSON.stringify({ error: "Run not found" }));
677
+ }
678
+ else {
679
+ respond500(res, err, "recipes plan");
680
+ }
498
681
  }
499
682
  })();
500
683
  return true;
@@ -545,22 +728,160 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
545
728
  res.writeHead(503, { "Content-Type": "application/json" });
546
729
  res.end(JSON.stringify({
547
730
  ok: false,
548
- error: "Recipe generation unavailable — requires --claude-driver subprocess",
731
+ error: "Recipe generation unavailable — requires --driver subprocess",
549
732
  unavailable: true,
550
733
  }));
551
734
  return;
552
735
  }
553
736
  try {
554
737
  const result = await deps.generateRecipeFn(prompt.trim());
738
+ // #605: stop collapsing every failure to 422. The dashboard
739
+ // can't distinguish 'driver crashed' from 'user prompt refused'
740
+ // from 'generated YAML failed lint' when everything maps to one
741
+ // status. Use the result.errorKind discriminant when present;
742
+ // fall back to 422 for the unstructured case.
743
+ let status;
744
+ if (result.ok) {
745
+ status = 200;
746
+ }
747
+ else if (result.unavailable) {
748
+ status = 503;
749
+ }
750
+ else {
751
+ const kind = result.errorKind;
752
+ if (kind === "driver_error" || kind === "timeout") {
753
+ status = 502; // upstream failure
754
+ }
755
+ else if (kind === "refused" || kind === "rate_limited") {
756
+ status = 429;
757
+ }
758
+ else if (kind === "lint_failed" || kind === "invalid_yaml") {
759
+ status = 422; // semantic generation failure
760
+ }
761
+ else {
762
+ status = 422; // unknown — preserve legacy
763
+ }
764
+ }
765
+ res.writeHead(status, { "Content-Type": "application/json" });
766
+ res.end(JSON.stringify(result));
767
+ }
768
+ catch (err) {
769
+ console.error(`[recipes/generate] internal error:`, err);
770
+ res.writeHead(500, { "Content-Type": "application/json" });
771
+ res.end(JSON.stringify({
772
+ ok: false,
773
+ error: "Internal server error",
774
+ }));
775
+ }
776
+ })();
777
+ return true;
778
+ }
779
+ if (parsedUrl.pathname === "/recipes/repair" && req.method === "POST") {
780
+ // Phase 2A: LLM-driven repair of a broken recipe. Gated behind
781
+ // the `recipe.repair-ai` feature flag (default off) — the input
782
+ // body carries arbitrary YAML which may have been marketplace-
783
+ // installed (= untrusted), and the orchestrator-bound prompt
784
+ // costs API tokens per call. Same shape as /recipes/generate
785
+ // (rate limit + body cap + sanitization + post-lint) but a
786
+ // distinct token bucket so back-to-back repair clicks don't
787
+ // starve generate.
788
+ void (async () => {
789
+ // Flag gate first — fail fast before consuming a bucket token.
790
+ const { isEnabled, FLAG_REPAIR_AI } = await import("./featureFlags.js");
791
+ if (!isEnabled(FLAG_REPAIR_AI)) {
792
+ res.writeHead(503, { "Content-Type": "application/json" });
793
+ res.end(JSON.stringify({
794
+ ok: false,
795
+ code: "feature_disabled",
796
+ error: "Recipe repair is gated behind the `recipe.repair-ai` feature flag (default off). Enable via the dashboard Settings → Feature flags or POST /feature-flags.",
797
+ unavailable: true,
798
+ }));
799
+ return;
800
+ }
801
+ const now = Date.now();
802
+ const refilled = refillBucket(recipeRepairBucket, now, RECIPE_REPAIR_LIMIT_PER_MIN);
803
+ const consumed = consumeToken(refilled);
804
+ recipeRepairBucket = consumed.nextState;
805
+ if (!consumed.allowed) {
806
+ const secondsToOneToken = Math.ceil(((1 - consumed.nextState.tokens) / RECIPE_REPAIR_LIMIT_PER_MIN) * 60);
807
+ res.writeHead(429, {
808
+ "Content-Type": "application/json",
809
+ "Retry-After": String(Math.max(1, secondsToOneToken)),
810
+ });
811
+ res.end(JSON.stringify({
812
+ ok: false,
813
+ error: `Rate limit exceeded — max ${RECIPE_REPAIR_LIMIT_PER_MIN} repair calls per minute`,
814
+ retryAfterSeconds: Math.max(1, secondsToOneToken),
815
+ }));
816
+ return;
817
+ }
818
+ const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.repair);
819
+ if (!parsedBody.ok) {
820
+ if (parsedBody.code === "too_large") {
821
+ respond413(res, RECIPE_ROUTE_BODY_CAPS.repair);
822
+ }
823
+ else {
824
+ respondInvalidJson(res);
825
+ }
826
+ return;
827
+ }
828
+ const body = parsedBody.value;
829
+ const currentYaml = body?.currentYaml;
830
+ if (typeof currentYaml !== "string" || !currentYaml.trim()) {
831
+ res.writeHead(400, { "Content-Type": "application/json" });
832
+ res.end(JSON.stringify({
833
+ ok: false,
834
+ error: "currentYaml must be a non-empty string",
835
+ }));
836
+ return;
837
+ }
838
+ // Coarse shape guard on lintIssues — accept array or default to
839
+ // empty. Each item is structurally checked (level + message
840
+ // strings); unknown extras are stripped to keep the prompt
841
+ // surface small.
842
+ const rawIssues = Array.isArray(body?.lintIssues) ? body.lintIssues : [];
843
+ const lintIssues = [];
844
+ for (const raw of rawIssues) {
845
+ if (raw &&
846
+ typeof raw === "object" &&
847
+ typeof raw.message === "string" &&
848
+ (raw.level === "error" ||
849
+ raw.level === "warning")) {
850
+ const r = raw;
851
+ lintIssues.push({
852
+ level: r.level,
853
+ message: r.message,
854
+ ...(typeof r.line === "number" && { line: r.line }),
855
+ ...(typeof r.column === "number" && { column: r.column }),
856
+ ...(typeof r.code === "string" && { code: r.code }),
857
+ ...(typeof r.path === "string" && { path: r.path }),
858
+ });
859
+ }
860
+ }
861
+ if (!deps.repairRecipeFn) {
862
+ res.writeHead(503, { "Content-Type": "application/json" });
863
+ res.end(JSON.stringify({
864
+ ok: false,
865
+ error: "Recipe repair unavailable — requires --driver subprocess",
866
+ unavailable: true,
867
+ }));
868
+ return;
869
+ }
870
+ try {
871
+ const result = await deps.repairRecipeFn({
872
+ currentYaml: currentYaml.trim(),
873
+ lintIssues,
874
+ });
555
875
  const status = result.ok ? 200 : result.unavailable ? 503 : 422;
556
876
  res.writeHead(status, { "Content-Type": "application/json" });
557
877
  res.end(JSON.stringify(result));
558
878
  }
559
879
  catch (err) {
880
+ console.error(`[recipes/repair] internal error:`, err);
560
881
  res.writeHead(500, { "Content-Type": "application/json" });
561
882
  res.end(JSON.stringify({
562
883
  ok: false,
563
- error: err instanceof Error ? err.message : String(err),
884
+ error: "Internal server error",
564
885
  }));
565
886
  }
566
887
  })();
@@ -595,6 +916,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
595
916
  return;
596
917
  }
597
918
  const result = deps.saveRecipeFn(draft);
919
+ if (result.ok)
920
+ fireOnRecipesChanged(deps);
598
921
  res.writeHead(result.ok ? 201 : 400, {
599
922
  "Content-Type": "application/json",
600
923
  });
@@ -623,6 +946,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
623
946
  }
624
947
  return;
625
948
  }
949
+ if (respondIfUnknownBodyKeys(res, parsedBody.value, ["level"]))
950
+ return;
626
951
  const level = parsedBody.value
627
952
  ?.level;
628
953
  if (typeof level !== "string" || !level) {
@@ -674,6 +999,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
674
999
  return;
675
1000
  }
676
1001
  const result = deps.setRecipeEnabledFn(name, body.enabled);
1002
+ // Enable/disable changes which cron triggers should fire — the
1003
+ // RecipeScheduler honours the disabled set on every start(), so
1004
+ // re-priming after a toggle picks up the change without a restart.
1005
+ if (result.ok)
1006
+ fireOnRecipesChanged(deps);
677
1007
  res.writeHead(result.ok ? 200 : 400, {
678
1008
  "Content-Type": "application/json",
679
1009
  });
@@ -746,10 +1076,15 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
746
1076
  res.end(JSON.stringify({ plan }));
747
1077
  }
748
1078
  catch (err) {
749
- const msg = err instanceof Error ? err.message : String(err);
750
- const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
751
- res.writeHead(status, { "Content-Type": "application/json" });
752
- res.end(JSON.stringify({ error: msg }));
1079
+ // #605: classify by code, not substring (see /runs/:seq/plan).
1080
+ const code = err?.code;
1081
+ if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
1082
+ res.writeHead(404, { "Content-Type": "application/json" });
1083
+ res.end(JSON.stringify({ error: "Recipe not found" }));
1084
+ }
1085
+ else {
1086
+ respond500(res, err, "recipes plan");
1087
+ }
753
1088
  }
754
1089
  })();
755
1090
  return true;
@@ -805,6 +1140,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
805
1140
  return;
806
1141
  }
807
1142
  const result = deps.saveRecipeContentFn(name, body.content);
1143
+ // Editing recipe YAML can change cron schedule, webhook path,
1144
+ // or trigger type entirely — re-prime the scheduler so the new
1145
+ // shape takes effect without a bridge restart.
1146
+ if (result.ok)
1147
+ fireOnRecipesChanged(deps);
808
1148
  res.writeHead(result.ok ? 200 : 400, {
809
1149
  "Content-Type": "application/json",
810
1150
  });
@@ -827,6 +1167,33 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
827
1167
  return true;
828
1168
  }
829
1169
  const result = deps.deleteRecipeContentFn(name);
1170
+ // Deleting a cron recipe leaves an orphaned interval in the scheduler
1171
+ // until the next start() — re-prime so it goes away.
1172
+ if (result.ok)
1173
+ fireOnRecipesChanged(deps);
1174
+ const status = result.ok
1175
+ ? 200
1176
+ : result.error === "Recipe not found"
1177
+ ? 404
1178
+ : 400;
1179
+ res.writeHead(status, { "Content-Type": "application/json" });
1180
+ res.end(JSON.stringify(result));
1181
+ return true;
1182
+ }
1183
+ // POST /recipes/:name/archive — move recipe (+ sidecar) into <recipesDir>/.archive/
1184
+ const archiveMatch = /^\/recipes\/([^/]+)\/archive$/.exec(parsedUrl.pathname);
1185
+ if (archiveMatch && req.method === "POST") {
1186
+ const name = decodeURIComponent(archiveMatch[1] ?? "");
1187
+ if (!deps.archiveRecipeFn) {
1188
+ res.writeHead(503, { "Content-Type": "application/json" });
1189
+ res.end(JSON.stringify({ ok: false, error: "Recipe archive unavailable" }));
1190
+ return true;
1191
+ }
1192
+ const result = deps.archiveRecipeFn(name);
1193
+ // Archiving moves the recipe under .archive/ where the scheduler
1194
+ // ignores it — same orphan-interval cleanup needed as for delete.
1195
+ if (result.ok)
1196
+ fireOnRecipesChanged(deps);
830
1197
  const status = result.ok
831
1198
  ? 200
832
1199
  : result.error === "Recipe not found"
@@ -846,6 +1213,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
846
1213
  return true;
847
1214
  }
848
1215
  const result = deps.duplicateRecipeFn(name);
1216
+ // Duplication adds a new recipe file to the dir — re-prime so any
1217
+ // cron trigger inside the duplicate starts firing immediately.
1218
+ if (result.ok)
1219
+ fireOnRecipesChanged(deps);
849
1220
  const status = result.ok
850
1221
  ? 201
851
1222
  : result.error === "Recipe not found"
@@ -863,10 +1234,21 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
863
1234
  void (async () => {
864
1235
  const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
865
1236
  if (!parsedBody.ok) {
866
- respondInvalidJson(res);
1237
+ // #605: distinguish too_large (413) from invalid JSON (400) —
1238
+ // sibling routes already do this; promote was the only handler
1239
+ // collapsing both to 400.
1240
+ if (parsedBody.code === "too_large") {
1241
+ respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
1242
+ }
1243
+ else {
1244
+ respondInvalidJson(res);
1245
+ }
867
1246
  return;
868
1247
  }
869
- const { targetName, force } = parsedBody.value ?? {};
1248
+ const promoteBody = parsedBody.value ?? {};
1249
+ if (respondIfUnknownBodyKeys(res, promoteBody, ["targetName", "force"]))
1250
+ return;
1251
+ const { targetName, force } = promoteBody;
870
1252
  if (typeof targetName !== "string" || !targetName.trim()) {
871
1253
  res.writeHead(400, { "Content-Type": "application/json" });
872
1254
  res.end(JSON.stringify({ ok: false, error: "targetName required" }));
@@ -881,6 +1263,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
881
1263
  const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
882
1264
  force: force === true,
883
1265
  });
1266
+ // Promotion overwrites the canonical file with the variant's
1267
+ // contents — same scheduler refresh story as save/edit.
1268
+ if (result.ok)
1269
+ fireOnRecipesChanged(deps);
884
1270
  const httpStatus = result.ok
885
1271
  ? 200
886
1272
  : result.targetExists
@@ -892,10 +1278,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
892
1278
  res.end(JSON.stringify(result));
893
1279
  }
894
1280
  catch (err) {
1281
+ console.error(`[recipes/install] internal error:`, err);
895
1282
  res.writeHead(500, { "Content-Type": "application/json" });
896
1283
  res.end(JSON.stringify({
897
1284
  ok: false,
898
- error: err instanceof Error ? err.message : String(err),
1285
+ error: "Internal server error",
899
1286
  }));
900
1287
  }
901
1288
  })();
@@ -908,10 +1295,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
908
1295
  res.end(JSON.stringify(data));
909
1296
  }
910
1297
  catch (err) {
911
- res.writeHead(500, { "Content-Type": "application/json" });
912
- res.end(JSON.stringify({
913
- error: err instanceof Error ? err.message : String(err),
914
- }));
1298
+ respond500(res, err);
915
1299
  }
916
1300
  return true;
917
1301
  }
@@ -919,22 +1303,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
919
1303
  void (async () => {
920
1304
  try {
921
1305
  const now = Date.now();
922
- if (!templatesCache || now - templatesCacheTs > 5 * 60 * 1000) {
1306
+ if (templatesCache === null || now - templatesCacheTs > 5 * 60 * 1000) {
923
1307
  const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
924
1308
  if (!ghRes.ok) {
925
1309
  throw new Error(`GitHub returned ${ghRes.status}`);
926
1310
  }
927
- templatesCache = (await ghRes.json());
1311
+ // #605: validate content-type AND shape before caching.
1312
+ // Previously we just `await ghRes.json()` and cached whatever
1313
+ // came back for 5 minutes — any error page (HTML/text JSON),
1314
+ // any MITM-tampered payload, or any future GitHub schema
1315
+ // change would poison the cache for every dashboard client.
1316
+ const ct = ghRes.headers.get("content-type") ?? "";
1317
+ if (!ct.includes("application/json") && !ct.includes("text/plain")) {
1318
+ throw new Error(`GitHub returned non-JSON content-type: ${ct}`);
1319
+ }
1320
+ const raw = (await ghRes.json());
1321
+ if (!isWellFormedTemplatesPayload(raw)) {
1322
+ throw new Error("GitHub payload failed shape validation");
1323
+ }
1324
+ templatesCache = raw;
928
1325
  templatesCacheTs = now;
929
1326
  }
1327
+ if (templatesCache === false) {
1328
+ res.writeHead(502, { "Content-Type": "application/json" });
1329
+ res.end(JSON.stringify({ ok: false, error: "Upstream fetch failed" }));
1330
+ return;
1331
+ }
930
1332
  res.writeHead(200, { "Content-Type": "application/json" });
931
1333
  res.end(JSON.stringify(templatesCache));
932
1334
  }
933
1335
  catch (err) {
1336
+ console.error(`[recipes/templates] upstream error:`, err);
1337
+ // #605: negative cache — short window so an upstream 502 doesn't
1338
+ // pile up requests; clients see a fast 502 instead of waiting
1339
+ // for the next GH round-trip.
1340
+ templatesCache = false; // negative-cache sentinel — prevents !templatesCache bypass
1341
+ templatesCacheTs = Date.now() - 5 * 60 * 1000 + 30_000; // 30s before next try
934
1342
  res.writeHead(502, { "Content-Type": "application/json" });
935
1343
  res.end(JSON.stringify({
936
1344
  ok: false,
937
- error: err instanceof Error ? err.message : String(err),
1345
+ error: "Upstream fetch failed",
938
1346
  }));
939
1347
  }
940
1348
  })();
@@ -953,6 +1361,23 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
953
1361
  // an explicitly-allowlisted hostname STILL has to clear the SSRF check
954
1362
  // (so an admin can't accidentally allowlist `localhost`).
955
1363
  // ---------------------------------------------------------------------
1364
+ //
1365
+ // Marketplace trust Wave 0 (#782 follow-up): gate the route on the
1366
+ // global write kill-switch. Install writes recipe files to disk;
1367
+ // when an operator engages `PATCHWORK_FLAG_KILL_SWITCH_WRITES` or
1368
+ // flips the `kill-switch.writes` flag, this previously kept writing
1369
+ // anyway — a latent trust gap. Returns 503 with a code the
1370
+ // dashboard can match against to render the right banner.
1371
+ if (isWriteKillSwitchActive()) {
1372
+ res.writeHead(503, { "Content-Type": "application/json" });
1373
+ res.end(JSON.stringify({
1374
+ ok: false,
1375
+ code: "kill_switch_blocked",
1376
+ error: "Recipe install blocked by kill switch (PATCHWORK_FLAG_KILL_SWITCH_WRITES / kill-switch.writes). " +
1377
+ "Unset the env var or POST /kill-switch with {engaged:false} to restore.",
1378
+ }));
1379
+ return true;
1380
+ }
956
1381
  void (async () => {
957
1382
  const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
958
1383
  if (!parsedBody.ok) {
@@ -974,58 +1399,59 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
974
1399
  // -----------------------------------------------------------------
975
1400
  // BUNDLE INSTALL DISPATCH (#130 PR A).
976
1401
  //
977
- // `github:patchworkos/recipes/bundles/<name>` installs every recipe
1402
+ // `github:<owner>/<repo>/bundles/<name>` installs every recipe
978
1403
  // listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
979
1404
  // and policy template (`policy_template`) declared in the manifest
980
1405
  // are surfaced as advisory-only — wiring those needs separate
981
1406
  // decisions (npm-install surface, policy application UX) tracked
982
- // outside this PR. See the #130 scoping comment.
1407
+ // outside this PR.
1408
+ //
1409
+ // Org allowlist (#audit-thread): historically the path was hard-
1410
+ // coded to `patchworkos/recipes`. Now any allowlisted org/repo
1411
+ // can host a bundle; parse + validate via the shared helper so
1412
+ // single-recipe and bundle install share one source-of-truth.
983
1413
  // -----------------------------------------------------------------
984
- const bundlePrefix = "github:patchworkos/recipes/bundles/";
985
- if (source.startsWith(bundlePrefix)) {
986
- const bundleName = source.slice(bundlePrefix.length);
987
- const { isSafeBasename } = await import("./commands/recipeInstall.js");
988
- if (!isSafeBasename(bundleName)) {
989
- res.writeHead(400, { "Content-Type": "application/json" });
990
- res.end(JSON.stringify({
991
- ok: false,
992
- error: "Invalid bundle name in source",
993
- code: "invalid_bundle_name",
994
- }));
995
- return;
996
- }
997
- const manifestUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/bundles/${bundleName}/patchwork-bundle.json`;
1414
+ const bundleParse = source.startsWith("github:")
1415
+ ? await (async () => {
1416
+ const { parseGithubInstallSource } = await import("./recipes/githubInstallSource.js");
1417
+ return parseGithubInstallSource(source);
1418
+ })()
1419
+ : null;
1420
+ if (bundleParse?.ok && bundleParse.parsed.kind === "bundle") {
1421
+ const { buildGithubRawUrl, fetchGithubInstallFile, SEGMENT_RE } = await import("./recipes/githubInstallSource.js");
1422
+ const bundleName = bundleParse.parsed.name;
1423
+ const bundleOwner = bundleParse.parsed.owner;
1424
+ const bundleRepo = bundleParse.parsed.repo;
1425
+ const manifestUrl = buildGithubRawUrl(bundleParse.parsed);
998
1426
  const ctl = new AbortController();
999
1427
  const timeout = setTimeout(() => ctl.abort(), 30_000);
1000
- let manifestRes;
1001
- try {
1002
- manifestRes = await fetch(manifestUrl, {
1003
- signal: ctl.signal,
1004
- redirect: "follow",
1005
- });
1006
- }
1007
- catch (err) {
1008
- clearTimeout(timeout);
1009
- res.writeHead(502, { "Content-Type": "application/json" });
1010
- res.end(JSON.stringify({
1011
- ok: false,
1012
- error: `Bundle manifest fetch failed: ${err instanceof Error ? err.message : String(err)}`,
1013
- code: "bundle_fetch_network_error",
1014
- }));
1015
- return;
1016
- }
1428
+ // Raw-first / api.github.com-fallback: raw.githubusercontent.com
1429
+ // is blocked on many corporate / proxied networks.
1430
+ const manifestFetched = await fetchGithubInstallFile(bundleParse.parsed, { signal: ctl.signal });
1017
1431
  clearTimeout(timeout);
1018
- if (!manifestRes.ok) {
1019
- const outStatus = manifestRes.status === 404 ? 404 : 502;
1432
+ if (!manifestFetched.ok) {
1433
+ if (manifestFetched.kind === "network_error") {
1434
+ console.error(`[recipes/install] bundle manifest fetch failed (raw + api):`, manifestFetched.error);
1435
+ res.writeHead(502, { "Content-Type": "application/json" });
1436
+ res.end(JSON.stringify({
1437
+ ok: false,
1438
+ error: "Bundle manifest fetch failed",
1439
+ code: "bundle_fetch_network_error",
1440
+ }));
1441
+ return;
1442
+ }
1443
+ const failRes = manifestFetched.response;
1444
+ const outStatus = failRes.status === 404 ? 404 : 502;
1020
1445
  res.writeHead(outStatus, { "Content-Type": "application/json" });
1021
1446
  res.end(JSON.stringify({
1022
1447
  ok: false,
1023
- error: `Bundle manifest at ${manifestUrl} returned ${manifestRes.status}`,
1448
+ error: `Bundle manifest at ${manifestUrl} returned ${failRes.status}`,
1024
1449
  code: "bundle_fetch_upstream_error",
1025
- upstreamStatus: manifestRes.status,
1450
+ upstreamStatus: failRes.status,
1026
1451
  }));
1027
1452
  return;
1028
1453
  }
1454
+ const manifestRes = manifestFetched.response;
1029
1455
  // 64 KB hard cap on manifest body — real `patchwork-bundle.json`
1030
1456
  // is single-digit KB; anything past 64 KB is hostile or malformed.
1031
1457
  const manifestBuf = await manifestRes.arrayBuffer();
@@ -1043,14 +1469,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1043
1469
  manifest = JSON.parse(Buffer.from(manifestBuf).toString("utf-8"));
1044
1470
  }
1045
1471
  catch (err) {
1472
+ console.error(`[recipes/install] bundle manifest invalid JSON:`, err);
1046
1473
  res.writeHead(502, { "Content-Type": "application/json" });
1047
1474
  res.end(JSON.stringify({
1048
1475
  ok: false,
1049
- error: `Bundle manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
1476
+ error: "Bundle manifest is not valid JSON",
1050
1477
  code: "bundle_manifest_invalid_json",
1051
1478
  }));
1052
1479
  return;
1053
1480
  }
1481
+ // Validate each declared recipe basename to block traversal +
1482
+ // junk segments. `isSafeBasename` lives in the legacy recipe-
1483
+ // install command but the predicate is the right shape here.
1484
+ const { isSafeBasename } = await import("./commands/recipeInstall.js");
1054
1485
  if (!Array.isArray(manifest.recipes) ||
1055
1486
  manifest.recipes.length === 0 ||
1056
1487
  !manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
@@ -1067,27 +1498,57 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1067
1498
  // all-or-nothing when one of N recipes is broken.
1068
1499
  const installed = [];
1069
1500
  const failures = [];
1070
- const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
1501
+ // Wave 1 fix: aggregate connector preflight across every
1502
+ // bundle-installed recipe so the dashboard can surface a
1503
+ // single "you need to connect Gmail + Slack + Calendar before
1504
+ // running these" notice, matching the single-recipe install's
1505
+ // missingConnectors behaviour. Previously bundle installs
1506
+ // returned no preflight hint → users hit first-run "no Slack
1507
+ // token" surprise on every recipe.
1508
+ const aggregatedMissingConnectors = new Set();
1509
+ const { writeFileSync, mkdirSync, unlinkSync, readFileSync } = await import("node:fs");
1071
1510
  const { installRecipeFromFile } = await import("./recipes/installer.js");
1072
1511
  const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1073
1512
  mkdirSync(recipesDir, { recursive: true });
1074
1513
  for (const r of manifest.recipes) {
1075
- const recipeUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${r}/${r}.yaml`;
1514
+ // The recipe name comes from the bundle manifest's JSON body
1515
+ // (GitHub-hosted, but still external input). Validate it
1516
+ // before it reaches URL construction in fetchGithubInstallFile
1517
+ // — the `github:` source path gets this check via
1518
+ // parseGithubInstallSource, but the bundle loop builds the
1519
+ // struct directly and would otherwise bypass it.
1520
+ if (typeof r !== "string" || !SEGMENT_RE.test(r.toLowerCase())) {
1521
+ failures.push({
1522
+ name: String(r),
1523
+ error: "invalid recipe name in bundle manifest",
1524
+ });
1525
+ continue;
1526
+ }
1527
+ // Bundle's manifest is allowed to declare recipes that
1528
+ // live in the same repo as the bundle. Fetch with the
1529
+ // parsed owner/repo via the raw-first / api.github.com
1530
+ // fallback (raw host blocked on many proxied networks).
1076
1531
  const recipeCtl = new AbortController();
1077
1532
  const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
1078
1533
  try {
1079
- const recipeRes = await fetch(recipeUrl, {
1080
- signal: recipeCtl.signal,
1081
- redirect: "follow",
1082
- });
1534
+ const recipeFetched = await fetchGithubInstallFile({
1535
+ kind: "recipe",
1536
+ owner: bundleOwner,
1537
+ repo: bundleRepo,
1538
+ name: r,
1539
+ }, { signal: recipeCtl.signal });
1083
1540
  clearTimeout(recipeTimeout);
1084
- if (!recipeRes.ok) {
1541
+ if (!recipeFetched.ok) {
1542
+ if (recipeFetched.kind === "network_error") {
1543
+ throw recipeFetched.error;
1544
+ }
1085
1545
  failures.push({
1086
1546
  name: r,
1087
- error: `Upstream returned ${recipeRes.status}`,
1547
+ error: `Upstream returned ${recipeFetched.response.status}`,
1088
1548
  });
1089
1549
  continue;
1090
1550
  }
1551
+ const recipeRes = recipeFetched.response;
1091
1552
  const recipeBuf = await recipeRes.arrayBuffer();
1092
1553
  if (recipeBuf.byteLength > 1024 * 1024) {
1093
1554
  failures.push({
@@ -1097,13 +1558,47 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1097
1558
  continue;
1098
1559
  }
1099
1560
  const yamlText = Buffer.from(recipeBuf).toString("utf-8");
1100
- const tmpFile = path.join(os.tmpdir(), `patchwork-bundle-install-${Date.now()}-${r}.yaml`);
1561
+ // #605: same race-fix as /recipes/install — embed pid +
1562
+ // randomBytes so concurrent bundle installs of the same
1563
+ // recipe inside one millisecond don't collide.
1564
+ const { randomBytes: randomBytesFn } = await import("node:crypto");
1565
+ const tmpFile = path.join(os.tmpdir(), `patchwork-bundle-install-${process.pid}-${Date.now()}-${randomBytesFn(6).toString("hex")}-${r}.yaml`);
1101
1566
  writeFileSync(tmpFile, yamlText, "utf-8");
1102
1567
  try {
1103
1568
  const installResult = installRecipeFromFile(tmpFile, {
1104
1569
  recipesDir,
1105
1570
  });
1106
1571
  installed.push({ name: r, action: installResult.action });
1572
+ // Per-recipe connector preflight — same shape as the
1573
+ // single-recipe path (lines ~2005+). Defensive: any
1574
+ // failure must not roll back the install.
1575
+ try {
1576
+ const installedJson = readFileSync(installResult.installedPath, "utf-8");
1577
+ const recipeForPreflight = JSON.parse(installedJson);
1578
+ if (Array.isArray(recipeForPreflight.steps)) {
1579
+ const { detectRequiredConnectors, findMissingConnectors } = await import("./recipes/connectorPreflight.js");
1580
+ const required = detectRequiredConnectors(recipeForPreflight);
1581
+ if (required.length > 0) {
1582
+ const { handleConnectionsList } = await import("./connectors/gmail.js");
1583
+ const connsResult = await handleConnectionsList();
1584
+ let connections = [];
1585
+ try {
1586
+ const body = JSON.parse(connsResult.body);
1587
+ connections = body.connectors ?? [];
1588
+ }
1589
+ catch {
1590
+ /* malformed body — treat as no connections */
1591
+ }
1592
+ const missing = findMissingConnectors(required, connections);
1593
+ for (const id of missing) {
1594
+ aggregatedMissingConnectors.add(id);
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1599
+ catch (preflightErr) {
1600
+ console.warn(`[recipes/install] bundle preflight for "${r}" failed (non-blocking):`, preflightErr);
1601
+ }
1107
1602
  }
1108
1603
  finally {
1109
1604
  try {
@@ -1116,9 +1611,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1116
1611
  }
1117
1612
  catch (err) {
1118
1613
  clearTimeout(recipeTimeout);
1614
+ console.error(`[recipes/install] recipe "${r}" failed:`, err);
1119
1615
  failures.push({
1120
1616
  name: r,
1121
- error: err instanceof Error ? err.message : String(err),
1617
+ error: "Recipe install failed",
1122
1618
  });
1123
1619
  }
1124
1620
  }
@@ -1133,6 +1629,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1133
1629
  // 200 if any recipe installed; 502 otherwise. Always include both
1134
1630
  // arrays so callers (CLI + dashboard) can render partial-success.
1135
1631
  const status = installed.length > 0 ? 200 : 502;
1632
+ // Notify the scheduler so cron-trigger recipes in the bundle
1633
+ // start firing without a bridge restart. Fired once per bundle
1634
+ // (not per recipe inside) since scheduler.start() reads the
1635
+ // whole recipes dir anyway. Guarded by the partial-success
1636
+ // check — no point waking up the scheduler for a 0-installed
1637
+ // failure.
1638
+ if (installed.length > 0)
1639
+ fireOnRecipesChanged(deps);
1136
1640
  res.writeHead(status, { "Content-Type": "application/json" });
1137
1641
  res.end(JSON.stringify({
1138
1642
  ok: installed.length > 0,
@@ -1140,28 +1644,54 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1140
1644
  bundleName,
1141
1645
  installed,
1142
1646
  failures,
1647
+ ...(aggregatedMissingConnectors.size > 0 && {
1648
+ missingConnectors: Array.from(aggregatedMissingConnectors),
1649
+ }),
1143
1650
  ...(Object.keys(advisory).length > 0 && { advisory }),
1144
1651
  }));
1145
1652
  return;
1146
1653
  }
1147
- const githubPrefix = "github:patchworkos/recipes/recipes/";
1148
1654
  let fetchUrl;
1149
1655
  let recipeName;
1150
- if (source.startsWith(githubPrefix)) {
1151
- recipeName = source.slice(githubPrefix.length);
1152
- // The constructed URL is internal recipeName must be a safe
1153
- // single-segment so we don't end up encoding `../etc/passwd` into
1154
- // the path. Reuse the strict basename predicate from `recipeInstall`.
1155
- const { isSafeBasename } = await import("./commands/recipeInstall.js");
1156
- if (!isSafeBasename(recipeName)) {
1656
+ // When the source is a `github:` shorthand we keep the parsed
1657
+ // form around so the fetch leg below can use the raw-first /
1658
+ // api.github.com-fallback strategy (raw is blocked on many
1659
+ // corporate / proxied networks). Non-github `https://` sources
1660
+ // fetch the URL directly, unchanged.
1661
+ let githubParsed = null;
1662
+ if (source.startsWith("github:")) {
1663
+ // Parse the new generalised shape (any allowlisted org/repo)
1664
+ // instead of only `github:patchworkos/recipes/recipes/<name>`.
1665
+ // Distinguishes bad shape (400) from not-on-allowlist (403)
1666
+ // so operators can spot a config error vs. a typo.
1667
+ const { parseGithubInstallSource, buildGithubRawUrl } = await import("./recipes/githubInstallSource.js");
1668
+ const parsed = parseGithubInstallSource(source);
1669
+ if (!parsed.ok) {
1670
+ const status = parsed.code === "not_allowlisted" ? 403 : 400;
1671
+ res.writeHead(status, { "Content-Type": "application/json" });
1672
+ res.end(JSON.stringify({
1673
+ ok: false,
1674
+ error: parsed.error,
1675
+ code: parsed.code,
1676
+ }));
1677
+ return;
1678
+ }
1679
+ // Bundle shape on /recipes/install (single-recipe path) is a
1680
+ // mistake — surface it explicitly rather than silently fetching
1681
+ // an unrelated URL. Bundle installs have their own code path
1682
+ // above this block.
1683
+ if (parsed.parsed.kind === "bundle") {
1157
1684
  res.writeHead(400, { "Content-Type": "application/json" });
1158
1685
  res.end(JSON.stringify({
1159
1686
  ok: false,
1160
- error: "Invalid recipe name in source",
1687
+ error: "Bundle source on single-recipe install. Use the bundle install path.",
1688
+ code: "bad_shape",
1161
1689
  }));
1162
1690
  return;
1163
1691
  }
1164
- fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
1692
+ recipeName = parsed.parsed.name;
1693
+ githubParsed = parsed.parsed;
1694
+ fetchUrl = buildGithubRawUrl(parsed.parsed);
1165
1695
  }
1166
1696
  else if (source.startsWith("https://")) {
1167
1697
  // Non-github source: must clear the env-var allowlist AND the SSRF
@@ -1231,42 +1761,88 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1231
1761
  const fetchCtl = new AbortController();
1232
1762
  const fetchTimeout = setTimeout(() => fetchCtl.abort(), 30_000);
1233
1763
  let yamlRes;
1234
- try {
1235
- yamlRes = await fetch(fetchUrl, {
1764
+ if (githubParsed) {
1765
+ // github: source — try raw.githubusercontent.com first, then
1766
+ // fall back to api.github.com/contents (raw host is blocked on
1767
+ // many corporate / proxied networks). The fallback collapses
1768
+ // back into the SAME error contract as the direct path below.
1769
+ const { fetchGithubInstallFile } = await import("./recipes/githubInstallSource.js");
1770
+ const fetched = await fetchGithubInstallFile(githubParsed, {
1236
1771
  signal: fetchCtl.signal,
1237
- redirect: "follow",
1238
1772
  });
1239
- }
1240
- catch (err) {
1241
1773
  clearTimeout(fetchTimeout);
1242
- // Network-level error → 502 (upstream unreachable), not 500.
1243
- res.writeHead(502, { "Content-Type": "application/json" });
1244
- res.end(JSON.stringify({
1245
- ok: false,
1246
- error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
1247
- code: "fetch_network_error",
1248
- }));
1249
- return;
1774
+ if (!fetched.ok) {
1775
+ if (fetched.kind === "network_error") {
1776
+ console.error(`[recipes/install] fetch failed (raw + api):`, fetched.error);
1777
+ // Network-level error → 502 (upstream unreachable), not 500.
1778
+ res.writeHead(502, { "Content-Type": "application/json" });
1779
+ res.end(JSON.stringify({
1780
+ ok: false,
1781
+ error: "Fetch failed",
1782
+ code: "fetch_network_error",
1783
+ }));
1784
+ return;
1785
+ }
1786
+ // not_found / upstream_error — translate the HTTP status the
1787
+ // same way the direct path does.
1788
+ const failRes = fetched.response;
1789
+ let outStatus = 502;
1790
+ if (failRes.status === 404)
1791
+ outStatus = 404;
1792
+ else if (failRes.status === 403)
1793
+ outStatus = 403;
1794
+ else if (failRes.status >= 400 && failRes.status < 500)
1795
+ outStatus = 400;
1796
+ res.writeHead(outStatus, { "Content-Type": "application/json" });
1797
+ res.end(JSON.stringify({
1798
+ ok: false,
1799
+ error: `Upstream returned ${failRes.status} ${failRes.statusText}`,
1800
+ code: "fetch_upstream_error",
1801
+ upstreamStatus: failRes.status,
1802
+ }));
1803
+ return;
1804
+ }
1805
+ yamlRes = fetched.response;
1250
1806
  }
1251
- clearTimeout(fetchTimeout);
1252
- if (!yamlRes.ok) {
1253
- // Translate upstream HTTP into proper status — 404→404, 403→403,
1254
- // 5xx→502 (don't leak the upstream 500 as our 500). R2 H-routes Bug 2.
1255
- let outStatus = 502;
1256
- if (yamlRes.status === 404)
1257
- outStatus = 404;
1258
- else if (yamlRes.status === 403)
1259
- outStatus = 403;
1260
- else if (yamlRes.status >= 400 && yamlRes.status < 500)
1261
- outStatus = 400;
1262
- res.writeHead(outStatus, { "Content-Type": "application/json" });
1263
- res.end(JSON.stringify({
1264
- ok: false,
1265
- error: `Upstream returned ${yamlRes.status} ${yamlRes.statusText}`,
1266
- code: "fetch_upstream_error",
1267
- upstreamStatus: yamlRes.status,
1268
- }));
1269
- return;
1807
+ else {
1808
+ try {
1809
+ yamlRes = await fetch(fetchUrl, {
1810
+ signal: fetchCtl.signal,
1811
+ redirect: "follow",
1812
+ });
1813
+ }
1814
+ catch (err) {
1815
+ clearTimeout(fetchTimeout);
1816
+ console.error(`[recipes/install] fetch failed:`, err);
1817
+ // Network-level error → 502 (upstream unreachable), not 500.
1818
+ res.writeHead(502, { "Content-Type": "application/json" });
1819
+ res.end(JSON.stringify({
1820
+ ok: false,
1821
+ error: "Fetch failed",
1822
+ code: "fetch_network_error",
1823
+ }));
1824
+ return;
1825
+ }
1826
+ clearTimeout(fetchTimeout);
1827
+ if (!yamlRes.ok) {
1828
+ // Translate upstream HTTP into proper status — 404→404, 403→403,
1829
+ // 5xx→502 (don't leak the upstream 500 as our 500). R2 H-routes Bug 2.
1830
+ let outStatus = 502;
1831
+ if (yamlRes.status === 404)
1832
+ outStatus = 404;
1833
+ else if (yamlRes.status === 403)
1834
+ outStatus = 403;
1835
+ else if (yamlRes.status >= 400 && yamlRes.status < 500)
1836
+ outStatus = 400;
1837
+ res.writeHead(outStatus, { "Content-Type": "application/json" });
1838
+ res.end(JSON.stringify({
1839
+ ok: false,
1840
+ error: `Upstream returned ${yamlRes.status} ${yamlRes.statusText}`,
1841
+ code: "fetch_upstream_error",
1842
+ upstreamStatus: yamlRes.status,
1843
+ }));
1844
+ return;
1845
+ }
1270
1846
  }
1271
1847
  // Streamed read with 1 MB cap (mirrors `httpClient` pattern).
1272
1848
  const MAX_RECIPE_BYTES = 1024 * 1024;
@@ -1303,7 +1879,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1303
1879
  return;
1304
1880
  }
1305
1881
  const yamlText = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
1306
- const tmpFile = path.join(os.tmpdir(), `patchwork-install-${Date.now()}-${recipeName}.yaml`);
1882
+ // #605: temp path must be unique per process+request. Previous
1883
+ // form was `Date.now()-${recipeName}` — two concurrent installs
1884
+ // of the same recipe inside one millisecond produced identical
1885
+ // paths → writeFileSync interleaved bytes, and the first finally
1886
+ // block unlinked the file the second still needed.
1887
+ const { randomBytes } = await import("node:crypto");
1888
+ const tmpFile = path.join(os.tmpdir(), `patchwork-install-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}-${recipeName}.yaml`);
1307
1889
  const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
1308
1890
  writeFileSync(tmpFile, yamlText, "utf-8");
1309
1891
  let result;
@@ -1314,7 +1896,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1314
1896
  const installResult = installRecipeFromFile(tmpFile, {
1315
1897
  recipesDir,
1316
1898
  });
1317
- result = { action: installResult.action, name: recipeName };
1899
+ // Soft preflight: detect which connectors the recipe uses
1900
+ // and surface the unconfigured ones as a warning. The recipe
1901
+ // is already on disk — this is a hint for the dashboard to
1902
+ // prompt "you'll need to connect Slack + Gmail to run this",
1903
+ // not a gate on the install itself. Defensive: any failure
1904
+ // here MUST NOT roll the install back, so the whole block is
1905
+ // wrapped in try/catch.
1906
+ let missingConnectors;
1907
+ try {
1908
+ const { readFileSync } = await import("node:fs");
1909
+ const installedJson = readFileSync(installResult.installedPath, "utf-8");
1910
+ const recipe = JSON.parse(installedJson);
1911
+ if (Array.isArray(recipe.steps)) {
1912
+ const { detectRequiredConnectors, findMissingConnectors } = await import("./recipes/connectorPreflight.js");
1913
+ const required = detectRequiredConnectors(recipe);
1914
+ if (required.length > 0) {
1915
+ const { handleConnectionsList } = await import("./connectors/gmail.js");
1916
+ const connsResult = await handleConnectionsList();
1917
+ let connections = [];
1918
+ try {
1919
+ const body = JSON.parse(connsResult.body);
1920
+ connections = body.connectors ?? [];
1921
+ }
1922
+ catch {
1923
+ /* malformed body — treat as no connections */
1924
+ }
1925
+ const missing = findMissingConnectors(required, connections);
1926
+ if (missing.length > 0)
1927
+ missingConnectors = missing;
1928
+ }
1929
+ }
1930
+ }
1931
+ catch (preflightErr) {
1932
+ console.warn(`[recipes/install] connector preflight failed (non-blocking):`, preflightErr);
1933
+ }
1934
+ result = {
1935
+ action: installResult.action,
1936
+ name: recipeName,
1937
+ ...(missingConnectors ? { missingConnectors } : {}),
1938
+ };
1318
1939
  }
1319
1940
  finally {
1320
1941
  try {
@@ -1324,15 +1945,47 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1324
1945
  // best-effort cleanup
1325
1946
  }
1326
1947
  }
1948
+ // Notify the scheduler so the new recipe's cron/webhook trigger
1949
+ // starts firing without a bridge restart. The recipe file is
1950
+ // already on disk (`writeFileSync` above), so the next
1951
+ // `scheduler.start()` will pick it up via its directory scan.
1952
+ // Errors here are logged but never surface to the caller — the
1953
+ // install itself succeeded; a scheduler restart bug must not
1954
+ // make the response look failed.
1955
+ fireOnRecipesChanged(deps);
1327
1956
  res.writeHead(200, { "Content-Type": "application/json" });
1328
1957
  res.end(JSON.stringify({ ok: true, ...result }));
1329
1958
  }
1330
1959
  catch (err) {
1331
- // Truly unexpected installer crash, manifest validation throw, etc.
1960
+ // Distinguish "the recipe YAML is malformed" (user-actionable, 400)
1961
+ // from "the installer itself crashed" (server bug, 500). Before this
1962
+ // every parser error came back as the same opaque 500 — dashboards
1963
+ // surfaced "Internal server error" with no way to know what was wrong.
1964
+ const errName = err instanceof Error ? err.name : "";
1965
+ const errMsg = err instanceof Error ? err.message : String(err);
1966
+ const isParseError = errName === "RecipeParseError" ||
1967
+ // js-yaml / the `yaml` package both throw YAMLException / YAMLParseError.
1968
+ errName === "YAMLException" ||
1969
+ errName === "YAMLParseError" ||
1970
+ /yaml/i.test(errName);
1971
+ if (isParseError) {
1972
+ // Return only the first line of the parser message — strips any
1973
+ // embedded file path or stack frame that downstream parsers
1974
+ // sometimes include (CodeQL: js/stack-trace-exposure).
1975
+ const safeMsg = errMsg.split("\n", 1)[0]?.slice(0, 500) ?? "invalid recipe";
1976
+ res.writeHead(400, { "Content-Type": "application/json" });
1977
+ res.end(JSON.stringify({
1978
+ ok: false,
1979
+ error: safeMsg,
1980
+ code: "invalid_recipe",
1981
+ }));
1982
+ return;
1983
+ }
1984
+ console.error(`[recipes/install] internal install error:`, err);
1332
1985
  res.writeHead(500, { "Content-Type": "application/json" });
1333
1986
  res.end(JSON.stringify({
1334
1987
  ok: false,
1335
- error: err instanceof Error ? err.message : String(err),
1988
+ error: "Internal server error",
1336
1989
  code: "install_internal_error",
1337
1990
  }));
1338
1991
  }