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
package/dist/server.js CHANGED
@@ -1,17 +1,25 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
1
2
  import { EventEmitter } from "node:events";
3
+ import { unlinkSync } from "node:fs";
2
4
  import http from "node:http";
3
5
  import { WebSocket, WebSocketServer as WsServer } from "ws";
6
+ import { clearAnalyticsConfig, getAnalyticsConfig } from "./analyticsConfig.js";
7
+ import { getAnalyticsPrefsAll, getAnalyticsPrefsPath, getAnalyticsSaltPath, getTelemetryPrefs, setTelemetryPrefs, } from "./analyticsPrefs.js";
4
8
  import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
5
9
  import { getApprovalQueue } from "./approvalQueue.js";
6
10
  import { saveBridgeConfigDriver } from "./config.js";
7
11
  import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
8
12
  import { timingSafeStringEqual } from "./crypto.js";
9
13
  import { renderDashboardHtml } from "./dashboard.js";
14
+ import { isLoopbackOrPrivateEndpoint } from "./drivers/local/index.js";
15
+ import { EnvLockedFlagError, getEnvLockedValue, isEnabled, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, listFlags, setFlag, } from "./featureFlags.js";
16
+ import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
17
+ import { respond500 } from "./httpErrorResponse.js";
10
18
  import { tryHandleInboxRoute } from "./inboxRoutes.js";
11
19
  import { tryHandleMcpRoute } from "./mcpRoutes.js";
12
20
  import { tryHandleOAuthRoute } from "./oauthRoutes.js";
13
- import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
14
- import { tryHandleRecipeRoute } from "./recipeRoutes.js";
21
+ import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveApiKeyToSecureStore, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
22
+ import { readBodyWithCap, readJsonBody, respond413, tryHandleRecipeRoute, } from "./recipeRoutes.js";
15
23
  import { PACKAGE_VERSION } from "./version.js";
16
24
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
17
25
  const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -48,6 +56,7 @@ export class Server extends EventEmitter {
48
56
  logger;
49
57
  extraCorsOrigins;
50
58
  pingIntervalMs;
59
+ trustedProxies;
51
60
  httpServer;
52
61
  wss;
53
62
  pingInterval = null;
@@ -58,9 +67,25 @@ export class Server extends EventEmitter {
58
67
  oauthServer = null;
59
68
  oauthIssuerUrl = null;
60
69
  sseSubscriberCount = 0;
70
+ /**
71
+ * Registered SSE subscribers on `/stream`, oldest-first by insertion
72
+ * order. Used as a defense-in-depth backstop: if a subscriber's peer is
73
+ * a dead-but-not-erroring socket (e.g. an orphaned proxy connection),
74
+ * the ping-write cleanup can fail to fire and the subscriber leaks. To
75
+ * guarantee a leak can never *permanently* brick `/stream`, a new
76
+ * request arriving at the cap evicts the oldest subscriber instead of
77
+ * returning 503 — mirroring the HTTP-session eviction in ADR-0005.
78
+ * The real cure for the known leak is the dashboard proxy aborting its
79
+ * upstream fetch on client disconnect; this is the backstop.
80
+ */
81
+ sseSubscribers = new Set();
61
82
  /** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
62
83
  _explainRulesCache = null;
63
84
  static MAX_SSE_SUBSCRIBERS = 20;
85
+ /** Number of currently-registered `/stream` SSE subscribers. Read-only. */
86
+ get sseSubscribers_count() {
87
+ return this.sseSubscriberCount;
88
+ }
64
89
  /** Set by bridge to provide health data */
65
90
  healthDataFn = null;
66
91
  /** Set by bridge to provide Prometheus metrics */
@@ -79,6 +104,15 @@ export class Server extends EventEmitter {
79
104
  setRecipeTrustFn = null;
80
105
  /** Patchwork: set by bridge to generate a recipe YAML draft from a natural-language prompt. */
81
106
  generateRecipeFn = null;
107
+ /**
108
+ * Patchwork Phase 2A: repair a broken recipe YAML given the current
109
+ * content + structured LintIssue[] context. Driven by the same
110
+ * Claude-orchestrator infrastructure as `generateRecipeFn` (system
111
+ * prompt + sanitized user-tag wrapper + post-lint), but the user
112
+ * input is the user's CURRENT recipe rather than free-text — gated
113
+ * behind the `recipe.repair-ai` feature flag.
114
+ */
115
+ repairRecipeFn = null;
82
116
  /** Patchwork: set by bridge to list installed recipes for the dashboard. */
83
117
  recipesFn = null;
84
118
  /** Patchwork: set by bridge to load raw recipe source content by name. */
@@ -87,6 +121,8 @@ export class Server extends EventEmitter {
87
121
  saveRecipeContentFn = null;
88
122
  /** Patchwork: set by bridge to delete a recipe by name. */
89
123
  deleteRecipeContentFn = null;
124
+ /** Patchwork: set by bridge to archive a recipe (move to .archive/). */
125
+ archiveRecipeFn = null;
90
126
  /** Patchwork: set by bridge to promote a variant recipe to the canonical name. */
91
127
  promoteRecipeVariantFn = null;
92
128
  /** Patchwork: set by bridge to duplicate a recipe as a variant. */
@@ -99,6 +135,10 @@ export class Server extends EventEmitter {
99
135
  runsFn = null;
100
136
  /** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
101
137
  runDetailFn = null;
138
+ /** Patchwork (PR1c): aggregate halt-reason categories across recent runs. */
139
+ haltSummaryFn = null;
140
+ /** Patchwork (PR3b): aggregate judge-step verdicts across recent runs. */
141
+ judgeSummaryFn = null;
102
142
  /** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
103
143
  runPlanFn = null;
104
144
  /** Patchwork (VD-4): mocked replay of an existing run. Returns the new
@@ -106,10 +146,25 @@ export class Server extends EventEmitter {
106
146
  runReplayFn = null;
107
147
  /** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
108
148
  runRecipeFn = null;
149
+ /**
150
+ * Patchwork: set by bridge to re-prime the recipe scheduler when the
151
+ * on-disk recipe set changes (install / save / delete). Lets cron-
152
+ * triggered recipes start firing without a bridge restart. Optional —
153
+ * tests + headless tooling leave it null; the install handler treats
154
+ * the callback as best-effort fire-and-forget.
155
+ */
156
+ onRecipesChangedFn = null;
109
157
  /** Patchwork: admin-controlled managed settings path (highest rule precedence). */
110
158
  managedSettingsPath = undefined;
111
159
  /** Effective bridge config path to update when dashboard saves driver changes. */
112
160
  bridgeConfigPath = undefined;
161
+ /**
162
+ * Shared secret for HMAC-SHA256 verification of POST /hooks/* requests
163
+ * carrying `X-Hub-Signature-256`. When null (default), HMAC auth is
164
+ * disabled and /hooks/* requires the bridge bearer token like every
165
+ * other route. Set by Bridge constructor from `config.webhookSecret`.
166
+ */
167
+ webhookSecret = null;
113
168
  /** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
114
169
  approvalGate = "off";
115
170
  /** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
@@ -120,6 +175,10 @@ export class Server extends EventEmitter {
120
175
  pushServiceToken = undefined;
121
176
  /** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
122
177
  pushServiceBaseUrl = undefined;
178
+ /** Patchwork: ntfy.sh topic for direct phone-path approvals via action buttons. */
179
+ ntfyTopic = undefined;
180
+ /** Patchwork: ntfy server (default https://ntfy.sh; override for self-hosted). */
181
+ ntfyServer = undefined;
123
182
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
124
183
  onApprovalDecision = undefined;
125
184
  /**
@@ -142,6 +201,33 @@ export class Server extends EventEmitter {
142
201
  * the user's preference.
143
202
  */
144
203
  enableTimeOfDayAnomaly = false;
204
+ /**
205
+ * Patchwork: set by bridge to record a kill-switch audit trace.
206
+ * `/kill-switch` POST emits one entry on every state transition (no-op
207
+ * toggles do not emit). When unset, the handler logs via this.logger
208
+ * instead — see step 5 of issue #422.
209
+ *
210
+ * Trace encoding (v2-I6 from #422): we encode kill-switch events via
211
+ * the existing `DecisionTrace` schema rather than extending its
212
+ * `traceType` union, to keep schema migration off the kill-switch
213
+ * critical path. Fields used:
214
+ * ref = "kill-switch.writes"
215
+ * problem = "<short reason>" or "engage" / "release" if no reason
216
+ * solution = "ENGAGED at <ts>" or "RELEASED at <ts>"
217
+ * tags = ["kill-switch", "engage" | "release", "actor:http"]
218
+ *
219
+ * `ctxQueryTraces({tag: "kill-switch"})` returns the full audit
220
+ * history; pair with `tag: "engage"` / `tag: "release"` to filter
221
+ * direction.
222
+ */
223
+ recordKillSwitchTraceFn = null;
224
+ /**
225
+ * Set by bridge to broadcast a `kind: "kill-switch"` SSE event from
226
+ * `/stream` when the kill-switch state changes (issue #422 v2, pitfall I8).
227
+ * Bridge wires this to `activityLog.broadcastKillSwitch()` or an equivalent
228
+ * that notifies all active SSE listeners so the dashboard updates in <1s.
229
+ */
230
+ broadcastKillSwitchEventFn = null;
145
231
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
146
232
  webhookFn = null;
147
233
  /**
@@ -154,6 +240,36 @@ export class Server extends EventEmitter {
154
240
  */
155
241
  webhookPayloads = new Map();
156
242
  static MAX_WEBHOOK_PAYLOADS = 5;
243
+ /**
244
+ * Per-list FIFO bounds the per-recipe payload count, but the Map itself
245
+ * needs a key cap so a recipe-rename loop or a scanner hitting many
246
+ * distinct legitimate hookPaths can't grow `webhookPayloads.size`
247
+ * without bound. 1000 is generous for any realistic operator deployment
248
+ * (5 payloads × 1000 recipes = ~5000 entries × ~10 KB each = 50 MB).
249
+ * On overflow, evict the oldest *recipe* (Map iteration order is
250
+ * insertion order in JS), not the largest list.
251
+ */
252
+ static MAX_WEBHOOK_RECIPES = 1000;
253
+ /**
254
+ * Per-IP rate limit on the unauthenticated phone-path approval endpoints
255
+ * (`POST /approve/:callId` and `POST /reject/:callId` when
256
+ * `x-approval-token` is present). The auth gate intentionally bypasses
257
+ * bearer auth for those paths so a phone can dispatch without a bridge
258
+ * token; without rate limiting, an attacker who learns a callId (via
259
+ * webhook target leak, bearer-authed `/approvals` reader, etc.) can
260
+ * spray garbage tokens to DoS the legitimate approver. PR #380 bumped
261
+ * the per-callId failure cap to 1000 (memory bound, not security
262
+ * bound); this is the HTTP-layer spray defense flagged as the proper
263
+ * fix in that commit.
264
+ *
265
+ * 60 attempts per IP per minute is generous for legitimate retries
266
+ * (phone re-tap, network flake) and tight enough to bound brute-force
267
+ * attempts during the 5-minute approval TTL to 300 — well within the
268
+ * per-callId cap, so no legit retry budget is consumed by sprayers.
269
+ */
270
+ approvalIpCounts = new Map();
271
+ static APPROVAL_IP_MAX = 60;
272
+ static APPROVAL_IP_WINDOW_MS = 60_000;
157
273
  /** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
158
274
  httpMcpHandler = null;
159
275
  /** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
@@ -177,6 +293,22 @@ export class Server extends EventEmitter {
177
293
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
178
294
  launchQuickTaskFn = null;
179
295
  setRecipeEnabledFn = null;
296
+ /** Set by bridge to check if restart is safe (no in-flight tool calls). */
297
+ restartCheckFn = null;
298
+ /**
299
+ * Called when /restart decides it is safe to shut down. Defaults to
300
+ * `process.kill(process.pid, 'SIGTERM')`. Override in tests to a no-op so
301
+ * the Vitest runner process is not actually killed.
302
+ */
303
+ restartKillFn = () => process.kill(process.pid, "SIGTERM");
304
+ /**
305
+ * Called when /shutdown decides it is safe to exit. Defaults to
306
+ * `process.kill(process.pid, 'SIGTERM')`. Bridge overrides this to run its
307
+ * internal shutdown sequence directly — necessary on Windows where
308
+ * `process.kill(pid, 'SIGTERM')` is TerminateProcess and cleanup handlers
309
+ * never fire.
310
+ */
311
+ shutdownFn = () => process.kill(process.pid, "SIGTERM");
180
312
  /**
181
313
  * Attach an OAuth 2.0 Authorization Server.
182
314
  * When set, the bridge exposes:
@@ -193,12 +325,19 @@ export class Server extends EventEmitter {
193
325
  }
194
326
  /** Hosts accepted in the WebSocket upgrade Host header (DNS-rebinding guard). */
195
327
  allowedHosts;
196
- constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000) {
328
+ constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000,
329
+ // Reverse-proxy hops whose X-Forwarded-For values we trust. Empty by
330
+ // default — the per-IP rate limiter buckets on the direct socket peer.
331
+ // Behind nginx/Caddy/Cloudflare set this to the proxy's IP so distinct
332
+ // real clients get distinct buckets; otherwise every request looks
333
+ // like 127.0.0.1 and a single sprayer DoSes the legit approver.
334
+ trustedProxies = []) {
197
335
  super();
198
336
  this.authToken = authToken;
199
337
  this.logger = logger;
200
338
  this.extraCorsOrigins = extraCorsOrigins;
201
339
  this.pingIntervalMs = pingIntervalMs;
340
+ this.trustedProxies = trustedProxies;
202
341
  // Defense-in-depth: ensure token is non-empty so timingSafeTokenCompare
203
342
  // cannot accept a blank Authorization header against an empty token.
204
343
  if (authToken.length === 0) {
@@ -347,10 +486,7 @@ export class Server extends EventEmitter {
347
486
  res.end(JSON.stringify(body, null, 2));
348
487
  }
349
488
  catch (err) {
350
- res.writeHead(500, { "Content-Type": "application/json" });
351
- res.end(JSON.stringify({
352
- error: err instanceof Error ? err.message : String(err),
353
- }));
489
+ respond500(res, err);
354
490
  }
355
491
  return;
356
492
  }
@@ -371,13 +507,69 @@ export class Server extends EventEmitter {
371
507
  const oauthResolved = !isStaticToken && this.oauthServer
372
508
  ? this.oauthServer.resolveBearerToken(bearer)
373
509
  : null;
374
- // Phone-path: approve/reject with x-approval-token bypass bearer check.
510
+ // Phone-path: approve/reject with x-approval-token header or ?token= query param bypass bearer check.
375
511
  // The token itself is validated inside routeApprovalRequest via queue.validateToken.
376
512
  const isPhoneApprovalPath = req.method === "POST" &&
377
513
  /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
378
- !!req.headers["x-approval-token"];
514
+ !!(req.headers["x-approval-token"] || parsedUrl.searchParams.get("token"));
515
+ // GitHub-style webhook bypass: when --webhook-secret is configured,
516
+ // POST /hooks/* requests carrying X-Hub-Signature-256 bypass the
517
+ // bearer-token gate. Signature itself is verified inside the
518
+ // /hooks/* handler after the body has been read.
519
+ const isHmacWebhookCandidate = req.method === "POST" &&
520
+ parsedUrl.pathname.startsWith("/hooks/") &&
521
+ !!req.headers["x-hub-signature-256"] &&
522
+ this.webhookSecret !== null;
523
+ // Rate-limit the phone bypass surface. Only applies when this is
524
+ // actually a phone-path request that's relying on the bypass — a
525
+ // properly-authenticated bearer caller is unaffected. Counted +
526
+ // checked *before* dispatch so a sprayer can't burn the per-callId
527
+ // failure budget at line-rate.
528
+ if (isPhoneApprovalPath && !isStaticToken && !oauthResolved) {
529
+ const remoteIp = this.getClientIp(req);
530
+ if (!remoteIp) {
531
+ // Fail closed — without an attributable IP we cannot bucket
532
+ // safely, and the original "unknown" string was a single shared
533
+ // counter every IP-less request rolled up into.
534
+ res.writeHead(400, { "Content-Type": "application/json" });
535
+ res.end(JSON.stringify({
536
+ error: "bad_request",
537
+ error_description: "could not determine client IP",
538
+ }));
539
+ return;
540
+ }
541
+ const now = Date.now();
542
+ const entry = this.approvalIpCounts.get(remoteIp);
543
+ if (entry && now - entry.windowStart < Server.APPROVAL_IP_WINDOW_MS) {
544
+ entry.count++;
545
+ if (entry.count > Server.APPROVAL_IP_MAX) {
546
+ res.writeHead(429, { "Content-Type": "application/json" });
547
+ res.end(JSON.stringify({
548
+ error: "too_many_requests",
549
+ error_description: "per-IP approval endpoint rate limit reached",
550
+ }));
551
+ return;
552
+ }
553
+ }
554
+ else {
555
+ this.approvalIpCounts.set(remoteIp, { count: 1, windowStart: now });
556
+ }
557
+ // GC stale entries opportunistically — bounded growth alongside
558
+ // the same Map. 200 is well above any legitimate concurrent IP
559
+ // count; well below memory pressure.
560
+ if (this.approvalIpCounts.size > 200) {
561
+ for (const [k, v] of this.approvalIpCounts) {
562
+ if (now - v.windowStart > Server.APPROVAL_IP_WINDOW_MS) {
563
+ this.approvalIpCounts.delete(k);
564
+ }
565
+ }
566
+ }
567
+ }
379
568
  // oauthResolved is the bridge token if the OAuth token is valid; null otherwise
380
- if (!isStaticToken && !oauthResolved && !isPhoneApprovalPath) {
569
+ if (!isStaticToken &&
570
+ !oauthResolved &&
571
+ !isPhoneApprovalPath &&
572
+ !isHmacWebhookCandidate) {
381
573
  // RFC 6750: only include error= when a token was actually presented but invalid
382
574
  const tokenPresented = bearer.length > 0;
383
575
  const wwwAuth = this.oauthServer && this.oauthIssuerUrl
@@ -399,10 +591,7 @@ export class Server extends EventEmitter {
399
591
  res.end(body);
400
592
  }
401
593
  catch (err) {
402
- res.writeHead(500, { "Content-Type": "application/json" });
403
- res.end(JSON.stringify({
404
- error: err instanceof Error ? err.message : String(err),
405
- }));
594
+ respond500(res, err);
406
595
  }
407
596
  return;
408
597
  }
@@ -418,10 +607,7 @@ export class Server extends EventEmitter {
418
607
  res.end(JSON.stringify(data));
419
608
  }
420
609
  catch (err) {
421
- res.writeHead(500, { "Content-Type": "application/json" });
422
- res.end(JSON.stringify({
423
- error: err instanceof Error ? err.message : String(err),
424
- }));
610
+ respond500(res, err);
425
611
  }
426
612
  return;
427
613
  }
@@ -436,10 +622,7 @@ export class Server extends EventEmitter {
436
622
  res.end(JSON.stringify({ events: data, count: data.length }));
437
623
  }
438
624
  catch (err) {
439
- res.writeHead(500, { "Content-Type": "application/json" });
440
- res.end(JSON.stringify({
441
- error: err instanceof Error ? err.message : String(err),
442
- }));
625
+ respond500(res, err);
443
626
  }
444
627
  return;
445
628
  }
@@ -475,10 +658,7 @@ export class Server extends EventEmitter {
475
658
  res.end(JSON.stringify(data));
476
659
  }
477
660
  catch (err) {
478
- res.writeHead(500, { "Content-Type": "application/json" });
479
- res.end(JSON.stringify({
480
- error: err instanceof Error ? err.message : String(err),
481
- }));
661
+ respond500(res, err);
482
662
  }
483
663
  return;
484
664
  }
@@ -549,12 +729,7 @@ export class Server extends EventEmitter {
549
729
  }
550
730
  }
551
731
  catch (err) {
552
- if (!res.headersSent) {
553
- res.writeHead(500, { "Content-Type": "application/json" });
554
- res.end(JSON.stringify({
555
- error: err instanceof Error ? err.message : String(err),
556
- }));
557
- }
732
+ respond500(res, err);
558
733
  }
559
734
  })();
560
735
  return;
@@ -577,10 +752,7 @@ export class Server extends EventEmitter {
577
752
  res.end(JSON.stringify(data));
578
753
  }
579
754
  catch (err) {
580
- res.writeHead(500, { "Content-Type": "application/json" });
581
- res.end(JSON.stringify({
582
- error: err instanceof Error ? err.message : String(err),
583
- }));
755
+ respond500(res, err);
584
756
  }
585
757
  return;
586
758
  }
@@ -594,10 +766,7 @@ export class Server extends EventEmitter {
594
766
  res.end(JSON.stringify(data));
595
767
  }
596
768
  catch (err) {
597
- res.writeHead(500, { "Content-Type": "application/json" });
598
- res.end(JSON.stringify({
599
- error: err instanceof Error ? err.message : String(err),
600
- }));
769
+ respond500(res, err);
601
770
  }
602
771
  return;
603
772
  }
@@ -625,18 +794,23 @@ export class Server extends EventEmitter {
625
794
  }
626
795
  }
627
796
  catch (err) {
628
- res.writeHead(500, { "Content-Type": "application/json" });
629
- res.end(JSON.stringify({
630
- error: err instanceof Error ? err.message : String(err),
631
- }));
797
+ respond500(res, err);
632
798
  }
633
799
  return;
634
800
  }
635
801
  if (req.url === "/stream" && req.method === "GET") {
802
+ // Backstop (ADR-0005 pattern): at the cap, evict the oldest
803
+ // subscriber rather than returning 503 forever. A leaked
804
+ // subscriber whose socket is dead-but-not-erroring can otherwise
805
+ // permanently brick /stream. The dashboard proxy abort-on-disconnect
806
+ // fix is the real cure; this guarantees recovery either way.
807
+ // sseSubscriberCount and sseSubscribers.size stay in lockstep
808
+ // (both mutated together below + in cleanup()), so when the
809
+ // counter is at the cap there is always an evictable subscriber —
810
+ // eviction always frees the slot.
636
811
  if (this.sseSubscriberCount >= Server.MAX_SSE_SUBSCRIBERS) {
637
- res.writeHead(503, { "Content-Type": "application/json" });
638
- res.end(JSON.stringify({ error: "Too many SSE subscribers (max 20)" }));
639
- return;
812
+ const oldest = this.sseSubscribers.values().next().value;
813
+ oldest?.();
640
814
  }
641
815
  this.sseSubscriberCount++;
642
816
  // Disable socket timeout — SSE connections are long-lived by design
@@ -647,31 +821,58 @@ export class Server extends EventEmitter {
647
821
  Connection: "keep-alive",
648
822
  });
649
823
  res.flushHeaders();
650
- const unsub = this.streamFn?.((kind, entry) => {
651
- try {
652
- res.write(`data: ${JSON.stringify({ kind, ...entry })}\n\n`);
653
- }
654
- catch {
655
- // Client disconnected unsubscribe on next tick
656
- unsub?.();
657
- }
658
- }) ?? (() => { });
824
+ // Idempotent teardown every disconnect path (write error,
825
+ // ping error, req.close) funnels through here. Without this the
826
+ // counter could be decremented twice for a single subscriber
827
+ // (req.close fires AFTER write error in some Node versions) or
828
+ // not at all (write error never escalates to req.close on
829
+ // certain proxy intermediaries). Audit 2026-05-17 (#605
830
+ // BLOCKER): observed in production — subscribers hit cap of 20
831
+ // after enough page reloads, every new SSE returns 503 and
832
+ // kill-switch / activity ticker silently die.
833
+ let cleanedUp = false;
834
+ let pingHandle = null;
835
+ let unsub = () => { };
836
+ // Single teardown closure, also stored directly in the registry
837
+ // as this subscriber's eviction handler — it tears down state AND
838
+ // destroys the socket so the peer (and any orphaned proxy in
839
+ // between) observes the close. It removes this exact function
840
+ // from the Set, so no placeholder/no-op function is ever created.
841
+ const cleanup = () => {
842
+ if (cleanedUp)
843
+ return;
844
+ cleanedUp = true;
845
+ this.sseSubscriberCount--;
846
+ this.sseSubscribers.delete(cleanup);
847
+ if (pingHandle)
848
+ clearInterval(pingHandle);
849
+ unsub();
850
+ res.end();
851
+ res.socket?.destroy();
852
+ };
853
+ this.sseSubscribers.add(cleanup);
854
+ unsub =
855
+ this.streamFn?.((kind, entry) => {
856
+ try {
857
+ res.write(`data: ${JSON.stringify({ kind, ...entry })}\n\n`);
858
+ }
859
+ catch {
860
+ cleanup();
861
+ }
862
+ }) ?? (() => { });
659
863
  // Keep-alive comment ping every 15s so proxies don't close idle connections
660
- const ping = setInterval(() => {
864
+ pingHandle = setInterval(() => {
661
865
  try {
662
866
  res.write(": ping\n\n");
663
867
  }
664
868
  catch {
665
- clearInterval(ping);
666
- unsub();
869
+ cleanup();
667
870
  }
668
871
  }, 15_000);
669
- ping.unref();
670
- req.on("close", () => {
671
- this.sseSubscriberCount--;
672
- clearInterval(ping);
673
- unsub();
674
- });
872
+ pingHandle.unref();
873
+ req.on("close", cleanup);
874
+ req.on("error", cleanup);
875
+ res.on("error", cleanup);
675
876
  return;
676
877
  }
677
878
  if (req.url === "/tasks" && req.method === "GET") {
@@ -681,10 +882,7 @@ export class Server extends EventEmitter {
681
882
  res.end(JSON.stringify(data));
682
883
  }
683
884
  catch (err) {
684
- res.writeHead(500, { "Content-Type": "application/json" });
685
- res.end(JSON.stringify({
686
- error: err instanceof Error ? err.message : String(err),
687
- }));
885
+ respond500(res, err);
688
886
  }
689
887
  return;
690
888
  }
@@ -703,67 +901,104 @@ export class Server extends EventEmitter {
703
901
  }
704
902
  }
705
903
  catch (err) {
706
- res.writeHead(500, { "Content-Type": "application/json" });
707
- res.end(JSON.stringify({
708
- error: err instanceof Error ? err.message : String(err),
709
- }));
904
+ respond500(res, err);
710
905
  }
711
906
  return;
712
907
  }
713
908
  if (parsedUrl.pathname?.startsWith("/hooks/") && req.method === "POST") {
714
909
  const hookPath = parsedUrl.pathname.substring("/hooks".length);
715
- const chunks = [];
716
- req.on("data", (c) => chunks.push(c));
717
- req.on("end", () => {
718
- void (async () => {
719
- let payload;
720
- if (chunks.length > 0) {
721
- const body = Buffer.concat(chunks).toString("utf-8");
722
- if (body.trim()) {
723
- try {
724
- payload = JSON.parse(body);
725
- }
726
- catch {
727
- payload = body;
728
- }
729
- }
730
- }
731
- if (!this.webhookFn) {
732
- res.writeHead(503, { "Content-Type": "application/json" });
733
- res.end(JSON.stringify({
734
- ok: false,
735
- error: "Webhooks unavailable start bridge with --claude-driver subprocess",
736
- }));
737
- return;
738
- }
739
- const result = await this.webhookFn(hookPath, payload);
740
- const status = result.ok
741
- ? 200
742
- : result.error === "not_found"
743
- ? 404
744
- : 400;
745
- // Record in ring buffer so the dashboard can show "last
746
- // payload" per recipe. Skip not_found so unknown paths don't
747
- // pollute the buffer with garbage / scanner traffic.
748
- if (result.error !== "not_found") {
749
- const existing = this.webhookPayloads.get(hookPath) ?? [];
750
- existing.unshift({
751
- receivedAt: Date.now(),
752
- payload,
753
- ok: result.ok,
754
- error: result.error,
755
- taskId: result.taskId,
756
- recipeName: result.name,
757
- });
758
- if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
759
- existing.length = Server.MAX_WEBHOOK_PAYLOADS;
760
- }
761
- this.webhookPayloads.set(hookPath, existing);
762
- }
763
- res.writeHead(status, { "Content-Type": "application/json" });
764
- res.end(JSON.stringify(result));
765
- })();
766
- });
910
+ // 256 KB — webhook payloads from GitHub/Linear/etc. are typically
911
+ // 1–25 KB; 256 KB matches RECIPE_ROUTE_BODY_CAPS.content as a
912
+ // generous ceiling that still bounds an authenticated DoS vector.
913
+ const HOOKS_BODY_CAP = 256 * 1024;
914
+ const read = await readBodyWithCap(req, HOOKS_BODY_CAP);
915
+ if (!read.ok) {
916
+ respond413(res, HOOKS_BODY_CAP);
917
+ return;
918
+ }
919
+ // HMAC-SHA256 verification for GitHub-style webhooks. Signature is
920
+ // computed over the raw on-the-wire bytes (read.bytes), not the
921
+ // utf-8-decoded string — non-UTF-8 or denormalized payloads must
922
+ // round-trip identically to validate.
923
+ const sigHeader = req.headers["x-hub-signature-256"];
924
+ if (typeof sigHeader === "string" && sigHeader.length > 0) {
925
+ if (!this.webhookSecret) {
926
+ res.writeHead(401, { "Content-Type": "application/json" });
927
+ res.end(JSON.stringify({ error: "webhook_secret_not_configured" }));
928
+ return;
929
+ }
930
+ const expected = "sha256=" +
931
+ createHmac("sha256", this.webhookSecret)
932
+ .update(read.bytes)
933
+ .digest("hex");
934
+ const expectedBuf = Buffer.from(expected, "utf-8");
935
+ const providedBuf = Buffer.from(sigHeader, "utf-8");
936
+ // timingSafeEqual throws on length mismatch — length-check first
937
+ // so the constant-time path is only taken on equal-length inputs.
938
+ const sigOk = expectedBuf.length === providedBuf.length &&
939
+ timingSafeEqual(expectedBuf, providedBuf);
940
+ if (!sigOk) {
941
+ res.writeHead(401, { "Content-Type": "application/json" });
942
+ res.end(JSON.stringify({ error: "invalid_signature" }));
943
+ return;
944
+ }
945
+ }
946
+ let payload;
947
+ if (read.body.trim()) {
948
+ try {
949
+ payload = JSON.parse(read.body);
950
+ }
951
+ catch {
952
+ payload = read.body;
953
+ }
954
+ }
955
+ if (!this.webhookFn) {
956
+ res.writeHead(503, { "Content-Type": "application/json" });
957
+ res.end(JSON.stringify({
958
+ ok: false,
959
+ error: "Webhooks unavailable — start bridge with --driver subprocess",
960
+ }));
961
+ return;
962
+ }
963
+ const result = await this.webhookFn(hookPath, payload);
964
+ const status = result.ok
965
+ ? 200
966
+ : result.error === "not_found"
967
+ ? 404
968
+ : 400;
969
+ // Record in ring buffer so the dashboard can show "last
970
+ // payload" per recipe. Skip not_found so unknown paths don't
971
+ // pollute the buffer with garbage / scanner traffic.
972
+ if (result.error !== "not_found") {
973
+ const existing = this.webhookPayloads.get(hookPath) ?? [];
974
+ existing.unshift({
975
+ receivedAt: Date.now(),
976
+ payload,
977
+ ok: result.ok,
978
+ error: result.error,
979
+ taskId: result.taskId,
980
+ recipeName: result.name,
981
+ });
982
+ if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
983
+ existing.length = Server.MAX_WEBHOOK_PAYLOADS;
984
+ }
985
+ // LRU eviction: Map.set() on an existing key keeps original
986
+ // insertion-order position (per spec), so a hot recipe registered
987
+ // at startup would otherwise be evicted in favor of a cold
988
+ // scanner-spam recipe just because it was registered earlier.
989
+ // Delete + re-set re-anchors the key to the END of insertion
990
+ // order, making `.keys().next()` correctly point at the
991
+ // least-recently-fired recipe.
992
+ this.webhookPayloads.delete(hookPath);
993
+ if (this.webhookPayloads.size >= Server.MAX_WEBHOOK_RECIPES) {
994
+ const oldest = this.webhookPayloads.keys().next().value;
995
+ if (oldest !== undefined)
996
+ this.webhookPayloads.delete(oldest);
997
+ }
998
+ this.webhookPayloads.set(hookPath, existing);
999
+ }
1000
+ res.writeHead(status, { "Content-Type": "application/json" });
1001
+ res.end(JSON.stringify(result));
767
1002
  return;
768
1003
  }
769
1004
  if (parsedUrl.pathname?.startsWith("/webhook-payloads/") &&
@@ -912,10 +1147,12 @@ export class Server extends EventEmitter {
912
1147
  if (tryHandleRecipeRoute(req, res, parsedUrl, {
913
1148
  setRecipeTrustFn: this.setRecipeTrustFn,
914
1149
  generateRecipeFn: this.generateRecipeFn,
1150
+ repairRecipeFn: this.repairRecipeFn,
915
1151
  recipesFn: this.recipesFn,
916
1152
  loadRecipeContentFn: this.loadRecipeContentFn,
917
1153
  saveRecipeContentFn: this.saveRecipeContentFn,
918
1154
  deleteRecipeContentFn: this.deleteRecipeContentFn,
1155
+ archiveRecipeFn: this.archiveRecipeFn,
919
1156
  duplicateRecipeFn: this.duplicateRecipeFn,
920
1157
  promoteRecipeVariantFn: this.promoteRecipeVariantFn,
921
1158
  lintRecipeContentFn: this.lintRecipeContentFn,
@@ -923,9 +1160,12 @@ export class Server extends EventEmitter {
923
1160
  setRecipeEnabledFn: this.setRecipeEnabledFn,
924
1161
  runsFn: this.runsFn,
925
1162
  runDetailFn: this.runDetailFn,
1163
+ haltSummaryFn: this.haltSummaryFn,
1164
+ judgeSummaryFn: this.judgeSummaryFn,
926
1165
  runPlanFn: this.runPlanFn,
927
1166
  runReplayFn: this.runReplayFn,
928
1167
  runRecipeFn: this.runRecipeFn,
1168
+ onRecipesChangedFn: this.onRecipesChangedFn,
929
1169
  })) {
930
1170
  return;
931
1171
  }
@@ -943,10 +1183,7 @@ export class Server extends EventEmitter {
943
1183
  res.end(JSON.stringify(data));
944
1184
  }
945
1185
  catch (err) {
946
- res.writeHead(500, { "Content-Type": "application/json" });
947
- res.end(JSON.stringify({
948
- error: err instanceof Error ? err.message : String(err),
949
- }));
1186
+ respond500(res, err);
950
1187
  }
951
1188
  return;
952
1189
  }
@@ -962,68 +1199,96 @@ export class Server extends EventEmitter {
962
1199
  res.end(JSON.stringify(data));
963
1200
  }
964
1201
  catch (err) {
965
- res.writeHead(500, { "Content-Type": "application/json" });
966
- res.end(JSON.stringify({
967
- error: err instanceof Error ? err.message : String(err),
968
- }));
1202
+ respond500(res, err);
969
1203
  }
970
1204
  return;
971
1205
  }
972
1206
  if (parsedUrl.pathname === "/settings" && req.method === "POST") {
973
- const chunks = [];
974
- req.on("data", (c) => chunks.push(c));
975
- req.on("end", () => {
976
- try {
977
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1207
+ // Kill-switch gate: during an incident a settings mutation can
1208
+ // defeat the panic posture (e.g. switching driver to one with a
1209
+ // leaked API key, flipping approvalGate to "off"). The /settings
1210
+ // card on the dashboard sits directly above the kill-switch
1211
+ // toggle users will reasonably read "writes blocked" as
1212
+ // covering both. Refuse with 423 Locked; the /kill-switch
1213
+ // endpoint itself is the only way out and is not gated.
1214
+ if (isWriteKillSwitchActive()) {
1215
+ res.writeHead(423, { "Content-Type": "application/json" });
1216
+ res.end(JSON.stringify({
1217
+ error: "kill_switch_blocked",
1218
+ reason: "Settings writes are disabled while the write kill-switch is engaged. Release it via POST /kill-switch to mutate config.",
1219
+ }));
1220
+ return;
1221
+ }
1222
+ // 16 KB — settings POSTs are short-string fields (URLs, API keys,
1223
+ // gate level). 16 KB is generous; an authenticated attacker can't
1224
+ // stream gigabytes here.
1225
+ const SETTINGS_BODY_CAP = 16 * 1024;
1226
+ const parsed = await readJsonBody(req, SETTINGS_BODY_CAP);
1227
+ if (!parsed.ok) {
1228
+ if (parsed.code === "too_large") {
1229
+ respond413(res, SETTINGS_BODY_CAP);
1230
+ return;
1231
+ }
1232
+ res.writeHead(400, { "Content-Type": "application/json" });
1233
+ res.end(JSON.stringify({ error: "Invalid request body" }));
1234
+ return;
1235
+ }
1236
+ try {
1237
+ {
1238
+ const body = parsed.value ?? {};
1239
+ if (respondIfUnknownBodyKeys(res, body, [
1240
+ "webhookUrl",
1241
+ "approvalGate",
1242
+ "enableTimeOfDayAnomaly",
1243
+ "driver",
1244
+ "model",
1245
+ "localEndpoint",
1246
+ "localModel",
1247
+ "apiKey",
1248
+ "pushServiceUrl",
1249
+ "pushServiceToken",
1250
+ "pushServiceBaseUrl",
1251
+ "ntfyTopic",
1252
+ "ntfyServer",
1253
+ ])) {
1254
+ return;
1255
+ }
1256
+ // PHASE 1 — validate ALL fields up front. No disk writes, no
1257
+ // secure-store writes, no live-state mutations until every input
1258
+ // has passed. Prevents the "valid driver + invalid ntfyTopic"
1259
+ // class of bug where a 400 still leaves a partial side-effect on
1260
+ // disk.
1261
+ const respond400 = (error) => {
1262
+ res.writeHead(400, { "Content-Type": "application/json" });
1263
+ res.end(JSON.stringify({ error }));
1264
+ };
1265
+ // webhookUrl
978
1266
  const hasWebhookUpdate = body.webhookUrl !== undefined;
979
- const raw = hasWebhookUpdate
1267
+ const webhookRaw = hasWebhookUpdate
980
1268
  ? (body.webhookUrl?.trim() ?? "")
981
1269
  : undefined;
982
- if (raw !== undefined && raw !== "" && !/^https:\/\/.+/.test(raw)) {
983
- res.writeHead(400, { "Content-Type": "application/json" });
984
- res.end(JSON.stringify({ error: "webhookUrl must be HTTPS" }));
1270
+ if (webhookRaw !== undefined &&
1271
+ webhookRaw !== "" &&
1272
+ !/^https:\/\/.+/.test(webhookRaw)) {
1273
+ respond400("webhookUrl must be HTTPS");
985
1274
  return;
986
1275
  }
1276
+ // approvalGate
987
1277
  const gateRaw = body.approvalGate;
988
1278
  if (gateRaw !== undefined &&
989
1279
  gateRaw !== "off" &&
990
1280
  gateRaw !== "high" &&
991
1281
  gateRaw !== "all") {
992
- res.writeHead(400, { "Content-Type": "application/json" });
993
- res.end(JSON.stringify({
994
- error: 'approvalGate must be "off", "high", or "all"',
995
- }));
1282
+ respond400('approvalGate must be "off", "high", or "all"');
996
1283
  return;
997
1284
  }
998
- const configPath = patchworkConfigPath();
999
- const cfg = loadPatchworkConfig(configPath);
1000
- cfg.dashboard = {
1001
- port: cfg.dashboard?.port ?? 3200,
1002
- requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1003
- pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1004
- webhookUrl: hasWebhookUpdate
1005
- ? raw || undefined
1006
- : cfg.dashboard?.webhookUrl,
1007
- };
1008
- if (gateRaw !== undefined) {
1009
- cfg.approvalGate = gateRaw;
1010
- this.approvalGate = gateRaw;
1011
- }
1012
- // h10 toggle: must be boolean if present. Persists to
1013
- // ~/.patchwork/config.json AND live-mutates the Server
1014
- // field so the next /approvals POST honors it without
1015
- // needing a bridge restart.
1016
- if (body.enableTimeOfDayAnomaly !== undefined) {
1017
- if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
1018
- res.writeHead(400, { "Content-Type": "application/json" });
1019
- res.end(JSON.stringify({
1020
- error: "enableTimeOfDayAnomaly must be a boolean",
1021
- }));
1022
- return;
1023
- }
1024
- cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1025
- this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1285
+ // enableTimeOfDayAnomaly
1286
+ if (body.enableTimeOfDayAnomaly !== undefined &&
1287
+ typeof body.enableTimeOfDayAnomaly !== "boolean") {
1288
+ respond400("enableTimeOfDayAnomaly must be a boolean");
1289
+ return;
1026
1290
  }
1291
+ // driver
1027
1292
  const driverRaw = body.driver;
1028
1293
  if (driverRaw !== undefined) {
1029
1294
  const validDrivers = [
@@ -1032,19 +1297,16 @@ export class Server extends EventEmitter {
1032
1297
  "openai",
1033
1298
  "grok",
1034
1299
  "gemini",
1300
+ "gemini-api",
1301
+ "local",
1035
1302
  "none",
1036
1303
  ];
1037
1304
  if (!validDrivers.includes(driverRaw)) {
1038
- res.writeHead(400, { "Content-Type": "application/json" });
1039
- res.end(JSON.stringify({
1040
- error: `driver must be one of: ${validDrivers.join(", ")}`,
1041
- }));
1305
+ respond400(`driver must be one of: ${validDrivers.join(", ")}`);
1042
1306
  return;
1043
1307
  }
1044
- const driver = driverRaw;
1045
- cfg.driver = driver;
1046
- saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1047
1308
  }
1309
+ // model
1048
1310
  if (body.model !== undefined) {
1049
1311
  const validModels = [
1050
1312
  "claude",
@@ -1054,95 +1316,782 @@ export class Server extends EventEmitter {
1054
1316
  "local",
1055
1317
  ];
1056
1318
  if (!validModels.includes(body.model)) {
1057
- res.writeHead(400, { "Content-Type": "application/json" });
1058
- res.end(JSON.stringify({
1059
- error: `model must be one of: ${validModels.join(", ")}`,
1060
- }));
1319
+ respond400(`model must be one of: ${validModels.join(", ")}`);
1061
1320
  return;
1062
1321
  }
1063
- cfg.model = body.model;
1064
- if (body.model === "local") {
1065
- if (body.localEndpoint !== undefined)
1066
- cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1067
- if (body.localModel !== undefined)
1068
- cfg.localModel = body.localModel.trim() || undefined;
1069
- }
1070
1322
  }
1323
+ // apiKey
1071
1324
  if (body.apiKey) {
1072
1325
  const { provider, key } = body.apiKey;
1073
1326
  const validProviders = ["anthropic", "openai", "google", "xai"];
1074
1327
  if (!validProviders.includes(provider) ||
1075
1328
  typeof key !== "string") {
1076
- res.writeHead(400, { "Content-Type": "application/json" });
1077
- res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1329
+ respond400("Invalid apiKey provider or key");
1078
1330
  return;
1079
1331
  }
1080
- cfg.apiKeys = { ...cfg.apiKeys, [provider]: key || undefined };
1081
1332
  }
1082
- savePatchworkConfig(cfg, configPath);
1083
- if (hasWebhookUpdate) {
1084
- this.approvalWebhookUrl = raw || undefined;
1333
+ // pushServiceUrl
1334
+ const pushUrlTrimmed = body.pushServiceUrl !== undefined
1335
+ ? body.pushServiceUrl.trim()
1336
+ : undefined;
1337
+ if (pushUrlTrimmed !== undefined &&
1338
+ pushUrlTrimmed !== "" &&
1339
+ !pushUrlTrimmed.startsWith("https://")) {
1340
+ respond400("pushServiceUrl must be HTTPS");
1341
+ return;
1342
+ }
1343
+ // pushServiceToken — bearer for the push relay. Charset cap to
1344
+ // printable ASCII (no newlines / control chars) so it can't
1345
+ // header-inject when later interpolated into an outgoing
1346
+ // `Authorization` header. 512 chars is an order of magnitude
1347
+ // above any realistic relay token.
1348
+ const pushTokenTrimmed = body.pushServiceToken !== undefined
1349
+ ? body.pushServiceToken.trim()
1350
+ : undefined;
1351
+ if (pushTokenTrimmed !== undefined &&
1352
+ pushTokenTrimmed !== "" &&
1353
+ !/^[\x21-\x7E]{1,512}$/.test(pushTokenTrimmed)) {
1354
+ respond400("pushServiceToken must be 1-512 printable ASCII characters");
1355
+ return;
1356
+ }
1357
+ // pushServiceBaseUrl — bridge callback origin embedded in SW
1358
+ // approveUrl/rejectUrl. http:// or attacker host = approval token
1359
+ // exfiltration. Validate before persisting.
1360
+ const pushBaseTrimmed = body.pushServiceBaseUrl !== undefined
1361
+ ? body.pushServiceBaseUrl.trim()
1362
+ : undefined;
1363
+ if (pushBaseTrimmed !== undefined &&
1364
+ pushBaseTrimmed !== "" &&
1365
+ !pushBaseTrimmed.startsWith("https://")) {
1366
+ respond400("pushServiceBaseUrl must be HTTPS");
1367
+ return;
1085
1368
  }
1086
- if (body.pushServiceUrl !== undefined) {
1087
- const pushUrl = body.pushServiceUrl.trim();
1088
- if (pushUrl && !pushUrl.startsWith("https://")) {
1089
- res.writeHead(400, { "Content-Type": "application/json" });
1090
- res.end(JSON.stringify({ error: "pushServiceUrl must be HTTPS" }));
1369
+ // ntfyTopic bearer-token on public ntfy.sh; charset-restricted.
1370
+ const ntfyTopicTrimmed = body.ntfyTopic !== undefined ? body.ntfyTopic.trim() : undefined;
1371
+ if (ntfyTopicTrimmed !== undefined &&
1372
+ ntfyTopicTrimmed !== "" &&
1373
+ !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopicTrimmed)) {
1374
+ respond400("ntfyTopic must match [A-Za-z0-9_-]{1,64}");
1375
+ return;
1376
+ }
1377
+ // ntfyServer — same threat model as pushServiceBaseUrl.
1378
+ const ntfyServerTrimmed = body.ntfyServer !== undefined
1379
+ ? body.ntfyServer.trim()
1380
+ : undefined;
1381
+ if (ntfyServerTrimmed !== undefined &&
1382
+ ntfyServerTrimmed !== "" &&
1383
+ !ntfyServerTrimmed.startsWith("https://")) {
1384
+ respond400("ntfyServer must be HTTPS");
1385
+ return;
1386
+ }
1387
+ // localEndpoint — used by LocalApiDriver as the inference base
1388
+ // URL. Must parse as http(s)://, be ≤2048 chars, and resolve to
1389
+ // a loopback or private address. Otherwise prompts + context
1390
+ // would stream to a public / metadata / file:// host. Same gate
1391
+ // the driver enforces at construction, raised to the HTTP
1392
+ // boundary so the bad value never reaches disk. Operator can
1393
+ // opt out via LOCAL_ENDPOINT_ALLOW_REMOTE=1 for audited
1394
+ // internal inference clusters.
1395
+ const localEndpointTrimmed = body.localEndpoint !== undefined
1396
+ ? body.localEndpoint.trim()
1397
+ : undefined;
1398
+ if (localEndpointTrimmed !== undefined &&
1399
+ localEndpointTrimmed !== "") {
1400
+ if (localEndpointTrimmed.length > 2048) {
1401
+ respond400("localEndpoint must be ≤2048 characters");
1091
1402
  return;
1092
1403
  }
1093
- this.pushServiceUrl = pushUrl || undefined;
1404
+ let parsed;
1405
+ try {
1406
+ parsed = new URL(localEndpointTrimmed);
1407
+ }
1408
+ catch {
1409
+ respond400("localEndpoint must be a valid http(s):// URL");
1410
+ return;
1411
+ }
1412
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1413
+ respond400("localEndpoint must use http:// or https:// scheme");
1414
+ return;
1415
+ }
1416
+ if (process.env.LOCAL_ENDPOINT_ALLOW_REMOTE !== "1" &&
1417
+ !isLoopbackOrPrivateEndpoint(localEndpointTrimmed)) {
1418
+ respond400("localEndpoint must be loopback or private (set LOCAL_ENDPOINT_ALLOW_REMOTE=1 to override)");
1419
+ return;
1420
+ }
1421
+ }
1422
+ // PHASE 2 — load config and apply all in-memory edits to `cfg`.
1423
+ // Still no disk writes; if anything throws here the request
1424
+ // returns 500 with no side effects.
1425
+ const configPath = patchworkConfigPath();
1426
+ const cfg = loadPatchworkConfig(configPath);
1427
+ cfg.dashboard = {
1428
+ port: cfg.dashboard?.port ?? 3200,
1429
+ requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1430
+ pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1431
+ webhookUrl: hasWebhookUpdate
1432
+ ? webhookRaw || undefined
1433
+ : cfg.dashboard?.webhookUrl,
1434
+ };
1435
+ if (gateRaw !== undefined) {
1436
+ cfg.approvalGate = gateRaw;
1437
+ }
1438
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1439
+ cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1440
+ }
1441
+ if (driverRaw !== undefined) {
1442
+ cfg.driver = driverRaw;
1443
+ }
1444
+ if (body.model !== undefined) {
1445
+ cfg.model = body.model;
1446
+ }
1447
+ // localEndpoint / localModel persist regardless of whether
1448
+ // `model` is sent. Previously they were silently dropped when
1449
+ // the dashboard updated only the endpoint of an already-local
1450
+ // install, which left the running driver pointed at the old
1451
+ // host until the user happened to re-pick "Local LLM" in the
1452
+ // UI.
1453
+ if (body.localEndpoint !== undefined) {
1454
+ cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1455
+ }
1456
+ if (body.localModel !== undefined) {
1457
+ cfg.localModel = body.localModel.trim() || undefined;
1458
+ }
1459
+ // Push / ntfy fields used to be set only on `this.*` and were
1460
+ // lost on bridge restart. Persist alongside the rest.
1461
+ if (pushUrlTrimmed !== undefined) {
1462
+ cfg.pushServiceUrl = pushUrlTrimmed || undefined;
1094
1463
  }
1095
- if (body.pushServiceToken !== undefined) {
1096
- this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1464
+ if (pushTokenTrimmed !== undefined) {
1465
+ cfg.pushServiceToken = pushTokenTrimmed || undefined;
1097
1466
  }
1098
- if (body.pushServiceBaseUrl !== undefined) {
1099
- this.pushServiceBaseUrl =
1100
- body.pushServiceBaseUrl.trim() || undefined;
1467
+ if (pushBaseTrimmed !== undefined) {
1468
+ cfg.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1469
+ }
1470
+ if (ntfyTopicTrimmed !== undefined) {
1471
+ cfg.ntfyTopic = ntfyTopicTrimmed || undefined;
1472
+ }
1473
+ if (ntfyServerTrimmed !== undefined) {
1474
+ cfg.ntfyServer = ntfyServerTrimmed || undefined;
1475
+ }
1476
+ // PHASE 3 — disk + secure-store writes. Order: secure store →
1477
+ // bridge driver config → patchwork config. Each rolls back what
1478
+ // it can on later failure (left to PR #02; for now log + 500).
1479
+ if (body.apiKey) {
1480
+ try {
1481
+ saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
1482
+ }
1483
+ catch (writeErr) {
1484
+ // Some Keychain / Secret-Service backends echo the
1485
+ // payload into the error message on certain failure
1486
+ // modes. Cap the logged message at 200 chars AND
1487
+ // strip anything that looks like the leading hex/
1488
+ // base64 of the just-attempted key so a misbehaving
1489
+ // backend can't leak it into bridge logs.
1490
+ const raw = writeErr instanceof Error
1491
+ ? writeErr.message
1492
+ : String(writeErr);
1493
+ const safe = raw
1494
+ .replaceAll(body.apiKey.key, "[redacted]")
1495
+ .slice(0, 200);
1496
+ this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${safe}`);
1497
+ res.writeHead(500, { "Content-Type": "application/json" });
1498
+ res.end(JSON.stringify({ error: "Failed to write provider API key" }));
1499
+ return;
1500
+ }
1501
+ }
1502
+ if (driverRaw !== undefined) {
1503
+ try {
1504
+ saveBridgeConfigDriver(driverRaw, this.bridgeConfigPath);
1505
+ }
1506
+ catch (writeErr) {
1507
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1508
+ res.writeHead(500, { "Content-Type": "application/json" });
1509
+ res.end(JSON.stringify({
1510
+ error: "Failed to write bridge driver config",
1511
+ }));
1512
+ return;
1513
+ }
1101
1514
  }
1515
+ try {
1516
+ savePatchworkConfig(cfg, configPath);
1517
+ }
1518
+ catch (writeErr) {
1519
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1520
+ res.writeHead(500, { "Content-Type": "application/json" });
1521
+ res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
1522
+ return;
1523
+ }
1524
+ // PHASE 4 — live state. Only after every persistence step
1525
+ // succeeded; ensures live state never diverges from disk.
1526
+ if (gateRaw !== undefined) {
1527
+ const prevGate = this.approvalGate;
1528
+ this.approvalGate = gateRaw;
1529
+ // When the operator downgrades to "off", every pending
1530
+ // queue entry becomes moot — the originating tool dispatch
1531
+ // now bypasses, and a phone approver who hits "Approve"
1532
+ // five seconds later would get a 404. Cancel them
1533
+ // server-side so the dashboard /approvals list clears
1534
+ // and any pending phone notification reflects reality.
1535
+ if (gateRaw === "off" && prevGate !== "off") {
1536
+ const cancelled = getApprovalQueue().cancelAll();
1537
+ if (cancelled > 0) {
1538
+ this.logger.info(`[/settings] approvalGate → off; cancelled ${cancelled} pending entr${cancelled === 1 ? "y" : "ies"}`);
1539
+ }
1540
+ }
1541
+ }
1542
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1543
+ this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1544
+ }
1545
+ if (hasWebhookUpdate) {
1546
+ this.approvalWebhookUrl = webhookRaw || undefined;
1547
+ }
1548
+ if (pushUrlTrimmed !== undefined) {
1549
+ this.pushServiceUrl = pushUrlTrimmed || undefined;
1550
+ }
1551
+ if (pushTokenTrimmed !== undefined) {
1552
+ this.pushServiceToken = pushTokenTrimmed || undefined;
1553
+ }
1554
+ if (pushBaseTrimmed !== undefined) {
1555
+ this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1556
+ }
1557
+ if (ntfyTopicTrimmed !== undefined) {
1558
+ this.ntfyTopic = ntfyTopicTrimmed || undefined;
1559
+ }
1560
+ if (ntfyServerTrimmed !== undefined) {
1561
+ this.ntfyServer = ntfyServerTrimmed || undefined;
1562
+ }
1563
+ // restartRequired covers fields the bridge only reads at boot:
1564
+ // driver/model/apiKey (env injection + driver factory), plus
1565
+ // localEndpoint/localModel which LocalApiDriver reads at
1566
+ // construction time. Push/ntfy fields are live-mutated on
1567
+ // `this.*` in PHASE 4 below, so they do not require restart.
1102
1568
  const restartRequired = driverRaw !== undefined ||
1103
1569
  body.apiKey !== undefined ||
1104
- body.model !== undefined;
1570
+ body.model !== undefined ||
1571
+ body.localEndpoint !== undefined ||
1572
+ body.localModel !== undefined;
1573
+ // Audit-log emission — forensic record of every config mutation
1574
+ // so an operator can answer "who changed what, when?" after an
1575
+ // incident. Bearer-token auth doesn't carry an actor identity
1576
+ // (no user JWT) so we attribute to "http" with the request IP.
1577
+ // Secrets are redacted to the shape `"***"` — value presence
1578
+ // is recorded, value content is never logged.
1579
+ if (this.activityLog) {
1580
+ const changes = {};
1581
+ if (hasWebhookUpdate)
1582
+ changes.webhookUrl = webhookRaw || "";
1583
+ if (gateRaw !== undefined)
1584
+ changes.approvalGate = gateRaw;
1585
+ if (body.enableTimeOfDayAnomaly !== undefined)
1586
+ changes.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1587
+ if (driverRaw !== undefined)
1588
+ changes.driver = driverRaw;
1589
+ if (body.model !== undefined)
1590
+ changes.model = body.model;
1591
+ if (body.localEndpoint !== undefined)
1592
+ changes.localEndpoint = body.localEndpoint;
1593
+ if (body.localModel !== undefined)
1594
+ changes.localModel = body.localModel;
1595
+ if (body.apiKey)
1596
+ changes.apiKey = { provider: body.apiKey.provider, key: "***" };
1597
+ if (pushUrlTrimmed !== undefined)
1598
+ changes.pushServiceUrl = pushUrlTrimmed || "";
1599
+ if (pushTokenTrimmed !== undefined)
1600
+ changes.pushServiceToken = pushTokenTrimmed ? "***" : "";
1601
+ if (pushBaseTrimmed !== undefined)
1602
+ changes.pushServiceBaseUrl = pushBaseTrimmed || "";
1603
+ if (ntfyTopicTrimmed !== undefined)
1604
+ changes.ntfyTopic = ntfyTopicTrimmed ? "***" : "";
1605
+ if (ntfyServerTrimmed !== undefined)
1606
+ changes.ntfyServer = ntfyServerTrimmed || "";
1607
+ const remoteAddr = req.headers["x-forwarded-for"]
1608
+ ?.split(",")[0]
1609
+ ?.trim() ||
1610
+ req.socket.remoteAddress ||
1611
+ "unknown";
1612
+ this.activityLog.recordEvent("settings.change", {
1613
+ actor: "http",
1614
+ ip: remoteAddr,
1615
+ fields: Object.keys(changes),
1616
+ changes,
1617
+ });
1618
+ }
1105
1619
  res.writeHead(200, { "Content-Type": "application/json" });
1106
1620
  res.end(JSON.stringify({ ok: true, restartRequired }));
1107
1621
  }
1108
- catch (err) {
1622
+ }
1623
+ catch (err) {
1624
+ // Any error reaching this outer catch is unexpected — all caller
1625
+ // validation errors already returned their own 400 inline, and all
1626
+ // expected disk-write errors return their own 500. So this is a
1627
+ // server bug, not a client one. Returning 400 here misled clients
1628
+ // into believing they had sent a bad body when in fact something
1629
+ // crashed serverside.
1630
+ this.logger.error(`[/config/patchwork] unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1631
+ res.writeHead(500, { "Content-Type": "application/json" });
1632
+ res.end(JSON.stringify({ error: "Internal server error" }));
1633
+ }
1634
+ return;
1635
+ }
1636
+ // /kill-switch — dedicated endpoint for the global write-tier kill switch.
1637
+ // See issue #422 v2: not folded into /settings because kill-switch has
1638
+ // audit + idempotency + env-lock semantics nothing else on /settings has.
1639
+ //
1640
+ // POST {engage: boolean, reason?: string} → toggle; idempotent.
1641
+ // 200 {engaged, changed, locked: false} — accepted
1642
+ // 200 {engaged, changed: false, locked: false} — no-op (already in that state)
1643
+ // 409 {error: "env_locked", flag, frozenValue, lockedReason}
1644
+ // — env-locked, cannot toggle
1645
+ // 400 {error: "invalid_request"} — malformed body
1646
+ //
1647
+ // GET → status. 200 {engaged, locked, lockedReason?, lockedValue?}
1648
+ //
1649
+ // Audit emit: state transitions log to this.logger.info (always)
1650
+ // AND record via this.recordKillSwitchTraceFn (when wired by the
1651
+ // bridge — see src/bridge.ts where it threads decisionTraceLog
1652
+ // through to ~/.patchwork/decision_traces.jsonl). Tests / headless
1653
+ // contexts that don't wire the callback still get the log line.
1654
+ if (parsedUrl.pathname === "/kill-switch") {
1655
+ if (req.method === "GET") {
1656
+ const engaged = isWriteKillSwitchActive();
1657
+ const locked = isEnvLockedFor(KILL_SWITCH_WRITES);
1658
+ const body = { engaged, locked };
1659
+ if (locked) {
1660
+ const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
1661
+ body.lockedValue = lockedValue;
1662
+ body.lockedReason = `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`;
1663
+ }
1664
+ res.writeHead(200, { "Content-Type": "application/json" });
1665
+ res.end(JSON.stringify(body));
1666
+ return;
1667
+ }
1668
+ if (req.method === "POST") {
1669
+ // 1 KB — body is `{engage: bool, reason?: string}`; reason is a
1670
+ // short audit note, 1 KB is generous.
1671
+ const KS_BODY_CAP = 1 * 1024;
1672
+ const parsed = await readJsonBody(req, KS_BODY_CAP);
1673
+ if (!parsed.ok) {
1674
+ if (parsed.code === "too_large") {
1675
+ respond413(res, KS_BODY_CAP);
1676
+ return;
1677
+ }
1678
+ res.writeHead(400, { "Content-Type": "application/json" });
1679
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1680
+ return;
1681
+ }
1682
+ const body = parsed.value ?? {};
1683
+ if (respondIfUnknownBodyKeys(res, body, ["engage", "reason"])) {
1684
+ return;
1685
+ }
1686
+ if (typeof body.engage !== "boolean") {
1109
1687
  res.writeHead(400, { "Content-Type": "application/json" });
1110
1688
  res.end(JSON.stringify({
1111
- error: err instanceof Error ? err.message : String(err),
1689
+ error: "invalid_request",
1690
+ reason: "engage must be boolean",
1112
1691
  }));
1692
+ return;
1113
1693
  }
1114
- });
1115
- return;
1694
+ const reason = typeof body.reason === "string" && body.reason.trim().length > 0
1695
+ ? body.reason.trim().slice(0, 500)
1696
+ : undefined;
1697
+ // v2-B2 + I3: surface env-lock conflict as structured 409 so CLI
1698
+ // + dashboard can distinguish "you sent garbage" from
1699
+ // "policy-locked by sysadmin via PATCHWORK_FLAG_*".
1700
+ if (isEnvLockedFor(KILL_SWITCH_WRITES)) {
1701
+ const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
1702
+ res.writeHead(409, { "Content-Type": "application/json" });
1703
+ res.end(JSON.stringify({
1704
+ error: "env_locked",
1705
+ flag: KILL_SWITCH_WRITES,
1706
+ frozenValue: lockedValue,
1707
+ lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`,
1708
+ }));
1709
+ return;
1710
+ }
1711
+ // v2-I12: idempotent. State transitions emit audit; no-ops don't.
1712
+ const prev = isWriteKillSwitchActive();
1713
+ const next = body.engage;
1714
+ const changed = prev !== next;
1715
+ if (changed) {
1716
+ try {
1717
+ setFlag(KILL_SWITCH_WRITES, next, true);
1718
+ }
1719
+ catch (err) {
1720
+ // Belt-and-suspenders: setFlag now throws EnvLockedFlagError if
1721
+ // the flag was env-locked (we already checked isEnvLockedFor above,
1722
+ // but a race with lockKillSwitchEnv() in tests warrants this).
1723
+ if (err instanceof EnvLockedFlagError) {
1724
+ res.writeHead(409, { "Content-Type": "application/json" });
1725
+ res.end(JSON.stringify({
1726
+ error: "env_locked",
1727
+ flag: KILL_SWITCH_WRITES,
1728
+ frozenValue: err.frozenValue,
1729
+ lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${err.frozenValue ? "1" : "0"} at bridge startup`,
1730
+ }));
1731
+ return;
1732
+ }
1733
+ throw err;
1734
+ }
1735
+ // v2-I6: audit emit on every state transition; no-ops skip.
1736
+ // The bridge wires recordKillSwitchTraceFn to write a
1737
+ // DecisionTrace entry to ~/.patchwork/decision_traces.jsonl
1738
+ // (see src/bridge.ts). The logger.info line stays as a
1739
+ // secondary signal in the bridge log and is the only output
1740
+ // when the trace fn is unset (tests, headless contexts).
1741
+ this.logger.info(`[kill-switch] ${next ? "ENGAGED" : "RELEASED"}${reason ? ` (reason: ${reason})` : ""} — actor=http`);
1742
+ this.recordKillSwitchTraceFn?.({
1743
+ engaged: next,
1744
+ reason,
1745
+ ts: Date.now(),
1746
+ });
1747
+ // v2-I8: broadcast SSE kind:"kill-switch" so dashboard updates
1748
+ // in <1s without changing the poll cadence.
1749
+ this.broadcastKillSwitchEventFn?.(next, reason);
1750
+ }
1751
+ res.writeHead(200, { "Content-Type": "application/json" });
1752
+ res.end(JSON.stringify({
1753
+ engaged: next,
1754
+ changed,
1755
+ locked: false,
1756
+ }));
1757
+ return;
1758
+ }
1116
1759
  }
1117
- // CC hook notify endpoint lightweight alternative to full MCP session for hook wiring.
1118
- if (parsedUrl.pathname === "/notify" && req.method === "POST") {
1119
- const chunks = [];
1120
- req.on("data", (c) => chunks.push(c));
1121
- req.on("end", () => {
1760
+ // /feature-flagslist + toggle USER-OPT-IN UI flags only.
1761
+ //
1762
+ // Deliberately scoped: this endpoint can ONLY flip flags where
1763
+ // category === "ui" && requiresOptIn === true && !isKillSwitch
1764
+ // so it can never be used to disable a safety kill-switch (those go
1765
+ // through the audited /kill-switch route) or enable an experimental
1766
+ // flag. The motivating use is the recipe-editor "Enable & retry"
1767
+ // affordance for `recipe.repair-ai` (default-off, opt-in, costs API
1768
+ // tokens — see featureFlags.ts FLAG_REPAIR_AI).
1769
+ //
1770
+ // POST {id: string, value: boolean}
1771
+ // 200 {id, value, changed} — accepted
1772
+ // 400 {error: "invalid_request"} — malformed body
1773
+ // 404 {error: "unknown_flag"} — not registered
1774
+ // 403 {error: "not_user_toggleable"} — flag not in the opt-in/ui set
1775
+ // 409 {error: "env_override", envVar} — PATCHWORK_FLAG_* pins the
1776
+ // value; the write persisted to config but isEnabled() still
1777
+ // resolves to the env value, so the toggle won't take effect.
1778
+ if (parsedUrl.pathname === "/feature-flags") {
1779
+ const isUserToggleable = (f) => f.category === "ui" && f.requiresOptIn && f.isKillSwitch !== true;
1780
+ if (req.method === "POST") {
1781
+ const FF_BODY_CAP = 1 * 1024;
1782
+ const parsed = await readJsonBody(req, FF_BODY_CAP);
1783
+ if (!parsed.ok) {
1784
+ if (parsed.code === "too_large") {
1785
+ respond413(res, FF_BODY_CAP);
1786
+ return;
1787
+ }
1788
+ res.writeHead(400, { "Content-Type": "application/json" });
1789
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1790
+ return;
1791
+ }
1792
+ const body = parsed.value ?? {};
1793
+ if (respondIfUnknownBodyKeys(res, body, ["id", "value"])) {
1794
+ return;
1795
+ }
1796
+ if (typeof body.id !== "string" || typeof body.value !== "boolean") {
1797
+ res.writeHead(400, { "Content-Type": "application/json" });
1798
+ res.end(JSON.stringify({
1799
+ error: "invalid_request",
1800
+ reason: "id must be string and value must be boolean",
1801
+ }));
1802
+ return;
1803
+ }
1804
+ const flagId = body.id;
1805
+ const flag = listFlags().find((f) => f.id === flagId);
1806
+ if (!flag) {
1807
+ res.writeHead(404, { "Content-Type": "application/json" });
1808
+ res.end(JSON.stringify({ error: "unknown_flag", id: flagId }));
1809
+ return;
1810
+ }
1811
+ if (!isUserToggleable(flag)) {
1812
+ res.writeHead(403, { "Content-Type": "application/json" });
1813
+ res.end(JSON.stringify({ error: "not_user_toggleable", id: flagId }));
1814
+ return;
1815
+ }
1816
+ const prev = isEnabled(flagId);
1817
+ setFlag(flagId, body.value, true);
1818
+ // Non-kill-switch flags read PATCHWORK_FLAG_* dynamically with
1819
+ // precedence over the config we just wrote. If a sysadmin pinned
1820
+ // the flag via env, the write won't take effect — surface that as
1821
+ // a 409 instead of a misleading 200 (the editor would otherwise
1822
+ // "Enable & retry" straight into another 503).
1823
+ const effective = isEnabled(flagId);
1824
+ if (effective !== body.value) {
1825
+ const envVar = `PATCHWORK_FLAG_${flagId
1826
+ .replace(/[.-]/g, "_")
1827
+ .toUpperCase()}`;
1828
+ res.writeHead(409, { "Content-Type": "application/json" });
1829
+ res.end(JSON.stringify({
1830
+ error: "env_override",
1831
+ id: flagId,
1832
+ envVar,
1833
+ effectiveValue: effective,
1834
+ }));
1835
+ return;
1836
+ }
1837
+ this.logger.info(`[feature-flags] ${flagId} = ${body.value} — actor=http`);
1838
+ res.writeHead(200, { "Content-Type": "application/json" });
1839
+ res.end(JSON.stringify({
1840
+ id: flagId,
1841
+ value: body.value,
1842
+ changed: prev !== body.value,
1843
+ }));
1844
+ return;
1845
+ }
1846
+ }
1847
+ // /telemetry-prefs — read/write per-flag telemetry preferences.
1848
+ // GET → {crashReports, usageStats, localDiagnostics, endpoint, endpointSource}
1849
+ // POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
1850
+ // DELETE → clears prefs file + analytics-config + install salt
1851
+ // (right-to-erasure affordance).
1852
+ if (parsedUrl.pathname === "/telemetry-prefs") {
1853
+ if (req.method === "GET") {
1854
+ const prefs = getTelemetryPrefs();
1855
+ const all = getAnalyticsPrefsAll();
1856
+ const response = { ...prefs };
1857
+ if (all?.lastSentAt !== undefined) {
1858
+ response.lastSentAt = all.lastSentAt;
1859
+ }
1860
+ // Destination visibility — consent baseline. Caller can see
1861
+ // where their data would go and whether the destination came
1862
+ // from env (CI/headless) or the on-disk config file.
1863
+ const envEndpoint = process.env.PATCHWORK_ANALYTICS_ENDPOINT;
1864
+ const cfgEndpoint = getAnalyticsConfig().endpoint;
1865
+ const defaultEndpoint = "https://analytics.claude-ide-bridge.dev/v1/usage";
1866
+ if (envEndpoint) {
1867
+ response.endpoint = envEndpoint;
1868
+ response.endpointSource = "env";
1869
+ }
1870
+ else if (cfgEndpoint) {
1871
+ response.endpoint = cfgEndpoint;
1872
+ response.endpointSource = "config";
1873
+ }
1874
+ else {
1875
+ response.endpoint = defaultEndpoint;
1876
+ response.endpointSource = "default";
1877
+ }
1878
+ res.writeHead(200, { "Content-Type": "application/json" });
1879
+ res.end(JSON.stringify(response));
1880
+ return;
1881
+ }
1882
+ if (req.method === "DELETE") {
1883
+ // Right-to-erasure: drop prefs, analytics-config (endpoint /
1884
+ // shared secret), and the install salt. Next outbound send
1885
+ // (if any) will mint a fresh salt and treat this as a new
1886
+ // install. Kill-switch is honored here too — incident-mode
1887
+ // operators may still want to scrub local state, so allow
1888
+ // it; flip if the threat model changes.
1122
1889
  try {
1123
- const body = Buffer.concat(chunks).toString("utf-8");
1124
- const parsed = JSON.parse(body);
1125
- const event = parsed.event ?? "";
1126
- const args = parsed.args ?? {};
1127
- if (!this.notifyFn) {
1128
- res.writeHead(503, { "Content-Type": "application/json" });
1890
+ unlinkSync(getAnalyticsPrefsPath());
1891
+ }
1892
+ catch {
1893
+ /* missing is fine */
1894
+ }
1895
+ try {
1896
+ unlinkSync(getAnalyticsSaltPath());
1897
+ }
1898
+ catch {
1899
+ /* missing is fine */
1900
+ }
1901
+ clearAnalyticsConfig();
1902
+ if (this.activityLog) {
1903
+ this.activityLog.recordEvent("telemetry.reset", {
1904
+ actor: "http",
1905
+ ip: req.headers["x-forwarded-for"]
1906
+ ?.split(",")[0]
1907
+ ?.trim() ||
1908
+ req.socket.remoteAddress ||
1909
+ "unknown",
1910
+ });
1911
+ }
1912
+ res.writeHead(200, { "Content-Type": "application/json" });
1913
+ res.end(JSON.stringify({ ok: true }));
1914
+ return;
1915
+ }
1916
+ if (req.method === "POST") {
1917
+ // Kill-switch gate — telemetry prefs are config writes too.
1918
+ // GET stays open so operators can verify state during an
1919
+ // incident.
1920
+ if (isWriteKillSwitchActive()) {
1921
+ res.writeHead(423, { "Content-Type": "application/json" });
1922
+ res.end(JSON.stringify({
1923
+ error: "kill_switch_blocked",
1924
+ reason: "Telemetry pref writes are disabled while the write kill-switch is engaged.",
1925
+ }));
1926
+ return;
1927
+ }
1928
+ const TP_BODY_CAP = 1 * 1024;
1929
+ const parsed = await readJsonBody(req, TP_BODY_CAP);
1930
+ if (!parsed.ok) {
1931
+ if (parsed.code === "too_large") {
1932
+ respond413(res, TP_BODY_CAP);
1933
+ return;
1934
+ }
1935
+ res.writeHead(400, { "Content-Type": "application/json" });
1936
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1937
+ return;
1938
+ }
1939
+ const body = parsed.value ?? {};
1940
+ if (respondIfUnknownBodyKeys(res, body, [
1941
+ "crashReports",
1942
+ "usageStats",
1943
+ "localDiagnostics",
1944
+ ])) {
1945
+ return;
1946
+ }
1947
+ const update = {};
1948
+ // Reject non-boolean values for known keys instead of silently
1949
+ // dropping them. Previously `{"crashReports": "true"}` (string)
1950
+ // got a 200 with no effect — caller never learned the toggle
1951
+ // did nothing. Misrepresented consent is the worst-case
1952
+ // outcome on the telemetry surface, so be strict here.
1953
+ for (const key of [
1954
+ "crashReports",
1955
+ "usageStats",
1956
+ "localDiagnostics",
1957
+ ]) {
1958
+ if (body[key] !== undefined && typeof body[key] !== "boolean") {
1959
+ res.writeHead(400, { "Content-Type": "application/json" });
1129
1960
  res.end(JSON.stringify({
1130
- ok: false,
1131
- error: "Automation not enabled",
1961
+ error: "invalid_request",
1962
+ reason: `${key} must be a boolean`,
1132
1963
  }));
1133
1964
  return;
1134
1965
  }
1135
- const result = this.notifyFn(event, args);
1136
- res.writeHead(result.ok ? 200 : 400, {
1137
- "Content-Type": "application/json",
1138
- });
1139
- res.end(JSON.stringify(result));
1140
1966
  }
1141
- catch {
1142
- res.writeHead(400, { "Content-Type": "application/json" });
1143
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1967
+ if (typeof body.crashReports === "boolean") {
1968
+ update.crashReports = body.crashReports;
1969
+ }
1970
+ if (typeof body.usageStats === "boolean") {
1971
+ update.usageStats = body.usageStats;
1972
+ }
1973
+ if (typeof body.localDiagnostics === "boolean") {
1974
+ update.localDiagnostics = body.localDiagnostics;
1975
+ }
1976
+ setTelemetryPrefs(update);
1977
+ const prefs = getTelemetryPrefs();
1978
+ res.writeHead(200, { "Content-Type": "application/json" });
1979
+ res.end(JSON.stringify(prefs));
1980
+ return;
1981
+ }
1982
+ }
1983
+ // /restart — graceful bridge restart endpoint.
1984
+ // POST → triggers SIGTERM if no active work; returns 409 if busy.
1985
+ // Safety checks: rejects restart if sessions have in-flight tool calls.
1986
+ if (parsedUrl.pathname === "/restart" && req.method === "POST") {
1987
+ if (!this.restartCheckFn) {
1988
+ res.writeHead(503, { "Content-Type": "application/json" });
1989
+ res.end(JSON.stringify({
1990
+ ok: false,
1991
+ error: "restart_unavailable",
1992
+ reason: "Restart endpoint not configured",
1993
+ }));
1994
+ return;
1995
+ }
1996
+ const check = this.restartCheckFn();
1997
+ // Reject restart if there's active work
1998
+ if (check.inFlightCalls > 0) {
1999
+ this.logger.warn(`[/restart] Rejected — ${check.inFlightCalls} in-flight tool call${check.inFlightCalls === 1 ? "" : "s"} across ${check.busySessions.length} session${check.busySessions.length === 1 ? "" : "s"}`);
2000
+ res.writeHead(409, { "Content-Type": "application/json" });
2001
+ res.end(JSON.stringify({
2002
+ ok: false,
2003
+ error: "restart_blocked",
2004
+ reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
2005
+ activeSessions: check.totalSessions,
2006
+ inFlightCalls: check.inFlightCalls,
2007
+ busySessions: check.busySessions,
2008
+ }));
2009
+ return;
2010
+ }
2011
+ // Safe to restart — log and trigger SIGTERM
2012
+ this.logger.info(`[/restart] Initiating graceful restart — ${check.totalSessions} session${check.totalSessions === 1 ? "" : "s"}, 0 in-flight calls`);
2013
+ res.writeHead(202, { "Content-Type": "application/json" });
2014
+ res.end(JSON.stringify({
2015
+ ok: true,
2016
+ message: "Restart initiated. Bridge will shut down gracefully.",
2017
+ activeSessions: check.totalSessions,
2018
+ }));
2019
+ // Trigger shutdown after response is sent (100ms delay to ensure response delivery).
2020
+ // Uses this.restartKillFn so tests can override without killing the runner.
2021
+ const killFn = this.restartKillFn;
2022
+ setTimeout(() => {
2023
+ this.logger.info("[/restart] Sending SIGTERM to self");
2024
+ killFn();
2025
+ }, 100);
2026
+ return;
2027
+ }
2028
+ // /shutdown — graceful exit endpoint. POST → triggers the bridge's
2029
+ // internal shutdown sequence (lockfile unlink, HTTP close, telemetry
2030
+ // flush). Same in-flight safety check as /restart; pass `?force=1` to
2031
+ // skip it. Necessary on Windows where `process.kill(pid, 'SIGTERM')`
2032
+ // is TerminateProcess and cleanup handlers never fire — the bridge
2033
+ // wires `shutdownFn` to call the shutdown sequence directly.
2034
+ if (parsedUrl.pathname === "/shutdown" && req.method === "POST") {
2035
+ const force = parsedUrl.searchParams.get("force") === "1";
2036
+ if (!force && this.restartCheckFn) {
2037
+ const check = this.restartCheckFn();
2038
+ if (check.inFlightCalls > 0) {
2039
+ this.logger.warn(`[/shutdown] Rejected — ${check.inFlightCalls} in-flight tool call${check.inFlightCalls === 1 ? "" : "s"}`);
2040
+ res.writeHead(409, { "Content-Type": "application/json" });
2041
+ res.end(JSON.stringify({
2042
+ ok: false,
2043
+ error: "shutdown_blocked",
2044
+ reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
2045
+ inFlightCalls: check.inFlightCalls,
2046
+ busySessions: check.busySessions,
2047
+ }));
2048
+ return;
1144
2049
  }
2050
+ }
2051
+ this.logger.info("[/shutdown] Initiating graceful shutdown");
2052
+ res.writeHead(202, { "Content-Type": "application/json" });
2053
+ res.end(JSON.stringify({
2054
+ ok: true,
2055
+ message: "Shutdown initiated.",
2056
+ }));
2057
+ const shutdownFn = this.shutdownFn;
2058
+ setTimeout(() => {
2059
+ this.logger.info("[/shutdown] Calling bridge shutdown sequence");
2060
+ shutdownFn();
2061
+ }, 100);
2062
+ return;
2063
+ }
2064
+ // CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
2065
+ if (parsedUrl.pathname === "/notify" && req.method === "POST") {
2066
+ // 8 KB — notify payloads carry an event name + small arg map
2067
+ // (taskId, prompt, tool name). 8 KB fits everything we send today
2068
+ // with headroom.
2069
+ const NOTIFY_BODY_CAP = 8 * 1024;
2070
+ const parsed = await readJsonBody(req, NOTIFY_BODY_CAP);
2071
+ if (!parsed.ok) {
2072
+ if (parsed.code === "too_large") {
2073
+ respond413(res, NOTIFY_BODY_CAP);
2074
+ return;
2075
+ }
2076
+ res.writeHead(400, { "Content-Type": "application/json" });
2077
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
2078
+ return;
2079
+ }
2080
+ const event = parsed.value?.event ?? "";
2081
+ const args = parsed.value?.args ?? {};
2082
+ if (!this.notifyFn) {
2083
+ res.writeHead(503, { "Content-Type": "application/json" });
2084
+ res.end(JSON.stringify({
2085
+ ok: false,
2086
+ error: "Automation not enabled",
2087
+ }));
2088
+ return;
2089
+ }
2090
+ const result = this.notifyFn(event, args);
2091
+ res.writeHead(result.ok ? 200 : 400, {
2092
+ "Content-Type": "application/json",
1145
2093
  });
2094
+ res.end(JSON.stringify(result));
1146
2095
  return;
1147
2096
  }
1148
2097
  // Single-approval detail lookup for the dashboard detail page.
@@ -1160,10 +2109,7 @@ export class Server extends EventEmitter {
1160
2109
  res.end(JSON.stringify(data));
1161
2110
  }
1162
2111
  catch (err) {
1163
- res.writeHead(500, { "Content-Type": "application/json" });
1164
- res.end(JSON.stringify({
1165
- error: err instanceof Error ? err.message : String(err),
1166
- }));
2112
+ respond500(res, err);
1167
2113
  }
1168
2114
  return;
1169
2115
  }
@@ -1177,101 +2123,103 @@ export class Server extends EventEmitter {
1177
2123
  if (parsedUrl.pathname === "/approvals" ||
1178
2124
  parsedUrl.pathname === "/cc-permissions" ||
1179
2125
  /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname)) {
1180
- const chunks = [];
1181
- req.on("data", (c) => chunks.push(c));
1182
- req.on("end", async () => {
1183
- let parsedBody;
1184
- if (chunks.length > 0) {
1185
- try {
1186
- parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1187
- }
1188
- catch {
1189
- res.writeHead(400, { "Content-Type": "application/json" });
1190
- res.end(JSON.stringify({ error: "invalid JSON body" }));
1191
- return;
1192
- }
1193
- }
1194
- try {
1195
- const result = await routeApprovalRequest({
1196
- method: req.method ?? "GET",
1197
- path: parsedUrl.pathname,
1198
- body: parsedBody,
1199
- query: parsedUrl.searchParams,
1200
- approvalToken: req.headers["x-approval-token"],
1201
- }, {
1202
- queue: getApprovalQueue(),
1203
- workspace: process.cwd(),
1204
- managedSettingsPath: this.managedSettingsPath,
1205
- onDecision: this.onApprovalDecision,
1206
- webhookUrl: this.approvalWebhookUrl,
1207
- approvalGate: this.approvalGate,
1208
- pushServiceUrl: this.pushServiceUrl,
1209
- pushServiceToken: this.pushServiceToken,
1210
- pushServiceBaseUrl: this.pushServiceBaseUrl,
1211
- activityLog: this.activityLog,
1212
- // RecipeRunLog satisfies RecipeRunQuerier structurally
1213
- // — the cast bridges TS contravariance: RecipeRunQuerier's
1214
- // narrow query interface (`status?: string`) is deliberately
1215
- // loose so tests can mock it; RecipeRunLog's stricter
1216
- // RunStatus union is a strict subset and fails the param
1217
- // contravariance check despite being safe at runtime.
1218
- recipeRunLog: this.recipeRunLog,
1219
- enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
1220
- });
1221
- res.writeHead(result.status, {
1222
- "Content-Type": "application/json",
1223
- });
1224
- res.end(JSON.stringify(result.body));
1225
- }
1226
- catch (err) {
1227
- res.writeHead(500, { "Content-Type": "application/json" });
1228
- res.end(JSON.stringify({
1229
- error: err instanceof Error ? err.message : String(err),
1230
- }));
2126
+ // 32 KB — approvals carry decision + reason + optional permission
2127
+ // rule patches; 32 KB matches RECIPE_ROUTE_BODY_CAPS.run. Critical
2128
+ // here: /approve/:id and /reject/:id are reachable via the
2129
+ // x-approval-token phone-path bypass at line 609-612 — without a
2130
+ // cap an unbounded body read happens *before* token validation.
2131
+ const APPROVALS_BODY_CAP = 32 * 1024;
2132
+ const parsed = await readJsonBody(req, APPROVALS_BODY_CAP);
2133
+ if (!parsed.ok) {
2134
+ if (parsed.code === "too_large") {
2135
+ respond413(res, APPROVALS_BODY_CAP);
2136
+ return;
1231
2137
  }
1232
- });
2138
+ res.writeHead(400, { "Content-Type": "application/json" });
2139
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
2140
+ return;
2141
+ }
2142
+ const parsedBody = parsed.value;
2143
+ try {
2144
+ const result = await routeApprovalRequest({
2145
+ method: req.method ?? "GET",
2146
+ path: parsedUrl.pathname,
2147
+ body: parsedBody,
2148
+ query: parsedUrl.searchParams,
2149
+ approvalToken: req.headers["x-approval-token"] ??
2150
+ parsedUrl.searchParams.get("token") ??
2151
+ undefined,
2152
+ }, {
2153
+ queue: getApprovalQueue(),
2154
+ workspace: process.cwd(),
2155
+ managedSettingsPath: this.managedSettingsPath,
2156
+ onDecision: this.onApprovalDecision,
2157
+ webhookUrl: this.approvalWebhookUrl,
2158
+ approvalGate: this.approvalGate,
2159
+ pushServiceUrl: this.pushServiceUrl,
2160
+ pushServiceToken: this.pushServiceToken,
2161
+ pushServiceBaseUrl: this.pushServiceBaseUrl,
2162
+ ntfyTopic: this.ntfyTopic,
2163
+ ntfyServer: this.ntfyServer,
2164
+ activityLog: this.activityLog,
2165
+ // RecipeRunLog satisfies RecipeRunQuerier structurally
2166
+ // — the cast bridges TS contravariance: RecipeRunQuerier's
2167
+ // narrow query interface (`status?: string`) is deliberately
2168
+ // loose so tests can mock it; RecipeRunLog's stricter
2169
+ // RunStatus union is a strict subset and fails the param
2170
+ // contravariance check despite being safe at runtime.
2171
+ recipeRunLog: this.recipeRunLog,
2172
+ enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
2173
+ });
2174
+ res.writeHead(result.status, {
2175
+ "Content-Type": "application/json",
2176
+ });
2177
+ res.end(JSON.stringify(result.body));
2178
+ }
2179
+ catch (err) {
2180
+ respond500(res, err);
2181
+ }
1233
2182
  return;
1234
2183
  }
1235
2184
  // Quick-task launch endpoint — mirrors /notify pattern. Bearer auth already checked above.
1236
2185
  if (parsedUrl.pathname === "/launch-quick-task" &&
1237
2186
  req.method === "POST") {
1238
- const chunks = [];
1239
- req.on("data", (c) => chunks.push(c));
1240
- req.on("end", () => {
1241
- void (async () => {
1242
- try {
1243
- const body = Buffer.concat(chunks).toString("utf-8");
1244
- const parsed = JSON.parse(body);
1245
- const presetId = parsed.presetId;
1246
- const source = parsed.source ?? "cli";
1247
- if (typeof presetId !== "string" || !presetId) {
1248
- res.writeHead(400, { "Content-Type": "application/json" });
1249
- res.end(JSON.stringify({
1250
- ok: false,
1251
- error: "presetId required",
1252
- }));
1253
- return;
1254
- }
1255
- if (!this.launchQuickTaskFn) {
1256
- res.writeHead(503, { "Content-Type": "application/json" });
1257
- res.end(JSON.stringify({
1258
- ok: false,
1259
- error: "Quick tasks unavailable — requires --claude-driver subprocess",
1260
- }));
1261
- return;
1262
- }
1263
- const result = await this.launchQuickTaskFn(presetId, source);
1264
- res.writeHead(result.ok ? 200 : 429, {
1265
- "Content-Type": "application/json",
1266
- });
1267
- res.end(JSON.stringify(result));
1268
- }
1269
- catch {
1270
- res.writeHead(400, { "Content-Type": "application/json" });
1271
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1272
- }
1273
- })();
2187
+ // 4 KB — body is `{ presetId?: string; source?: string }`, two
2188
+ // short strings. 4 KB is plenty.
2189
+ const QUICK_TASK_BODY_CAP = 4 * 1024;
2190
+ const parsed = await readJsonBody(req, QUICK_TASK_BODY_CAP);
2191
+ if (!parsed.ok) {
2192
+ if (parsed.code === "too_large") {
2193
+ respond413(res, QUICK_TASK_BODY_CAP);
2194
+ return;
2195
+ }
2196
+ res.writeHead(400, { "Content-Type": "application/json" });
2197
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
2198
+ return;
2199
+ }
2200
+ const presetId = parsed.value?.presetId;
2201
+ const source = parsed.value?.source ?? "cli";
2202
+ if (typeof presetId !== "string" || !presetId) {
2203
+ res.writeHead(400, { "Content-Type": "application/json" });
2204
+ res.end(JSON.stringify({
2205
+ ok: false,
2206
+ error: "presetId required",
2207
+ }));
2208
+ return;
2209
+ }
2210
+ if (!this.launchQuickTaskFn) {
2211
+ res.writeHead(503, { "Content-Type": "application/json" });
2212
+ res.end(JSON.stringify({
2213
+ ok: false,
2214
+ error: "Quick tasks unavailable — requires --driver subprocess",
2215
+ }));
2216
+ return;
2217
+ }
2218
+ const result = await this.launchQuickTaskFn(presetId, source);
2219
+ res.writeHead(result.ok ? 200 : 429, {
2220
+ "Content-Type": "application/json",
1274
2221
  });
2222
+ res.end(JSON.stringify(result));
1275
2223
  return;
1276
2224
  }
1277
2225
  // MCP Streamable HTTP transport — POST/GET/DELETE /mcp.
@@ -1283,11 +2231,9 @@ export class Server extends EventEmitter {
1283
2231
  req.method === "GET" ||
1284
2232
  req.method === "DELETE") {
1285
2233
  this.httpMcpHandler(req, res).catch((err) => {
1286
- this.logger.error(`HTTP MCP handler error: ${err instanceof Error ? err.message : String(err)}`);
1287
- if (!res.headersSent) {
1288
- res.writeHead(500, { "Content-Type": "application/json" });
1289
- res.end(JSON.stringify({ error: String(err) }));
1290
- }
2234
+ // respond500 logs the underlying error detail server-side; no
2235
+ // need to also funnel it through this.logger.
2236
+ respond500(res, err, "/mcp HTTP handler");
1291
2237
  });
1292
2238
  return;
1293
2239
  }
@@ -1420,6 +2366,41 @@ export class Server extends EventEmitter {
1420
2366
  this.emit("connection", ws);
1421
2367
  });
1422
2368
  }
2369
+ /**
2370
+ * Resolve the client IP for rate-limit bucketing on the phone-path
2371
+ * approval bypass. Default: the direct socket peer. When trustedProxies
2372
+ * is set AND the socket peer is one of them, the rightmost X-Forwarded-For
2373
+ * entry not in the trusted list is the real client. XFF from an untrusted
2374
+ * peer is ignored — that header is spoofable by anyone who can reach the
2375
+ * server directly. Returns null when no IP can be determined; the caller
2376
+ * should fail closed.
2377
+ */
2378
+ getClientIp(req) {
2379
+ const socketIp = req.socket?.remoteAddress;
2380
+ if (socketIp &&
2381
+ this.trustedProxies.length > 0 &&
2382
+ this.trustedProxies.includes(socketIp)) {
2383
+ const xffRaw = req.headers["x-forwarded-for"];
2384
+ const xffStr = Array.isArray(xffRaw) ? xffRaw[0] : xffRaw;
2385
+ if (typeof xffStr === "string" && xffStr.length > 0) {
2386
+ const hops = xffStr
2387
+ .split(",")
2388
+ .map((s) => s.trim())
2389
+ .filter((s) => s.length > 0);
2390
+ // Walk right→left (proxies append to the right). The rightmost hop
2391
+ // we DON'T trust is the client; everything to its right is our own
2392
+ // hop chain.
2393
+ for (let i = hops.length - 1; i >= 0; i--) {
2394
+ const hop = hops[i];
2395
+ if (hop !== undefined && !this.trustedProxies.includes(hop)) {
2396
+ return hop.slice(0, 64);
2397
+ }
2398
+ }
2399
+ // Edge case: every hop is in trustedProxies — unusual; fall through.
2400
+ }
2401
+ }
2402
+ return socketIp ? socketIp.slice(0, 64) : null;
2403
+ }
1423
2404
  async listen(port, bindAddress = "127.0.0.1") {
1424
2405
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
1425
2406
  if (!LOOPBACK.has(bindAddress)) {