patchwork-os 0.2.0-beta.1 → 0.2.0-beta.10.canary.95

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 (652) hide show
  1. package/README.bridge.md +14 -14
  2. package/README.md +201 -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.js +1 -1
  24. package/dist/analyticsAggregator.js.map +1 -1
  25. package/dist/analyticsConfig.d.ts +29 -0
  26. package/dist/analyticsConfig.js +102 -0
  27. package/dist/analyticsConfig.js.map +1 -0
  28. package/dist/analyticsPrefs.d.ts +38 -2
  29. package/dist/analyticsPrefs.js +148 -23
  30. package/dist/analyticsPrefs.js.map +1 -1
  31. package/dist/analyticsSend.d.ts +17 -1
  32. package/dist/analyticsSend.js +67 -5
  33. package/dist/analyticsSend.js.map +1 -1
  34. package/dist/approvalHttp.d.ts +14 -0
  35. package/dist/approvalHttp.js +212 -9
  36. package/dist/approvalHttp.js.map +1 -1
  37. package/dist/approvalInsights.js +13 -5
  38. package/dist/approvalInsights.js.map +1 -1
  39. package/dist/approvalQueue.d.ts +97 -3
  40. package/dist/approvalQueue.js +193 -19
  41. package/dist/approvalQueue.js.map +1 -1
  42. package/dist/approvalSignals.js +19 -11
  43. package/dist/approvalSignals.js.map +1 -1
  44. package/dist/automation.d.ts +35 -10
  45. package/dist/automation.js +133 -37
  46. package/dist/automation.js.map +1 -1
  47. package/dist/automationSuggestions.js +10 -8
  48. package/dist/automationSuggestions.js.map +1 -1
  49. package/dist/bridge.d.ts +2 -0
  50. package/dist/bridge.js +202 -25
  51. package/dist/bridge.js.map +1 -1
  52. package/dist/bridgeLockDiscovery.d.ts +27 -1
  53. package/dist/bridgeLockDiscovery.js +38 -11
  54. package/dist/bridgeLockDiscovery.js.map +1 -1
  55. package/dist/bridgeToken.js +3 -2
  56. package/dist/bridgeToken.js.map +1 -1
  57. package/dist/claudeDriver.js +23 -8
  58. package/dist/claudeDriver.js.map +1 -1
  59. package/dist/claudeOrchestrator.d.ts +1 -1
  60. package/dist/claudeOrchestrator.js +87 -44
  61. package/dist/claudeOrchestrator.js.map +1 -1
  62. package/dist/commands/analytics.d.ts +8 -0
  63. package/dist/commands/analytics.js +134 -0
  64. package/dist/commands/analytics.js.map +1 -0
  65. package/dist/commands/auditEnv.d.ts +36 -0
  66. package/dist/commands/auditEnv.js +202 -0
  67. package/dist/commands/auditEnv.js.map +1 -0
  68. package/dist/commands/dashboard.js +8 -1
  69. package/dist/commands/dashboard.js.map +1 -1
  70. package/dist/commands/doctor.d.ts +35 -0
  71. package/dist/commands/doctor.js +51 -0
  72. package/dist/commands/doctor.js.map +1 -0
  73. package/dist/commands/install.js +3 -0
  74. package/dist/commands/install.js.map +1 -1
  75. package/dist/commands/launchd.js +15 -16
  76. package/dist/commands/launchd.js.map +1 -1
  77. package/dist/commands/patchworkInit.d.ts +5 -0
  78. package/dist/commands/patchworkInit.js +89 -7
  79. package/dist/commands/patchworkInit.js.map +1 -1
  80. package/dist/commands/recipe.d.ts +110 -0
  81. package/dist/commands/recipe.js +512 -9
  82. package/dist/commands/recipe.js.map +1 -1
  83. package/dist/commands/recipeInstall.js +11 -4
  84. package/dist/commands/recipeInstall.js.map +1 -1
  85. package/dist/commands/shadowScan.d.ts +34 -0
  86. package/dist/commands/shadowScan.js +142 -0
  87. package/dist/commands/shadowScan.js.map +1 -0
  88. package/dist/commands/task.js +2 -2
  89. package/dist/commands/task.js.map +1 -1
  90. package/dist/commands/tools.d.ts +20 -1
  91. package/dist/commands/tools.js +112 -3
  92. package/dist/commands/tools.js.map +1 -1
  93. package/dist/commands/tracesImport.js +21 -4
  94. package/dist/commands/tracesImport.js.map +1 -1
  95. package/dist/commitIssueLinkLog.d.ts +16 -0
  96. package/dist/commitIssueLinkLog.js +110 -24
  97. package/dist/commitIssueLinkLog.js.map +1 -1
  98. package/dist/companions/registry.js +15 -7
  99. package/dist/companions/registry.js.map +1 -1
  100. package/dist/config.d.ts +47 -3
  101. package/dist/config.js +111 -21
  102. package/dist/config.js.map +1 -1
  103. package/dist/connectorRoutes.js +866 -57
  104. package/dist/connectorRoutes.js.map +1 -1
  105. package/dist/connectors/airtable.d.ts +230 -0
  106. package/dist/connectors/airtable.js +700 -0
  107. package/dist/connectors/airtable.js.map +1 -0
  108. package/dist/connectors/asana.js +13 -9
  109. package/dist/connectors/asana.js.map +1 -1
  110. package/dist/connectors/baseConnector.js +25 -3
  111. package/dist/connectors/baseConnector.js.map +1 -1
  112. package/dist/connectors/caldiy.d.ts +137 -0
  113. package/dist/connectors/caldiy.js +424 -0
  114. package/dist/connectors/caldiy.js.map +1 -0
  115. package/dist/connectors/circleci.d.ts +162 -0
  116. package/dist/connectors/circleci.js +576 -0
  117. package/dist/connectors/circleci.js.map +1 -0
  118. package/dist/connectors/cloudflare.d.ts +132 -0
  119. package/dist/connectors/cloudflare.js +622 -0
  120. package/dist/connectors/cloudflare.js.map +1 -0
  121. package/dist/connectors/confluence.js +35 -0
  122. package/dist/connectors/confluence.js.map +1 -1
  123. package/dist/connectors/connectorRedirectUri.d.ts +30 -0
  124. package/dist/connectors/connectorRedirectUri.js +38 -0
  125. package/dist/connectors/connectorRedirectUri.js.map +1 -0
  126. package/dist/connectors/connectorRegistry.d.ts +63 -0
  127. package/dist/connectors/connectorRegistry.js +354 -0
  128. package/dist/connectors/connectorRegistry.js.map +1 -0
  129. package/dist/connectors/datadog.js +33 -4
  130. package/dist/connectors/datadog.js.map +1 -1
  131. package/dist/connectors/discord.js +14 -10
  132. package/dist/connectors/discord.js.map +1 -1
  133. package/dist/connectors/elasticsearch.d.ts +141 -0
  134. package/dist/connectors/elasticsearch.js +601 -0
  135. package/dist/connectors/elasticsearch.js.map +1 -0
  136. package/dist/connectors/figma.d.ts +130 -0
  137. package/dist/connectors/figma.js +387 -0
  138. package/dist/connectors/figma.js.map +1 -0
  139. package/dist/connectors/github.d.ts +17 -0
  140. package/dist/connectors/github.js +53 -2
  141. package/dist/connectors/github.js.map +1 -1
  142. package/dist/connectors/gitlab.js +12 -6
  143. package/dist/connectors/gitlab.js.map +1 -1
  144. package/dist/connectors/gmail.js +72 -21
  145. package/dist/connectors/gmail.js.map +1 -1
  146. package/dist/connectors/googleCalendar.js +8 -8
  147. package/dist/connectors/googleCalendar.js.map +1 -1
  148. package/dist/connectors/googleDocs.d.ts +103 -0
  149. package/dist/connectors/googleDocs.js +409 -0
  150. package/dist/connectors/googleDocs.js.map +1 -0
  151. package/dist/connectors/googleDrive.d.ts +12 -0
  152. package/dist/connectors/googleDrive.js +35 -8
  153. package/dist/connectors/googleDrive.js.map +1 -1
  154. package/dist/connectors/grafana.d.ts +133 -0
  155. package/dist/connectors/grafana.js +478 -0
  156. package/dist/connectors/grafana.js.map +1 -0
  157. package/dist/connectors/jira.d.ts +25 -0
  158. package/dist/connectors/jira.js +227 -1
  159. package/dist/connectors/jira.js.map +1 -1
  160. package/dist/connectors/linear.js +1 -1
  161. package/dist/connectors/linear.js.map +1 -1
  162. package/dist/connectors/mcpOAuth.js +78 -16
  163. package/dist/connectors/mcpOAuth.js.map +1 -1
  164. package/dist/connectors/monday.d.ts +217 -0
  165. package/dist/connectors/monday.js +655 -0
  166. package/dist/connectors/monday.js.map +1 -0
  167. package/dist/connectors/mongodb.d.ts +139 -0
  168. package/dist/connectors/mongodb.js +455 -0
  169. package/dist/connectors/mongodb.js.map +1 -0
  170. package/dist/connectors/oauthStateStore.d.ts +20 -0
  171. package/dist/connectors/oauthStateStore.js +94 -4
  172. package/dist/connectors/oauthStateStore.js.map +1 -1
  173. package/dist/connectors/obsidian.d.ts +83 -0
  174. package/dist/connectors/obsidian.js +441 -0
  175. package/dist/connectors/obsidian.js.map +1 -0
  176. package/dist/connectors/paystack.d.ts +159 -0
  177. package/dist/connectors/paystack.js +607 -0
  178. package/dist/connectors/paystack.js.map +1 -0
  179. package/dist/connectors/pipedrive.d.ts +189 -0
  180. package/dist/connectors/pipedrive.js +559 -0
  181. package/dist/connectors/pipedrive.js.map +1 -0
  182. package/dist/connectors/postgres.d.ts +127 -0
  183. package/dist/connectors/postgres.js +512 -0
  184. package/dist/connectors/postgres.js.map +1 -0
  185. package/dist/connectors/posthog.d.ts +119 -0
  186. package/dist/connectors/posthog.js +479 -0
  187. package/dist/connectors/posthog.js.map +1 -0
  188. package/dist/connectors/redis.d.ts +140 -0
  189. package/dist/connectors/redis.js +571 -0
  190. package/dist/connectors/redis.js.map +1 -0
  191. package/dist/connectors/resend.d.ts +131 -0
  192. package/dist/connectors/resend.js +529 -0
  193. package/dist/connectors/resend.js.map +1 -0
  194. package/dist/connectors/salesforce.d.ts +136 -0
  195. package/dist/connectors/salesforce.js +603 -0
  196. package/dist/connectors/salesforce.js.map +1 -0
  197. package/dist/connectors/secrets.d.ts +51 -0
  198. package/dist/connectors/secrets.js +102 -0
  199. package/dist/connectors/secrets.js.map +1 -0
  200. package/dist/connectors/sendgrid.d.ts +102 -0
  201. package/dist/connectors/sendgrid.js +423 -0
  202. package/dist/connectors/sendgrid.js.map +1 -0
  203. package/dist/connectors/shopify.d.ts +145 -0
  204. package/dist/connectors/shopify.js +502 -0
  205. package/dist/connectors/shopify.js.map +1 -0
  206. package/dist/connectors/slack.d.ts +1 -1
  207. package/dist/connectors/slack.js +61 -9
  208. package/dist/connectors/slack.js.map +1 -1
  209. package/dist/connectors/snowflake.d.ts +119 -0
  210. package/dist/connectors/snowflake.js +615 -0
  211. package/dist/connectors/snowflake.js.map +1 -0
  212. package/dist/connectors/supabase.d.ts +92 -0
  213. package/dist/connectors/supabase.js +630 -0
  214. package/dist/connectors/supabase.js.map +1 -0
  215. package/dist/connectors/todoist.d.ts +117 -0
  216. package/dist/connectors/todoist.js +485 -0
  217. package/dist/connectors/todoist.js.map +1 -0
  218. package/dist/connectors/tokenStorage.js +56 -14
  219. package/dist/connectors/tokenStorage.js.map +1 -1
  220. package/dist/connectors/twilio.d.ts +118 -0
  221. package/dist/connectors/twilio.js +475 -0
  222. package/dist/connectors/twilio.js.map +1 -0
  223. package/dist/connectors/vercel.d.ts +110 -0
  224. package/dist/connectors/vercel.js +479 -0
  225. package/dist/connectors/vercel.js.map +1 -0
  226. package/dist/connectors/webflow.d.ts +118 -0
  227. package/dist/connectors/webflow.js +393 -0
  228. package/dist/connectors/webflow.js.map +1 -0
  229. package/dist/connectors/woocommerce.d.ts +220 -0
  230. package/dist/connectors/woocommerce.js +464 -0
  231. package/dist/connectors/woocommerce.js.map +1 -0
  232. package/dist/crypto.js +7 -2
  233. package/dist/crypto.js.map +1 -1
  234. package/dist/decisionReplay.js +15 -6
  235. package/dist/decisionReplay.js.map +1 -1
  236. package/dist/decisionTraceLog.d.ts +28 -0
  237. package/dist/decisionTraceLog.js +156 -29
  238. package/dist/decisionTraceLog.js.map +1 -1
  239. package/dist/drivers/claude/api.js +5 -4
  240. package/dist/drivers/claude/api.js.map +1 -1
  241. package/dist/drivers/claude/envSanitizer.d.ts +0 -6
  242. package/dist/drivers/claude/envSanitizer.js +17 -2
  243. package/dist/drivers/claude/envSanitizer.js.map +1 -1
  244. package/dist/drivers/claude/streamParser.js +5 -4
  245. package/dist/drivers/claude/streamParser.js.map +1 -1
  246. package/dist/drivers/claude/subprocess.js +22 -3
  247. package/dist/drivers/claude/subprocess.js.map +1 -1
  248. package/dist/drivers/gemini/index.d.ts +22 -0
  249. package/dist/drivers/gemini/index.js +256 -129
  250. package/dist/drivers/gemini/index.js.map +1 -1
  251. package/dist/drivers/local/index.d.ts +17 -0
  252. package/dist/drivers/local/index.js +99 -0
  253. package/dist/drivers/local/index.js.map +1 -1
  254. package/dist/drivers/openai/index.js +30 -2
  255. package/dist/drivers/openai/index.js.map +1 -1
  256. package/dist/extensionClient.d.ts +37 -4
  257. package/dist/extensionClient.js +58 -16
  258. package/dist/extensionClient.js.map +1 -1
  259. package/dist/featureFlags.d.ts +91 -0
  260. package/dist/featureFlags.js +174 -3
  261. package/dist/featureFlags.js.map +1 -1
  262. package/dist/fileLock.js +21 -12
  263. package/dist/fileLock.js.map +1 -1
  264. package/dist/fileLockSync.d.ts +67 -0
  265. package/dist/fileLockSync.js +126 -0
  266. package/dist/fileLockSync.js.map +1 -0
  267. package/dist/fp/activityAnalytics.js +26 -12
  268. package/dist/fp/activityAnalytics.js.map +1 -1
  269. package/dist/fp/automationInterpreter.d.ts +15 -1
  270. package/dist/fp/automationInterpreter.js +217 -82
  271. package/dist/fp/automationInterpreter.js.map +1 -1
  272. package/dist/fp/automationProgram.d.ts +30 -0
  273. package/dist/fp/automationProgram.js.map +1 -1
  274. package/dist/fp/automationState.d.ts +24 -5
  275. package/dist/fp/automationState.js +56 -9
  276. package/dist/fp/automationState.js.map +1 -1
  277. package/dist/fp/automationUtils.js +1 -1
  278. package/dist/fp/automationUtils.js.map +1 -1
  279. package/dist/fp/commandDescription.js +7 -1
  280. package/dist/fp/commandDescription.js.map +1 -1
  281. package/dist/fp/extensionSnapshot.js +8 -2
  282. package/dist/fp/extensionSnapshot.js.map +1 -1
  283. package/dist/fp/interpreterContext.d.ts +66 -1
  284. package/dist/fp/interpreterContext.js +91 -1
  285. package/dist/fp/interpreterContext.js.map +1 -1
  286. package/dist/fp/policyParser.js +29 -1
  287. package/dist/fp/policyParser.js.map +1 -1
  288. package/dist/fsWatchWithFallback.d.ts +36 -0
  289. package/dist/fsWatchWithFallback.js +128 -0
  290. package/dist/fsWatchWithFallback.js.map +1 -0
  291. package/dist/haltPushDispatch.d.ts +33 -0
  292. package/dist/haltPushDispatch.js +103 -0
  293. package/dist/haltPushDispatch.js.map +1 -0
  294. package/dist/httpBodyValidation.d.ts +41 -0
  295. package/dist/httpBodyValidation.js +45 -0
  296. package/dist/httpBodyValidation.js.map +1 -0
  297. package/dist/inboxRoutes.d.ts +22 -0
  298. package/dist/inboxRoutes.js +61 -1
  299. package/dist/inboxRoutes.js.map +1 -1
  300. package/dist/index.js +1053 -76
  301. package/dist/index.js.map +1 -1
  302. package/dist/installGuard.js +6 -2
  303. package/dist/installGuard.js.map +1 -1
  304. package/dist/lockfile.js +31 -4
  305. package/dist/lockfile.js.map +1 -1
  306. package/dist/oauth.d.ts +9 -0
  307. package/dist/oauth.js +33 -0
  308. package/dist/oauth.js.map +1 -1
  309. package/dist/oauthRoutes.d.ts +1 -1
  310. package/dist/oauthRoutes.js +5 -3
  311. package/dist/oauthRoutes.js.map +1 -1
  312. package/dist/orchestrator/childBridgeRegistry.js +29 -11
  313. package/dist/orchestrator/childBridgeRegistry.js.map +1 -1
  314. package/dist/patchworkConfig.d.ts +44 -1
  315. package/dist/patchworkConfig.js +28 -3
  316. package/dist/patchworkConfig.js.map +1 -1
  317. package/dist/pluginLoader.js +10 -1
  318. package/dist/pluginLoader.js.map +1 -1
  319. package/dist/pluginWatcher.js +12 -14
  320. package/dist/pluginWatcher.js.map +1 -1
  321. package/dist/preToolUseHook.js +3 -2
  322. package/dist/preToolUseHook.js.map +1 -1
  323. package/dist/processTree.d.ts +34 -0
  324. package/dist/processTree.js +105 -0
  325. package/dist/processTree.js.map +1 -0
  326. package/dist/prompts.js +3 -3
  327. package/dist/prompts.js.map +1 -1
  328. package/dist/recipeOrchestration.d.ts +9 -0
  329. package/dist/recipeOrchestration.js +341 -24
  330. package/dist/recipeOrchestration.js.map +1 -1
  331. package/dist/recipeRoutes.d.ts +58 -2
  332. package/dist/recipeRoutes.js +744 -115
  333. package/dist/recipeRoutes.js.map +1 -1
  334. package/dist/recipes/RecipeOrchestrator.d.ts +2 -0
  335. package/dist/recipes/RecipeOrchestrator.js +6 -1
  336. package/dist/recipes/RecipeOrchestrator.js.map +1 -1
  337. package/dist/recipes/agentExecutor.d.ts +25 -5
  338. package/dist/recipes/agentExecutor.js.map +1 -1
  339. package/dist/recipes/chainedRunner.d.ts +2 -0
  340. package/dist/recipes/chainedRunner.js +142 -5
  341. package/dist/recipes/chainedRunner.js.map +1 -1
  342. package/dist/recipes/connectorPreflight.d.ts +66 -0
  343. package/dist/recipes/connectorPreflight.js +169 -0
  344. package/dist/recipes/connectorPreflight.js.map +1 -0
  345. package/dist/recipes/dependencyGraph.js +13 -5
  346. package/dist/recipes/dependencyGraph.js.map +1 -1
  347. package/dist/recipes/githubInstallSource.d.ts +128 -0
  348. package/dist/recipes/githubInstallSource.js +206 -0
  349. package/dist/recipes/githubInstallSource.js.map +1 -0
  350. package/dist/recipes/haltCategory.d.ts +119 -0
  351. package/dist/recipes/haltCategory.js +188 -0
  352. package/dist/recipes/haltCategory.js.map +1 -0
  353. package/dist/recipes/idempotencyKey.d.ts +134 -0
  354. package/dist/recipes/idempotencyKey.js +322 -0
  355. package/dist/recipes/idempotencyKey.js.map +1 -0
  356. package/dist/recipes/installer.js +48 -2
  357. package/dist/recipes/installer.js.map +1 -1
  358. package/dist/recipes/judgeSummary.d.ts +50 -0
  359. package/dist/recipes/judgeSummary.js +47 -0
  360. package/dist/recipes/judgeSummary.js.map +1 -0
  361. package/dist/recipes/judgeVerdict.d.ts +48 -0
  362. package/dist/recipes/judgeVerdict.js +174 -0
  363. package/dist/recipes/judgeVerdict.js.map +1 -0
  364. package/dist/recipes/migrations/index.d.ts +9 -0
  365. package/dist/recipes/migrations/index.js +133 -0
  366. package/dist/recipes/migrations/index.js.map +1 -1
  367. package/dist/recipes/names.d.ts +20 -0
  368. package/dist/recipes/names.js +25 -0
  369. package/dist/recipes/names.js.map +1 -1
  370. package/dist/recipes/parser.js +88 -5
  371. package/dist/recipes/parser.js.map +1 -1
  372. package/dist/recipes/replayRun.js +1 -1
  373. package/dist/recipes/replayRun.js.map +1 -1
  374. package/dist/recipes/runBudget.d.ts +70 -0
  375. package/dist/recipes/runBudget.js +109 -0
  376. package/dist/recipes/runBudget.js.map +1 -0
  377. package/dist/recipes/scheduler.d.ts +30 -0
  378. package/dist/recipes/scheduler.js +69 -19
  379. package/dist/recipes/scheduler.js.map +1 -1
  380. package/dist/recipes/schema.d.ts +36 -0
  381. package/dist/recipes/schemaGenerator.js +103 -5
  382. package/dist/recipes/schemaGenerator.js.map +1 -1
  383. package/dist/recipes/stepObservation.js +9 -0
  384. package/dist/recipes/stepObservation.js.map +1 -1
  385. package/dist/recipes/toolRegistry.js +20 -3
  386. package/dist/recipes/toolRegistry.js.map +1 -1
  387. package/dist/recipes/tools/fanOut.d.ts +20 -0
  388. package/dist/recipes/tools/fanOut.js +199 -0
  389. package/dist/recipes/tools/fanOut.js.map +1 -0
  390. package/dist/recipes/tools/file.js +5 -2
  391. package/dist/recipes/tools/file.js.map +1 -1
  392. package/dist/recipes/tools/github.d.ts +1 -1
  393. package/dist/recipes/tools/github.js +75 -1
  394. package/dist/recipes/tools/github.js.map +1 -1
  395. package/dist/recipes/tools/gmail.js +27 -5
  396. package/dist/recipes/tools/gmail.js.map +1 -1
  397. package/dist/recipes/tools/googleDrive.js +64 -0
  398. package/dist/recipes/tools/googleDrive.js.map +1 -1
  399. package/dist/recipes/tools/http.d.ts +10 -0
  400. package/dist/recipes/tools/http.js +176 -0
  401. package/dist/recipes/tools/http.js.map +1 -0
  402. package/dist/recipes/tools/index.d.ts +2 -0
  403. package/dist/recipes/tools/index.js +2 -0
  404. package/dist/recipes/tools/index.js.map +1 -1
  405. package/dist/recipes/tools/slack.js +1 -1
  406. package/dist/recipes/validation.d.ts +17 -0
  407. package/dist/recipes/validation.js +29 -11
  408. package/dist/recipes/validation.js.map +1 -1
  409. package/dist/recipes/workspaceRoot.d.ts +37 -0
  410. package/dist/recipes/workspaceRoot.js +73 -0
  411. package/dist/recipes/workspaceRoot.js.map +1 -0
  412. package/dist/recipes/yamlPositions.d.ts +56 -0
  413. package/dist/recipes/yamlPositions.js +183 -0
  414. package/dist/recipes/yamlPositions.js.map +1 -0
  415. package/dist/recipes/yamlRunner.d.ts +182 -8
  416. package/dist/recipes/yamlRunner.js +877 -217
  417. package/dist/recipes/yamlRunner.js.map +1 -1
  418. package/dist/recipesHttp.d.ts +19 -3
  419. package/dist/recipesHttp.js +67 -11
  420. package/dist/recipesHttp.js.map +1 -1
  421. package/dist/resources.js +21 -13
  422. package/dist/resources.js.map +1 -1
  423. package/dist/runLog.d.ts +58 -5
  424. package/dist/runLog.js +98 -21
  425. package/dist/runLog.js.map +1 -1
  426. package/dist/sanitizeParsedJson.d.ts +39 -0
  427. package/dist/sanitizeParsedJson.js +55 -0
  428. package/dist/sanitizeParsedJson.js.map +1 -0
  429. package/dist/schemas/recipe.v1.json +885 -196
  430. package/dist/server.d.ts +160 -3
  431. package/dist/server.js +1099 -122
  432. package/dist/server.js.map +1 -1
  433. package/dist/sessionCheckpoint.d.ts +8 -0
  434. package/dist/sessionCheckpoint.js +33 -3
  435. package/dist/sessionCheckpoint.js.map +1 -1
  436. package/dist/ssrfGuard.d.ts +16 -0
  437. package/dist/ssrfGuard.js +87 -4
  438. package/dist/ssrfGuard.js.map +1 -1
  439. package/dist/streamableHttp.d.ts +9 -4
  440. package/dist/streamableHttp.js +76 -20
  441. package/dist/streamableHttp.js.map +1 -1
  442. package/dist/telemetry.js +19 -12
  443. package/dist/telemetry.js.map +1 -1
  444. package/dist/testing/shadowRun.d.ts +52 -0
  445. package/dist/testing/shadowRun.js +71 -0
  446. package/dist/testing/shadowRun.js.map +1 -0
  447. package/dist/tools/batchLsp.d.ts +3 -0
  448. package/dist/tools/bridgeDoctor.d.ts +11 -0
  449. package/dist/tools/bridgeDoctor.js +48 -2
  450. package/dist/tools/bridgeDoctor.js.map +1 -1
  451. package/dist/tools/bridgeStatus.d.ts +0 -12
  452. package/dist/tools/bridgeStatus.js +2 -28
  453. package/dist/tools/bridgeStatus.js.map +1 -1
  454. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  455. package/dist/tools/clipboard.d.ts +2 -0
  456. package/dist/tools/closeTabs.d.ts +1 -0
  457. package/dist/tools/codeLens.d.ts +1 -0
  458. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  459. package/dist/tools/ctxGetTaskContext.d.ts +9 -0
  460. package/dist/tools/ctxGetTaskContext.js +50 -2
  461. package/dist/tools/ctxGetTaskContext.js.map +1 -1
  462. package/dist/tools/ctxQueryTraces.js +32 -24
  463. package/dist/tools/ctxQueryTraces.js.map +1 -1
  464. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  465. package/dist/tools/debug.d.ts +4 -0
  466. package/dist/tools/decorations.d.ts +2 -0
  467. package/dist/tools/detectUnusedCode.js +9 -7
  468. package/dist/tools/detectUnusedCode.js.map +1 -1
  469. package/dist/tools/documentLinks.d.ts +1 -0
  470. package/dist/tools/editText.d.ts +1 -0
  471. package/dist/tools/editText.js +2 -1
  472. package/dist/tools/editText.js.map +1 -1
  473. package/dist/tools/enrichCommit.d.ts +1 -0
  474. package/dist/tools/enrichStackTrace.js +11 -5
  475. package/dist/tools/enrichStackTrace.js.map +1 -1
  476. package/dist/tools/explainDiagnostic.d.ts +1 -0
  477. package/dist/tools/explainSymbol.d.ts +1 -0
  478. package/dist/tools/fileOperations.d.ts +3 -0
  479. package/dist/tools/fileOperations.js +2 -1
  480. package/dist/tools/fileOperations.js.map +1 -1
  481. package/dist/tools/fileWatcher.d.ts +2 -0
  482. package/dist/tools/fileWatcher.js +8 -2
  483. package/dist/tools/fileWatcher.js.map +1 -1
  484. package/dist/tools/findFiles.d.ts +1 -0
  485. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  486. package/dist/tools/fixAllLintErrors.js +10 -5
  487. package/dist/tools/fixAllLintErrors.js.map +1 -1
  488. package/dist/tools/foldingRanges.d.ts +1 -0
  489. package/dist/tools/formatDocument.d.ts +1 -0
  490. package/dist/tools/formatDocument.js +10 -5
  491. package/dist/tools/formatDocument.js.map +1 -1
  492. package/dist/tools/generateTests.d.ts +1 -0
  493. package/dist/tools/getAIComments.d.ts +1 -0
  494. package/dist/tools/getAnalyticsReport.js +5 -1
  495. package/dist/tools/getAnalyticsReport.js.map +1 -1
  496. package/dist/tools/getBufferContent.d.ts +1 -0
  497. package/dist/tools/getChangeImpact.d.ts +1 -0
  498. package/dist/tools/getChangeImpact.js +23 -14
  499. package/dist/tools/getChangeImpact.js.map +1 -1
  500. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  501. package/dist/tools/getCodeCoverage.d.ts +1 -0
  502. package/dist/tools/getCodeCoverage.js +32 -14
  503. package/dist/tools/getCodeCoverage.js.map +1 -1
  504. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  505. package/dist/tools/getDebugState.d.ts +1 -0
  506. package/dist/tools/getDiagnostics.js +32 -29
  507. package/dist/tools/getDiagnostics.js.map +1 -1
  508. package/dist/tools/getDiffFromHandoff.js +8 -2
  509. package/dist/tools/getDiffFromHandoff.js.map +1 -1
  510. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  511. package/dist/tools/getGitHotspots.d.ts +1 -0
  512. package/dist/tools/getImportedSignatures.d.ts +1 -0
  513. package/dist/tools/getImportedSignatures.js +18 -14
  514. package/dist/tools/getImportedSignatures.js.map +1 -1
  515. package/dist/tools/getPRTemplate.d.ts +1 -0
  516. package/dist/tools/getProjectContext.js +23 -10
  517. package/dist/tools/getProjectContext.js.map +1 -1
  518. package/dist/tools/getSymbolHistory.d.ts +1 -0
  519. package/dist/tools/getToolCapabilities.d.ts +10 -5
  520. package/dist/tools/getToolCapabilities.js +79 -202
  521. package/dist/tools/getToolCapabilities.js.map +1 -1
  522. package/dist/tools/getTypeSignature.d.ts +1 -0
  523. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  524. package/dist/tools/gitWrite.d.ts +11 -0
  525. package/dist/tools/github/actions.d.ts +2 -0
  526. package/dist/tools/github/composite.d.ts +3 -0
  527. package/dist/tools/github/issues.d.ts +4 -0
  528. package/dist/tools/github/pr.d.ts +7 -0
  529. package/dist/tools/handoffNote.d.ts +1 -0
  530. package/dist/tools/handoffNote.js +2 -1
  531. package/dist/tools/handoffNote.js.map +1 -1
  532. package/dist/tools/headless/lspClient.js +3 -0
  533. package/dist/tools/headless/lspClient.js.map +1 -1
  534. package/dist/tools/hoverAtCursor.d.ts +1 -0
  535. package/dist/tools/httpClient.d.ts +2 -0
  536. package/dist/tools/httpClient.js +38 -71
  537. package/dist/tools/httpClient.js.map +1 -1
  538. package/dist/tools/inlayHints.d.ts +1 -0
  539. package/dist/tools/launchQuickTask.d.ts +1 -0
  540. package/dist/tools/listClaudeTasks.d.ts +1 -0
  541. package/dist/tools/listClaudeTasks.js +10 -8
  542. package/dist/tools/listClaudeTasks.js.map +1 -1
  543. package/dist/tools/listTerminals.d.ts +1 -0
  544. package/dist/tools/lsp.d.ts +15 -0
  545. package/dist/tools/lsp.js +17 -0
  546. package/dist/tools/lsp.js.map +1 -1
  547. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  548. package/dist/tools/openDiff.d.ts +1 -0
  549. package/dist/tools/openDiff.js +4 -1
  550. package/dist/tools/openDiff.js.map +1 -1
  551. package/dist/tools/openFile.d.ts +1 -0
  552. package/dist/tools/openFile.js +4 -1
  553. package/dist/tools/openFile.js.map +1 -1
  554. package/dist/tools/openInBrowser.js +6 -1
  555. package/dist/tools/openInBrowser.js.map +1 -1
  556. package/dist/tools/organizeImports.d.ts +1 -0
  557. package/dist/tools/organizeImports.js +5 -3
  558. package/dist/tools/organizeImports.js.map +1 -1
  559. package/dist/tools/performanceReport.js +5 -3
  560. package/dist/tools/performanceReport.js.map +1 -1
  561. package/dist/tools/planPersistence.d.ts +3 -0
  562. package/dist/tools/planPersistence.js +4 -1
  563. package/dist/tools/planPersistence.js.map +1 -1
  564. package/dist/tools/previewEdit.d.ts +1 -0
  565. package/dist/tools/previewEdit.js +15 -4
  566. package/dist/tools/previewEdit.js.map +1 -1
  567. package/dist/tools/recentTracesDigest.js +54 -11
  568. package/dist/tools/recentTracesDigest.js.map +1 -1
  569. package/dist/tools/refactorAnalyze.d.ts +1 -0
  570. package/dist/tools/refactorExtractFunction.js +4 -1
  571. package/dist/tools/refactorExtractFunction.js.map +1 -1
  572. package/dist/tools/refactorPreview.d.ts +1 -0
  573. package/dist/tools/refactorPreview.js +10 -2
  574. package/dist/tools/refactorPreview.js.map +1 -1
  575. package/dist/tools/replaceBlock.d.ts +1 -0
  576. package/dist/tools/replaceBlock.js +2 -1
  577. package/dist/tools/replaceBlock.js.map +1 -1
  578. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  579. package/dist/tools/runClaudeTask.d.ts +1 -0
  580. package/dist/tools/runTests.js +15 -4
  581. package/dist/tools/runTests.js.map +1 -1
  582. package/dist/tools/screenshot.d.ts +1 -0
  583. package/dist/tools/screenshotAndAnnotate.js +6 -2
  584. package/dist/tools/screenshotAndAnnotate.js.map +1 -1
  585. package/dist/tools/searchAndReplace.d.ts +1 -0
  586. package/dist/tools/searchAndReplace.js +17 -7
  587. package/dist/tools/searchAndReplace.js.map +1 -1
  588. package/dist/tools/searchTools.js +20 -19
  589. package/dist/tools/searchTools.js.map +1 -1
  590. package/dist/tools/searchWorkspace.d.ts +1 -0
  591. package/dist/tools/selectionRanges.d.ts +1 -0
  592. package/dist/tools/semanticTokens.d.ts +1 -0
  593. package/dist/tools/signatureHelp.d.ts +1 -0
  594. package/dist/tools/spawnWorkspace.js +15 -7
  595. package/dist/tools/spawnWorkspace.js.map +1 -1
  596. package/dist/tools/terminal.d.ts +6 -0
  597. package/dist/tools/testRunners/pytest.js +6 -2
  598. package/dist/tools/testRunners/pytest.js.map +1 -1
  599. package/dist/tools/testRunners/vitestJest.js +3 -1
  600. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  601. package/dist/tools/testTraceToSource.d.ts +1 -0
  602. package/dist/tools/transaction.d.ts +4 -0
  603. package/dist/tools/transaction.js +4 -1
  604. package/dist/tools/transaction.js.map +1 -1
  605. package/dist/tools/typeHierarchy.d.ts +1 -0
  606. package/dist/tools/utils.d.ts +18 -0
  607. package/dist/tools/utils.js +103 -18
  608. package/dist/tools/utils.js.map +1 -1
  609. package/dist/tools/vscodeCommands.d.ts +2 -0
  610. package/dist/tools/vscodeTasks.d.ts +2 -0
  611. package/dist/tools/workspaceSettings.d.ts +1 -0
  612. package/dist/transport.d.ts +3 -1
  613. package/dist/transport.js +103 -52
  614. package/dist/transport.js.map +1 -1
  615. package/dist/winShim.d.ts +34 -0
  616. package/dist/winShim.js +94 -0
  617. package/dist/winShim.js.map +1 -0
  618. package/dist/wireHaltPushDispatch.d.ts +38 -0
  619. package/dist/wireHaltPushDispatch.js +71 -0
  620. package/dist/wireHaltPushDispatch.js.map +1 -0
  621. package/dist/writeFileAtomic.d.ts +23 -0
  622. package/dist/writeFileAtomic.js +121 -0
  623. package/dist/writeFileAtomic.js.map +1 -0
  624. package/package.json +23 -7
  625. package/scripts/postinstall.mjs +42 -2
  626. package/scripts/smoke/run-all.mjs +213 -0
  627. package/scripts/start-all.mjs +572 -0
  628. package/scripts/start-all.ps1 +209 -0
  629. package/scripts/start-all.sh +73 -17
  630. package/scripts/start-orchestrator.ps1 +158 -0
  631. package/scripts/start-remote.mjs +122 -0
  632. package/templates/automation-policies/recipe-authoring.json +1 -1
  633. package/templates/automation-policies/security-first.json +1 -1
  634. package/templates/automation-policies/strict-lint.json +1 -1
  635. package/templates/automation-policies/test-driven.json +1 -1
  636. package/templates/automation-policy.example.json +1 -1
  637. package/templates/co.patchwork-os.bridge.plist +2 -2
  638. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  639. package/templates/recipes/ctx-loop-test.yaml +1 -1
  640. package/templates/recipes/fix-errors-on-save.yaml +71 -0
  641. package/templates/recipes/morning-brief.yaml +5 -2
  642. package/templates/recipes/project-health-check.yaml +4 -1
  643. package/templates/recipes/sentry-to-linear.yaml +72 -38
  644. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  645. package/templates/recipes/webhook/customer-escalation.yaml +8 -9
  646. package/templates/recipes/webhook/meeting-prep.yaml +11 -5
  647. package/dist/commands/marketplace.d.ts +0 -16
  648. package/dist/commands/marketplace.js +0 -32
  649. package/dist/commands/marketplace.js.map +0 -1
  650. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  651. package/dist/recipes/legacyRecipeCompat.js +0 -131
  652. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -29,13 +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";
33
35
  import { respond500 } from "./httpErrorResponse.js";
34
36
  import { validateSafeUrl } from "./ssrfGuard.js";
35
37
  // 5-minute cache of the public template registry from the patchworkos/recipes
36
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.
37
41
  let templatesCache = null;
38
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
+ }
39
59
  /**
40
60
  * Per-process token bucket guarding `/recipes/generate`. Every call to the
41
61
  * route enqueues a Claude subprocess via the orchestrator — without a cap a
@@ -63,6 +83,26 @@ export function _resetGenerateRateLimitForTests() {
63
83
  lastRefill: 0,
64
84
  };
65
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
+ }
66
106
  // G-security R2 C-3 / I-3 / F-02: HTTP `vars` validation.
67
107
  //
68
108
  // The post-render path jail in `src/recipes/resolveRecipePath.ts` is the
@@ -142,6 +182,12 @@ export const RECIPE_ROUTE_BODY_CAPS = {
142
182
  run: 32 * 1024,
143
183
  /** /recipes (POST), PUT/PATCH /recipes/:name, /recipes/lint — yaml content. */
144
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,
145
191
  };
146
192
  /**
147
193
  * Read an HTTP request body up to `max` bytes. Returns the raw string or
@@ -188,7 +234,11 @@ export function readBodyWithCap(req, max) {
188
234
  const onEnd = () => {
189
235
  if (aborted)
190
236
  return;
191
- 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 });
192
242
  };
193
243
  const onError = () => {
194
244
  if (aborted)
@@ -254,6 +304,24 @@ function respondInvalidJson(res) {
254
304
  res.writeHead(400, { "Content-Type": "application/json" });
255
305
  res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
256
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
+ }
257
325
  /**
258
326
  * Try to handle a recipe / run-audit / template route. Returns true if
259
327
  * the route was dispatched (caller should `return` from the request
@@ -281,6 +349,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
281
349
  }
282
350
  try {
283
351
  const parsed = parsedBody.value ?? {};
352
+ if (respondIfUnknownBodyKeys(res, parsed, ["vars", "inputs"]))
353
+ return;
284
354
  const varsRaw = parsed.vars ?? parsed.inputs;
285
355
  const varsErr = validateRecipeVars(varsRaw);
286
356
  if (varsErr) {
@@ -295,7 +365,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
295
365
  res.writeHead(503, { "Content-Type": "application/json" });
296
366
  res.end(JSON.stringify({
297
367
  ok: false,
298
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
368
+ error: "Recipe execution unavailable — requires --driver subprocess",
299
369
  }));
300
370
  return;
301
371
  }
@@ -326,17 +396,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
326
396
  }
327
397
  try {
328
398
  const parsed = parsedBody.value ?? {};
399
+ if (respondIfUnknownBodyKeys(res, parsed, ["name", "vars", "inputs"]))
400
+ return;
329
401
  const name = parsed.name;
330
- 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);
331
405
  if (varsErr) {
332
406
  res.writeHead(400, { "Content-Type": "application/json" });
333
407
  res.end(JSON.stringify(varsErr));
334
408
  return;
335
409
  }
336
- const vars = parsed.vars &&
337
- typeof parsed.vars === "object" &&
338
- !Array.isArray(parsed.vars)
339
- ? parsed.vars
410
+ const vars = varsRaw && typeof varsRaw === "object" && !Array.isArray(varsRaw)
411
+ ? varsRaw
340
412
  : undefined;
341
413
  if (typeof name !== "string" || !name) {
342
414
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -347,7 +419,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
347
419
  res.writeHead(503, { "Content-Type": "application/json" });
348
420
  res.end(JSON.stringify({
349
421
  ok: false,
350
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
422
+ error: "Recipe execution unavailable — requires --driver subprocess",
351
423
  }));
352
424
  return;
353
425
  }
@@ -383,6 +455,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
383
455
  const trigger = sp.get("trigger");
384
456
  const status = sp.get("status");
385
457
  const recipe = sp.get("recipe");
458
+ const manualRunId = sp.get("manualRunId");
386
459
  const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
387
460
  const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
388
461
  const runs = deps.runsFn?.({
@@ -390,6 +463,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
390
463
  ...(trigger && { trigger }),
391
464
  ...(status && { status }),
392
465
  ...(recipe && { recipe }),
466
+ ...(manualRunId && { manualRunId }),
393
467
  ...(Number.isFinite(after) && { after }),
394
468
  }) ?? [];
395
469
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -400,6 +474,116 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
400
474
  }
401
475
  return true;
402
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);
521
+ }
522
+ return true;
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
+ }
403
587
  // GET /runs/:seq — single run detail (includes stepResults if present)
404
588
  const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
405
589
  if (runDetailMatch?.[1]) {
@@ -481,9 +665,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
481
665
  res.end(JSON.stringify({ plan }));
482
666
  }
483
667
  catch (err) {
484
- const msg = err instanceof Error ? err.message : String(err);
485
- const isNotFound = msg.includes("not found") || msg.includes("ENOENT");
486
- if (isNotFound) {
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") {
487
675
  res.writeHead(404, { "Content-Type": "application/json" });
488
676
  res.end(JSON.stringify({ error: "Run not found" }));
489
677
  }
@@ -540,19 +728,156 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
540
728
  res.writeHead(503, { "Content-Type": "application/json" });
541
729
  res.end(JSON.stringify({
542
730
  ok: false,
543
- error: "Recipe generation unavailable — requires --claude-driver subprocess",
731
+ error: "Recipe generation unavailable — requires --driver subprocess",
544
732
  unavailable: true,
545
733
  }));
546
734
  return;
547
735
  }
548
736
  try {
549
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
+ });
550
875
  const status = result.ok ? 200 : result.unavailable ? 503 : 422;
551
876
  res.writeHead(status, { "Content-Type": "application/json" });
552
877
  res.end(JSON.stringify(result));
553
878
  }
554
879
  catch (err) {
555
- console.error(`[recipes/install] internal error:`, err);
880
+ console.error(`[recipes/repair] internal error:`, err);
556
881
  res.writeHead(500, { "Content-Type": "application/json" });
557
882
  res.end(JSON.stringify({
558
883
  ok: false,
@@ -591,6 +916,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
591
916
  return;
592
917
  }
593
918
  const result = deps.saveRecipeFn(draft);
919
+ if (result.ok)
920
+ fireOnRecipesChanged(deps);
594
921
  res.writeHead(result.ok ? 201 : 400, {
595
922
  "Content-Type": "application/json",
596
923
  });
@@ -619,6 +946,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
619
946
  }
620
947
  return;
621
948
  }
949
+ if (respondIfUnknownBodyKeys(res, parsedBody.value, ["level"]))
950
+ return;
622
951
  const level = parsedBody.value
623
952
  ?.level;
624
953
  if (typeof level !== "string" || !level) {
@@ -670,6 +999,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
670
999
  return;
671
1000
  }
672
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);
673
1007
  res.writeHead(result.ok ? 200 : 400, {
674
1008
  "Content-Type": "application/json",
675
1009
  });
@@ -742,11 +1076,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
742
1076
  res.end(JSON.stringify({ plan }));
743
1077
  }
744
1078
  catch (err) {
745
- const msg = err instanceof Error ? err.message : String(err);
746
- const isNotFound = msg.includes("not found") || msg.includes("ENOENT");
747
- if (isNotFound) {
1079
+ // #605: classify by code, not substring (see /runs/:seq/plan).
1080
+ const code = err?.code;
1081
+ if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
748
1082
  res.writeHead(404, { "Content-Type": "application/json" });
749
- res.end(JSON.stringify({ error: "Run not found" }));
1083
+ res.end(JSON.stringify({ error: "Recipe not found" }));
750
1084
  }
751
1085
  else {
752
1086
  respond500(res, err, "recipes plan");
@@ -806,6 +1140,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
806
1140
  return;
807
1141
  }
808
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);
809
1148
  res.writeHead(result.ok ? 200 : 400, {
810
1149
  "Content-Type": "application/json",
811
1150
  });
@@ -828,6 +1167,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
828
1167
  return true;
829
1168
  }
830
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);
831
1174
  const status = result.ok
832
1175
  ? 200
833
1176
  : result.error === "Recipe not found"
@@ -847,6 +1190,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
847
1190
  return true;
848
1191
  }
849
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);
850
1197
  const status = result.ok
851
1198
  ? 200
852
1199
  : result.error === "Recipe not found"
@@ -866,6 +1213,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
866
1213
  return true;
867
1214
  }
868
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);
869
1220
  const status = result.ok
870
1221
  ? 201
871
1222
  : result.error === "Recipe not found"
@@ -883,10 +1234,21 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
883
1234
  void (async () => {
884
1235
  const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
885
1236
  if (!parsedBody.ok) {
886
- 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
+ }
887
1246
  return;
888
1247
  }
889
- const { targetName, force } = parsedBody.value ?? {};
1248
+ const promoteBody = parsedBody.value ?? {};
1249
+ if (respondIfUnknownBodyKeys(res, promoteBody, ["targetName", "force"]))
1250
+ return;
1251
+ const { targetName, force } = promoteBody;
890
1252
  if (typeof targetName !== "string" || !targetName.trim()) {
891
1253
  res.writeHead(400, { "Content-Type": "application/json" });
892
1254
  res.end(JSON.stringify({ ok: false, error: "targetName required" }));
@@ -901,6 +1263,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
901
1263
  const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
902
1264
  force: force === true,
903
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);
904
1270
  const httpStatus = result.ok
905
1271
  ? 200
906
1272
  : result.targetExists
@@ -937,19 +1303,42 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
937
1303
  void (async () => {
938
1304
  try {
939
1305
  const now = Date.now();
940
- if (!templatesCache || now - templatesCacheTs > 5 * 60 * 1000) {
1306
+ if (templatesCache === null || now - templatesCacheTs > 5 * 60 * 1000) {
941
1307
  const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
942
1308
  if (!ghRes.ok) {
943
1309
  throw new Error(`GitHub returned ${ghRes.status}`);
944
1310
  }
945
- 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;
946
1325
  templatesCacheTs = now;
947
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
+ }
948
1332
  res.writeHead(200, { "Content-Type": "application/json" });
949
1333
  res.end(JSON.stringify(templatesCache));
950
1334
  }
951
1335
  catch (err) {
952
- console.error(`[recipes/install] upstream error:`, 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
953
1342
  res.writeHead(502, { "Content-Type": "application/json" });
954
1343
  res.end(JSON.stringify({
955
1344
  ok: false,
@@ -972,6 +1361,23 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
972
1361
  // an explicitly-allowlisted hostname STILL has to clear the SSRF check
973
1362
  // (so an admin can't accidentally allowlist `localhost`).
974
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
+ }
975
1381
  void (async () => {
976
1382
  const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
977
1383
  if (!parsedBody.ok) {
@@ -993,59 +1399,59 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
993
1399
  // -----------------------------------------------------------------
994
1400
  // BUNDLE INSTALL DISPATCH (#130 PR A).
995
1401
  //
996
- // `github:patchworkos/recipes/bundles/<name>` installs every recipe
1402
+ // `github:<owner>/<repo>/bundles/<name>` installs every recipe
997
1403
  // listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
998
1404
  // and policy template (`policy_template`) declared in the manifest
999
1405
  // are surfaced as advisory-only — wiring those needs separate
1000
1406
  // decisions (npm-install surface, policy application UX) tracked
1001
- // 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.
1002
1413
  // -----------------------------------------------------------------
1003
- const bundlePrefix = "github:patchworkos/recipes/bundles/";
1004
- if (source.startsWith(bundlePrefix)) {
1005
- const bundleName = source.slice(bundlePrefix.length);
1006
- const { isSafeBasename } = await import("./commands/recipeInstall.js");
1007
- if (!isSafeBasename(bundleName)) {
1008
- res.writeHead(400, { "Content-Type": "application/json" });
1009
- res.end(JSON.stringify({
1010
- ok: false,
1011
- error: "Invalid bundle name in source",
1012
- code: "invalid_bundle_name",
1013
- }));
1014
- return;
1015
- }
1016
- 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);
1017
1426
  const ctl = new AbortController();
1018
1427
  const timeout = setTimeout(() => ctl.abort(), 30_000);
1019
- let manifestRes;
1020
- try {
1021
- manifestRes = await fetch(manifestUrl, {
1022
- signal: ctl.signal,
1023
- redirect: "follow",
1024
- });
1025
- }
1026
- catch (err) {
1027
- clearTimeout(timeout);
1028
- console.error(`[recipes/install] bundle manifest fetch failed:`, err);
1029
- res.writeHead(502, { "Content-Type": "application/json" });
1030
- res.end(JSON.stringify({
1031
- ok: false,
1032
- error: "Bundle manifest fetch failed",
1033
- code: "bundle_fetch_network_error",
1034
- }));
1035
- return;
1036
- }
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 });
1037
1431
  clearTimeout(timeout);
1038
- if (!manifestRes.ok) {
1039
- 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;
1040
1445
  res.writeHead(outStatus, { "Content-Type": "application/json" });
1041
1446
  res.end(JSON.stringify({
1042
1447
  ok: false,
1043
- error: `Bundle manifest at ${manifestUrl} returned ${manifestRes.status}`,
1448
+ error: `Bundle manifest at ${manifestUrl} returned ${failRes.status}`,
1044
1449
  code: "bundle_fetch_upstream_error",
1045
- upstreamStatus: manifestRes.status,
1450
+ upstreamStatus: failRes.status,
1046
1451
  }));
1047
1452
  return;
1048
1453
  }
1454
+ const manifestRes = manifestFetched.response;
1049
1455
  // 64 KB hard cap on manifest body — real `patchwork-bundle.json`
1050
1456
  // is single-digit KB; anything past 64 KB is hostile or malformed.
1051
1457
  const manifestBuf = await manifestRes.arrayBuffer();
@@ -1072,6 +1478,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1072
1478
  }));
1073
1479
  return;
1074
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");
1075
1485
  if (!Array.isArray(manifest.recipes) ||
1076
1486
  manifest.recipes.length === 0 ||
1077
1487
  !manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
@@ -1088,27 +1498,57 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1088
1498
  // all-or-nothing when one of N recipes is broken.
1089
1499
  const installed = [];
1090
1500
  const failures = [];
1091
- 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");
1092
1510
  const { installRecipeFromFile } = await import("./recipes/installer.js");
1093
1511
  const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1094
1512
  mkdirSync(recipesDir, { recursive: true });
1095
1513
  for (const r of manifest.recipes) {
1096
- 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).
1097
1531
  const recipeCtl = new AbortController();
1098
1532
  const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
1099
1533
  try {
1100
- const recipeRes = await fetch(recipeUrl, {
1101
- signal: recipeCtl.signal,
1102
- redirect: "follow",
1103
- });
1534
+ const recipeFetched = await fetchGithubInstallFile({
1535
+ kind: "recipe",
1536
+ owner: bundleOwner,
1537
+ repo: bundleRepo,
1538
+ name: r,
1539
+ }, { signal: recipeCtl.signal });
1104
1540
  clearTimeout(recipeTimeout);
1105
- if (!recipeRes.ok) {
1541
+ if (!recipeFetched.ok) {
1542
+ if (recipeFetched.kind === "network_error") {
1543
+ throw recipeFetched.error;
1544
+ }
1106
1545
  failures.push({
1107
1546
  name: r,
1108
- error: `Upstream returned ${recipeRes.status}`,
1547
+ error: `Upstream returned ${recipeFetched.response.status}`,
1109
1548
  });
1110
1549
  continue;
1111
1550
  }
1551
+ const recipeRes = recipeFetched.response;
1112
1552
  const recipeBuf = await recipeRes.arrayBuffer();
1113
1553
  if (recipeBuf.byteLength > 1024 * 1024) {
1114
1554
  failures.push({
@@ -1118,13 +1558,47 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1118
1558
  continue;
1119
1559
  }
1120
1560
  const yamlText = Buffer.from(recipeBuf).toString("utf-8");
1121
- 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`);
1122
1566
  writeFileSync(tmpFile, yamlText, "utf-8");
1123
1567
  try {
1124
1568
  const installResult = installRecipeFromFile(tmpFile, {
1125
1569
  recipesDir,
1126
1570
  });
1127
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
+ }
1128
1602
  }
1129
1603
  finally {
1130
1604
  try {
@@ -1155,6 +1629,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1155
1629
  // 200 if any recipe installed; 502 otherwise. Always include both
1156
1630
  // arrays so callers (CLI + dashboard) can render partial-success.
1157
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);
1158
1640
  res.writeHead(status, { "Content-Type": "application/json" });
1159
1641
  res.end(JSON.stringify({
1160
1642
  ok: installed.length > 0,
@@ -1162,28 +1644,54 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1162
1644
  bundleName,
1163
1645
  installed,
1164
1646
  failures,
1647
+ ...(aggregatedMissingConnectors.size > 0 && {
1648
+ missingConnectors: Array.from(aggregatedMissingConnectors),
1649
+ }),
1165
1650
  ...(Object.keys(advisory).length > 0 && { advisory }),
1166
1651
  }));
1167
1652
  return;
1168
1653
  }
1169
- const githubPrefix = "github:patchworkos/recipes/recipes/";
1170
1654
  let fetchUrl;
1171
1655
  let recipeName;
1172
- if (source.startsWith(githubPrefix)) {
1173
- recipeName = source.slice(githubPrefix.length);
1174
- // The constructed URL is internal recipeName must be a safe
1175
- // single-segment so we don't end up encoding `../etc/passwd` into
1176
- // the path. Reuse the strict basename predicate from `recipeInstall`.
1177
- const { isSafeBasename } = await import("./commands/recipeInstall.js");
1178
- 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") {
1179
1684
  res.writeHead(400, { "Content-Type": "application/json" });
1180
1685
  res.end(JSON.stringify({
1181
1686
  ok: false,
1182
- error: "Invalid recipe name in source",
1687
+ error: "Bundle source on single-recipe install. Use the bundle install path.",
1688
+ code: "bad_shape",
1183
1689
  }));
1184
1690
  return;
1185
1691
  }
1186
- 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);
1187
1695
  }
1188
1696
  else if (source.startsWith("https://")) {
1189
1697
  // Non-github source: must clear the env-var allowlist AND the SSRF
@@ -1253,43 +1761,88 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1253
1761
  const fetchCtl = new AbortController();
1254
1762
  const fetchTimeout = setTimeout(() => fetchCtl.abort(), 30_000);
1255
1763
  let yamlRes;
1256
- try {
1257
- 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, {
1258
1771
  signal: fetchCtl.signal,
1259
- redirect: "follow",
1260
1772
  });
1261
- }
1262
- catch (err) {
1263
1773
  clearTimeout(fetchTimeout);
1264
- console.error(`[recipes/install] fetch failed:`, err);
1265
- // Network-level error → 502 (upstream unreachable), not 500.
1266
- res.writeHead(502, { "Content-Type": "application/json" });
1267
- res.end(JSON.stringify({
1268
- ok: false,
1269
- error: "Fetch failed",
1270
- code: "fetch_network_error",
1271
- }));
1272
- 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;
1273
1806
  }
1274
- clearTimeout(fetchTimeout);
1275
- if (!yamlRes.ok) {
1276
- // Translate upstream HTTP into proper status — 404→404, 403→403,
1277
- // 5xx→502 (don't leak the upstream 500 as our 500). R2 H-routes Bug 2.
1278
- let outStatus = 502;
1279
- if (yamlRes.status === 404)
1280
- outStatus = 404;
1281
- else if (yamlRes.status === 403)
1282
- outStatus = 403;
1283
- else if (yamlRes.status >= 400 && yamlRes.status < 500)
1284
- outStatus = 400;
1285
- res.writeHead(outStatus, { "Content-Type": "application/json" });
1286
- res.end(JSON.stringify({
1287
- ok: false,
1288
- error: `Upstream returned ${yamlRes.status} ${yamlRes.statusText}`,
1289
- code: "fetch_upstream_error",
1290
- upstreamStatus: yamlRes.status,
1291
- }));
1292
- 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
+ }
1293
1846
  }
1294
1847
  // Streamed read with 1 MB cap (mirrors `httpClient` pattern).
1295
1848
  const MAX_RECIPE_BYTES = 1024 * 1024;
@@ -1326,7 +1879,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1326
1879
  return;
1327
1880
  }
1328
1881
  const yamlText = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
1329
- 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`);
1330
1889
  const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
1331
1890
  writeFileSync(tmpFile, yamlText, "utf-8");
1332
1891
  let result;
@@ -1337,7 +1896,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1337
1896
  const installResult = installRecipeFromFile(tmpFile, {
1338
1897
  recipesDir,
1339
1898
  });
1340
- 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
+ };
1341
1939
  }
1342
1940
  finally {
1343
1941
  try {
@@ -1347,11 +1945,42 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1347
1945
  // best-effort cleanup
1348
1946
  }
1349
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);
1350
1956
  res.writeHead(200, { "Content-Type": "application/json" });
1351
1957
  res.end(JSON.stringify({ ok: true, ...result }));
1352
1958
  }
1353
1959
  catch (err) {
1354
- // 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
+ }
1355
1984
  console.error(`[recipes/install] internal install error:`, err);
1356
1985
  res.writeHead(500, { "Content-Type": "application/json" });
1357
1986
  res.end(JSON.stringify({