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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (652) hide show
  1. package/README.bridge.md +14 -14
  2. package/README.md +201 -34
  3. package/deploy/README.md +1 -1
  4. package/deploy/bootstrap-vps.sh +14 -9
  5. package/deploy/deploy-dashboard.sh +25 -1
  6. package/deploy/macos/README.md +153 -0
  7. package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
  8. package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
  9. package/deploy/macos/install-mac-bridge.sh +244 -0
  10. package/deploy/macos/uninstall-mac-bridge.sh +22 -0
  11. package/dist/activityLog.d.ts +6 -0
  12. package/dist/activityLog.js +63 -26
  13. package/dist/activityLog.js.map +1 -1
  14. package/dist/adapters/claude.js +22 -16
  15. package/dist/adapters/claude.js.map +1 -1
  16. package/dist/adapters/gemini.js +15 -11
  17. package/dist/adapters/gemini.js.map +1 -1
  18. package/dist/adapters/openai.js +9 -9
  19. package/dist/adapters/openai.js.map +1 -1
  20. package/dist/ajv2020.d.ts +25 -0
  21. package/dist/ajv2020.js +33 -0
  22. package/dist/ajv2020.js.map +1 -0
  23. package/dist/analyticsAggregator.js +1 -1
  24. package/dist/analyticsAggregator.js.map +1 -1
  25. package/dist/analyticsConfig.d.ts +29 -0
  26. package/dist/analyticsConfig.js +102 -0
  27. package/dist/analyticsConfig.js.map +1 -0
  28. package/dist/analyticsPrefs.d.ts +38 -2
  29. package/dist/analyticsPrefs.js +148 -23
  30. package/dist/analyticsPrefs.js.map +1 -1
  31. package/dist/analyticsSend.d.ts +17 -1
  32. package/dist/analyticsSend.js +67 -5
  33. package/dist/analyticsSend.js.map +1 -1
  34. package/dist/approvalHttp.d.ts +14 -0
  35. package/dist/approvalHttp.js +212 -9
  36. package/dist/approvalHttp.js.map +1 -1
  37. package/dist/approvalInsights.js +13 -5
  38. package/dist/approvalInsights.js.map +1 -1
  39. package/dist/approvalQueue.d.ts +97 -3
  40. package/dist/approvalQueue.js +193 -19
  41. package/dist/approvalQueue.js.map +1 -1
  42. package/dist/approvalSignals.js +19 -11
  43. package/dist/approvalSignals.js.map +1 -1
  44. package/dist/automation.d.ts +35 -10
  45. package/dist/automation.js +133 -37
  46. package/dist/automation.js.map +1 -1
  47. package/dist/automationSuggestions.js +10 -8
  48. package/dist/automationSuggestions.js.map +1 -1
  49. package/dist/bridge.d.ts +2 -0
  50. package/dist/bridge.js +202 -25
  51. package/dist/bridge.js.map +1 -1
  52. package/dist/bridgeLockDiscovery.d.ts +27 -1
  53. package/dist/bridgeLockDiscovery.js +38 -11
  54. package/dist/bridgeLockDiscovery.js.map +1 -1
  55. package/dist/bridgeToken.js +3 -2
  56. package/dist/bridgeToken.js.map +1 -1
  57. package/dist/claudeDriver.js +23 -8
  58. package/dist/claudeDriver.js.map +1 -1
  59. package/dist/claudeOrchestrator.d.ts +1 -1
  60. package/dist/claudeOrchestrator.js +87 -44
  61. package/dist/claudeOrchestrator.js.map +1 -1
  62. package/dist/commands/analytics.d.ts +8 -0
  63. package/dist/commands/analytics.js +134 -0
  64. package/dist/commands/analytics.js.map +1 -0
  65. package/dist/commands/auditEnv.d.ts +36 -0
  66. package/dist/commands/auditEnv.js +202 -0
  67. package/dist/commands/auditEnv.js.map +1 -0
  68. package/dist/commands/dashboard.js +8 -1
  69. package/dist/commands/dashboard.js.map +1 -1
  70. package/dist/commands/doctor.d.ts +35 -0
  71. package/dist/commands/doctor.js +51 -0
  72. package/dist/commands/doctor.js.map +1 -0
  73. package/dist/commands/install.js +3 -0
  74. package/dist/commands/install.js.map +1 -1
  75. package/dist/commands/launchd.js +15 -16
  76. package/dist/commands/launchd.js.map +1 -1
  77. package/dist/commands/patchworkInit.d.ts +5 -0
  78. package/dist/commands/patchworkInit.js +89 -7
  79. package/dist/commands/patchworkInit.js.map +1 -1
  80. package/dist/commands/recipe.d.ts +110 -0
  81. package/dist/commands/recipe.js +512 -9
  82. package/dist/commands/recipe.js.map +1 -1
  83. package/dist/commands/recipeInstall.js +11 -4
  84. package/dist/commands/recipeInstall.js.map +1 -1
  85. package/dist/commands/shadowScan.d.ts +34 -0
  86. package/dist/commands/shadowScan.js +142 -0
  87. package/dist/commands/shadowScan.js.map +1 -0
  88. package/dist/commands/task.js +2 -2
  89. package/dist/commands/task.js.map +1 -1
  90. package/dist/commands/tools.d.ts +20 -1
  91. package/dist/commands/tools.js +112 -3
  92. package/dist/commands/tools.js.map +1 -1
  93. package/dist/commands/tracesImport.js +21 -4
  94. package/dist/commands/tracesImport.js.map +1 -1
  95. package/dist/commitIssueLinkLog.d.ts +16 -0
  96. package/dist/commitIssueLinkLog.js +110 -24
  97. package/dist/commitIssueLinkLog.js.map +1 -1
  98. package/dist/companions/registry.js +15 -7
  99. package/dist/companions/registry.js.map +1 -1
  100. package/dist/config.d.ts +47 -3
  101. package/dist/config.js +111 -21
  102. package/dist/config.js.map +1 -1
  103. package/dist/connectorRoutes.js +866 -57
  104. package/dist/connectorRoutes.js.map +1 -1
  105. package/dist/connectors/airtable.d.ts +230 -0
  106. package/dist/connectors/airtable.js +700 -0
  107. package/dist/connectors/airtable.js.map +1 -0
  108. package/dist/connectors/asana.js +13 -9
  109. package/dist/connectors/asana.js.map +1 -1
  110. package/dist/connectors/baseConnector.js +25 -3
  111. package/dist/connectors/baseConnector.js.map +1 -1
  112. package/dist/connectors/caldiy.d.ts +137 -0
  113. package/dist/connectors/caldiy.js +424 -0
  114. package/dist/connectors/caldiy.js.map +1 -0
  115. package/dist/connectors/circleci.d.ts +162 -0
  116. package/dist/connectors/circleci.js +576 -0
  117. package/dist/connectors/circleci.js.map +1 -0
  118. package/dist/connectors/cloudflare.d.ts +132 -0
  119. package/dist/connectors/cloudflare.js +622 -0
  120. package/dist/connectors/cloudflare.js.map +1 -0
  121. package/dist/connectors/confluence.js +35 -0
  122. package/dist/connectors/confluence.js.map +1 -1
  123. package/dist/connectors/connectorRedirectUri.d.ts +30 -0
  124. package/dist/connectors/connectorRedirectUri.js +38 -0
  125. package/dist/connectors/connectorRedirectUri.js.map +1 -0
  126. package/dist/connectors/connectorRegistry.d.ts +63 -0
  127. package/dist/connectors/connectorRegistry.js +354 -0
  128. package/dist/connectors/connectorRegistry.js.map +1 -0
  129. package/dist/connectors/datadog.js +33 -4
  130. package/dist/connectors/datadog.js.map +1 -1
  131. package/dist/connectors/discord.js +14 -10
  132. package/dist/connectors/discord.js.map +1 -1
  133. package/dist/connectors/elasticsearch.d.ts +141 -0
  134. package/dist/connectors/elasticsearch.js +601 -0
  135. package/dist/connectors/elasticsearch.js.map +1 -0
  136. package/dist/connectors/figma.d.ts +130 -0
  137. package/dist/connectors/figma.js +387 -0
  138. package/dist/connectors/figma.js.map +1 -0
  139. package/dist/connectors/github.d.ts +17 -0
  140. package/dist/connectors/github.js +53 -2
  141. package/dist/connectors/github.js.map +1 -1
  142. package/dist/connectors/gitlab.js +12 -6
  143. package/dist/connectors/gitlab.js.map +1 -1
  144. package/dist/connectors/gmail.js +72 -21
  145. package/dist/connectors/gmail.js.map +1 -1
  146. package/dist/connectors/googleCalendar.js +8 -8
  147. package/dist/connectors/googleCalendar.js.map +1 -1
  148. package/dist/connectors/googleDocs.d.ts +103 -0
  149. package/dist/connectors/googleDocs.js +409 -0
  150. package/dist/connectors/googleDocs.js.map +1 -0
  151. package/dist/connectors/googleDrive.d.ts +12 -0
  152. package/dist/connectors/googleDrive.js +35 -8
  153. package/dist/connectors/googleDrive.js.map +1 -1
  154. package/dist/connectors/grafana.d.ts +133 -0
  155. package/dist/connectors/grafana.js +478 -0
  156. package/dist/connectors/grafana.js.map +1 -0
  157. package/dist/connectors/jira.d.ts +25 -0
  158. package/dist/connectors/jira.js +227 -1
  159. package/dist/connectors/jira.js.map +1 -1
  160. package/dist/connectors/linear.js +1 -1
  161. package/dist/connectors/linear.js.map +1 -1
  162. package/dist/connectors/mcpOAuth.js +78 -16
  163. package/dist/connectors/mcpOAuth.js.map +1 -1
  164. package/dist/connectors/monday.d.ts +217 -0
  165. package/dist/connectors/monday.js +655 -0
  166. package/dist/connectors/monday.js.map +1 -0
  167. package/dist/connectors/mongodb.d.ts +139 -0
  168. package/dist/connectors/mongodb.js +455 -0
  169. package/dist/connectors/mongodb.js.map +1 -0
  170. package/dist/connectors/oauthStateStore.d.ts +20 -0
  171. package/dist/connectors/oauthStateStore.js +94 -4
  172. package/dist/connectors/oauthStateStore.js.map +1 -1
  173. package/dist/connectors/obsidian.d.ts +83 -0
  174. package/dist/connectors/obsidian.js +441 -0
  175. package/dist/connectors/obsidian.js.map +1 -0
  176. package/dist/connectors/paystack.d.ts +159 -0
  177. package/dist/connectors/paystack.js +607 -0
  178. package/dist/connectors/paystack.js.map +1 -0
  179. package/dist/connectors/pipedrive.d.ts +189 -0
  180. package/dist/connectors/pipedrive.js +559 -0
  181. package/dist/connectors/pipedrive.js.map +1 -0
  182. package/dist/connectors/postgres.d.ts +127 -0
  183. package/dist/connectors/postgres.js +512 -0
  184. package/dist/connectors/postgres.js.map +1 -0
  185. package/dist/connectors/posthog.d.ts +119 -0
  186. package/dist/connectors/posthog.js +479 -0
  187. package/dist/connectors/posthog.js.map +1 -0
  188. package/dist/connectors/redis.d.ts +140 -0
  189. package/dist/connectors/redis.js +571 -0
  190. package/dist/connectors/redis.js.map +1 -0
  191. package/dist/connectors/resend.d.ts +131 -0
  192. package/dist/connectors/resend.js +529 -0
  193. package/dist/connectors/resend.js.map +1 -0
  194. package/dist/connectors/salesforce.d.ts +136 -0
  195. package/dist/connectors/salesforce.js +603 -0
  196. package/dist/connectors/salesforce.js.map +1 -0
  197. package/dist/connectors/secrets.d.ts +51 -0
  198. package/dist/connectors/secrets.js +102 -0
  199. package/dist/connectors/secrets.js.map +1 -0
  200. package/dist/connectors/sendgrid.d.ts +102 -0
  201. package/dist/connectors/sendgrid.js +423 -0
  202. package/dist/connectors/sendgrid.js.map +1 -0
  203. package/dist/connectors/shopify.d.ts +145 -0
  204. package/dist/connectors/shopify.js +502 -0
  205. package/dist/connectors/shopify.js.map +1 -0
  206. package/dist/connectors/slack.d.ts +1 -1
  207. package/dist/connectors/slack.js +61 -9
  208. package/dist/connectors/slack.js.map +1 -1
  209. package/dist/connectors/snowflake.d.ts +119 -0
  210. package/dist/connectors/snowflake.js +615 -0
  211. package/dist/connectors/snowflake.js.map +1 -0
  212. package/dist/connectors/supabase.d.ts +92 -0
  213. package/dist/connectors/supabase.js +630 -0
  214. package/dist/connectors/supabase.js.map +1 -0
  215. package/dist/connectors/todoist.d.ts +117 -0
  216. package/dist/connectors/todoist.js +485 -0
  217. package/dist/connectors/todoist.js.map +1 -0
  218. package/dist/connectors/tokenStorage.js +56 -14
  219. package/dist/connectors/tokenStorage.js.map +1 -1
  220. package/dist/connectors/twilio.d.ts +118 -0
  221. package/dist/connectors/twilio.js +475 -0
  222. package/dist/connectors/twilio.js.map +1 -0
  223. package/dist/connectors/vercel.d.ts +110 -0
  224. package/dist/connectors/vercel.js +479 -0
  225. package/dist/connectors/vercel.js.map +1 -0
  226. package/dist/connectors/webflow.d.ts +118 -0
  227. package/dist/connectors/webflow.js +393 -0
  228. package/dist/connectors/webflow.js.map +1 -0
  229. package/dist/connectors/woocommerce.d.ts +220 -0
  230. package/dist/connectors/woocommerce.js +464 -0
  231. package/dist/connectors/woocommerce.js.map +1 -0
  232. package/dist/crypto.js +7 -2
  233. package/dist/crypto.js.map +1 -1
  234. package/dist/decisionReplay.js +15 -6
  235. package/dist/decisionReplay.js.map +1 -1
  236. package/dist/decisionTraceLog.d.ts +28 -0
  237. package/dist/decisionTraceLog.js +156 -29
  238. package/dist/decisionTraceLog.js.map +1 -1
  239. package/dist/drivers/claude/api.js +5 -4
  240. package/dist/drivers/claude/api.js.map +1 -1
  241. package/dist/drivers/claude/envSanitizer.d.ts +0 -6
  242. package/dist/drivers/claude/envSanitizer.js +17 -2
  243. package/dist/drivers/claude/envSanitizer.js.map +1 -1
  244. package/dist/drivers/claude/streamParser.js +5 -4
  245. package/dist/drivers/claude/streamParser.js.map +1 -1
  246. package/dist/drivers/claude/subprocess.js +22 -3
  247. package/dist/drivers/claude/subprocess.js.map +1 -1
  248. package/dist/drivers/gemini/index.d.ts +22 -0
  249. package/dist/drivers/gemini/index.js +256 -129
  250. package/dist/drivers/gemini/index.js.map +1 -1
  251. package/dist/drivers/local/index.d.ts +17 -0
  252. package/dist/drivers/local/index.js +99 -0
  253. package/dist/drivers/local/index.js.map +1 -1
  254. package/dist/drivers/openai/index.js +30 -2
  255. package/dist/drivers/openai/index.js.map +1 -1
  256. package/dist/extensionClient.d.ts +37 -4
  257. package/dist/extensionClient.js +58 -16
  258. package/dist/extensionClient.js.map +1 -1
  259. package/dist/featureFlags.d.ts +91 -0
  260. package/dist/featureFlags.js +174 -3
  261. package/dist/featureFlags.js.map +1 -1
  262. package/dist/fileLock.js +21 -12
  263. package/dist/fileLock.js.map +1 -1
  264. package/dist/fileLockSync.d.ts +67 -0
  265. package/dist/fileLockSync.js +126 -0
  266. package/dist/fileLockSync.js.map +1 -0
  267. package/dist/fp/activityAnalytics.js +26 -12
  268. package/dist/fp/activityAnalytics.js.map +1 -1
  269. package/dist/fp/automationInterpreter.d.ts +15 -1
  270. package/dist/fp/automationInterpreter.js +217 -82
  271. package/dist/fp/automationInterpreter.js.map +1 -1
  272. package/dist/fp/automationProgram.d.ts +30 -0
  273. package/dist/fp/automationProgram.js.map +1 -1
  274. package/dist/fp/automationState.d.ts +24 -5
  275. package/dist/fp/automationState.js +56 -9
  276. package/dist/fp/automationState.js.map +1 -1
  277. package/dist/fp/automationUtils.js +1 -1
  278. package/dist/fp/automationUtils.js.map +1 -1
  279. package/dist/fp/commandDescription.js +7 -1
  280. package/dist/fp/commandDescription.js.map +1 -1
  281. package/dist/fp/extensionSnapshot.js +8 -2
  282. package/dist/fp/extensionSnapshot.js.map +1 -1
  283. package/dist/fp/interpreterContext.d.ts +66 -1
  284. package/dist/fp/interpreterContext.js +91 -1
  285. package/dist/fp/interpreterContext.js.map +1 -1
  286. package/dist/fp/policyParser.js +29 -1
  287. package/dist/fp/policyParser.js.map +1 -1
  288. package/dist/fsWatchWithFallback.d.ts +36 -0
  289. package/dist/fsWatchWithFallback.js +128 -0
  290. package/dist/fsWatchWithFallback.js.map +1 -0
  291. package/dist/haltPushDispatch.d.ts +33 -0
  292. package/dist/haltPushDispatch.js +103 -0
  293. package/dist/haltPushDispatch.js.map +1 -0
  294. package/dist/httpBodyValidation.d.ts +41 -0
  295. package/dist/httpBodyValidation.js +45 -0
  296. package/dist/httpBodyValidation.js.map +1 -0
  297. package/dist/inboxRoutes.d.ts +22 -0
  298. package/dist/inboxRoutes.js +61 -1
  299. package/dist/inboxRoutes.js.map +1 -1
  300. package/dist/index.js +1053 -76
  301. package/dist/index.js.map +1 -1
  302. package/dist/installGuard.js +6 -2
  303. package/dist/installGuard.js.map +1 -1
  304. package/dist/lockfile.js +31 -4
  305. package/dist/lockfile.js.map +1 -1
  306. package/dist/oauth.d.ts +9 -0
  307. package/dist/oauth.js +33 -0
  308. package/dist/oauth.js.map +1 -1
  309. package/dist/oauthRoutes.d.ts +1 -1
  310. package/dist/oauthRoutes.js +5 -3
  311. package/dist/oauthRoutes.js.map +1 -1
  312. package/dist/orchestrator/childBridgeRegistry.js +29 -11
  313. package/dist/orchestrator/childBridgeRegistry.js.map +1 -1
  314. package/dist/patchworkConfig.d.ts +44 -1
  315. package/dist/patchworkConfig.js +28 -3
  316. package/dist/patchworkConfig.js.map +1 -1
  317. package/dist/pluginLoader.js +10 -1
  318. package/dist/pluginLoader.js.map +1 -1
  319. package/dist/pluginWatcher.js +12 -14
  320. package/dist/pluginWatcher.js.map +1 -1
  321. package/dist/preToolUseHook.js +3 -2
  322. package/dist/preToolUseHook.js.map +1 -1
  323. package/dist/processTree.d.ts +34 -0
  324. package/dist/processTree.js +105 -0
  325. package/dist/processTree.js.map +1 -0
  326. package/dist/prompts.js +3 -3
  327. package/dist/prompts.js.map +1 -1
  328. package/dist/recipeOrchestration.d.ts +9 -0
  329. package/dist/recipeOrchestration.js +341 -24
  330. package/dist/recipeOrchestration.js.map +1 -1
  331. package/dist/recipeRoutes.d.ts +58 -2
  332. package/dist/recipeRoutes.js +744 -115
  333. package/dist/recipeRoutes.js.map +1 -1
  334. package/dist/recipes/RecipeOrchestrator.d.ts +2 -0
  335. package/dist/recipes/RecipeOrchestrator.js +6 -1
  336. package/dist/recipes/RecipeOrchestrator.js.map +1 -1
  337. package/dist/recipes/agentExecutor.d.ts +25 -5
  338. package/dist/recipes/agentExecutor.js.map +1 -1
  339. package/dist/recipes/chainedRunner.d.ts +2 -0
  340. package/dist/recipes/chainedRunner.js +142 -5
  341. package/dist/recipes/chainedRunner.js.map +1 -1
  342. package/dist/recipes/connectorPreflight.d.ts +66 -0
  343. package/dist/recipes/connectorPreflight.js +169 -0
  344. package/dist/recipes/connectorPreflight.js.map +1 -0
  345. package/dist/recipes/dependencyGraph.js +13 -5
  346. package/dist/recipes/dependencyGraph.js.map +1 -1
  347. package/dist/recipes/githubInstallSource.d.ts +128 -0
  348. package/dist/recipes/githubInstallSource.js +206 -0
  349. package/dist/recipes/githubInstallSource.js.map +1 -0
  350. package/dist/recipes/haltCategory.d.ts +119 -0
  351. package/dist/recipes/haltCategory.js +188 -0
  352. package/dist/recipes/haltCategory.js.map +1 -0
  353. package/dist/recipes/idempotencyKey.d.ts +134 -0
  354. package/dist/recipes/idempotencyKey.js +322 -0
  355. package/dist/recipes/idempotencyKey.js.map +1 -0
  356. package/dist/recipes/installer.js +48 -2
  357. package/dist/recipes/installer.js.map +1 -1
  358. package/dist/recipes/judgeSummary.d.ts +50 -0
  359. package/dist/recipes/judgeSummary.js +47 -0
  360. package/dist/recipes/judgeSummary.js.map +1 -0
  361. package/dist/recipes/judgeVerdict.d.ts +48 -0
  362. package/dist/recipes/judgeVerdict.js +174 -0
  363. package/dist/recipes/judgeVerdict.js.map +1 -0
  364. package/dist/recipes/migrations/index.d.ts +9 -0
  365. package/dist/recipes/migrations/index.js +133 -0
  366. package/dist/recipes/migrations/index.js.map +1 -1
  367. package/dist/recipes/names.d.ts +20 -0
  368. package/dist/recipes/names.js +25 -0
  369. package/dist/recipes/names.js.map +1 -1
  370. package/dist/recipes/parser.js +88 -5
  371. package/dist/recipes/parser.js.map +1 -1
  372. package/dist/recipes/replayRun.js +1 -1
  373. package/dist/recipes/replayRun.js.map +1 -1
  374. package/dist/recipes/runBudget.d.ts +70 -0
  375. package/dist/recipes/runBudget.js +109 -0
  376. package/dist/recipes/runBudget.js.map +1 -0
  377. package/dist/recipes/scheduler.d.ts +30 -0
  378. package/dist/recipes/scheduler.js +69 -19
  379. package/dist/recipes/scheduler.js.map +1 -1
  380. package/dist/recipes/schema.d.ts +36 -0
  381. package/dist/recipes/schemaGenerator.js +103 -5
  382. package/dist/recipes/schemaGenerator.js.map +1 -1
  383. package/dist/recipes/stepObservation.js +9 -0
  384. package/dist/recipes/stepObservation.js.map +1 -1
  385. package/dist/recipes/toolRegistry.js +20 -3
  386. package/dist/recipes/toolRegistry.js.map +1 -1
  387. package/dist/recipes/tools/fanOut.d.ts +20 -0
  388. package/dist/recipes/tools/fanOut.js +199 -0
  389. package/dist/recipes/tools/fanOut.js.map +1 -0
  390. package/dist/recipes/tools/file.js +5 -2
  391. package/dist/recipes/tools/file.js.map +1 -1
  392. package/dist/recipes/tools/github.d.ts +1 -1
  393. package/dist/recipes/tools/github.js +75 -1
  394. package/dist/recipes/tools/github.js.map +1 -1
  395. package/dist/recipes/tools/gmail.js +27 -5
  396. package/dist/recipes/tools/gmail.js.map +1 -1
  397. package/dist/recipes/tools/googleDrive.js +64 -0
  398. package/dist/recipes/tools/googleDrive.js.map +1 -1
  399. package/dist/recipes/tools/http.d.ts +10 -0
  400. package/dist/recipes/tools/http.js +176 -0
  401. package/dist/recipes/tools/http.js.map +1 -0
  402. package/dist/recipes/tools/index.d.ts +2 -0
  403. package/dist/recipes/tools/index.js +2 -0
  404. package/dist/recipes/tools/index.js.map +1 -1
  405. package/dist/recipes/tools/slack.js +1 -1
  406. package/dist/recipes/validation.d.ts +17 -0
  407. package/dist/recipes/validation.js +29 -11
  408. package/dist/recipes/validation.js.map +1 -1
  409. package/dist/recipes/workspaceRoot.d.ts +37 -0
  410. package/dist/recipes/workspaceRoot.js +73 -0
  411. package/dist/recipes/workspaceRoot.js.map +1 -0
  412. package/dist/recipes/yamlPositions.d.ts +56 -0
  413. package/dist/recipes/yamlPositions.js +183 -0
  414. package/dist/recipes/yamlPositions.js.map +1 -0
  415. package/dist/recipes/yamlRunner.d.ts +182 -8
  416. package/dist/recipes/yamlRunner.js +877 -217
  417. package/dist/recipes/yamlRunner.js.map +1 -1
  418. package/dist/recipesHttp.d.ts +19 -3
  419. package/dist/recipesHttp.js +67 -11
  420. package/dist/recipesHttp.js.map +1 -1
  421. package/dist/resources.js +21 -13
  422. package/dist/resources.js.map +1 -1
  423. package/dist/runLog.d.ts +58 -5
  424. package/dist/runLog.js +98 -21
  425. package/dist/runLog.js.map +1 -1
  426. package/dist/sanitizeParsedJson.d.ts +39 -0
  427. package/dist/sanitizeParsedJson.js +55 -0
  428. package/dist/sanitizeParsedJson.js.map +1 -0
  429. package/dist/schemas/recipe.v1.json +885 -196
  430. package/dist/server.d.ts +160 -3
  431. package/dist/server.js +1099 -122
  432. package/dist/server.js.map +1 -1
  433. package/dist/sessionCheckpoint.d.ts +8 -0
  434. package/dist/sessionCheckpoint.js +33 -3
  435. package/dist/sessionCheckpoint.js.map +1 -1
  436. package/dist/ssrfGuard.d.ts +16 -0
  437. package/dist/ssrfGuard.js +87 -4
  438. package/dist/ssrfGuard.js.map +1 -1
  439. package/dist/streamableHttp.d.ts +9 -4
  440. package/dist/streamableHttp.js +76 -20
  441. package/dist/streamableHttp.js.map +1 -1
  442. package/dist/telemetry.js +19 -12
  443. package/dist/telemetry.js.map +1 -1
  444. package/dist/testing/shadowRun.d.ts +52 -0
  445. package/dist/testing/shadowRun.js +71 -0
  446. package/dist/testing/shadowRun.js.map +1 -0
  447. package/dist/tools/batchLsp.d.ts +3 -0
  448. package/dist/tools/bridgeDoctor.d.ts +11 -0
  449. package/dist/tools/bridgeDoctor.js +48 -2
  450. package/dist/tools/bridgeDoctor.js.map +1 -1
  451. package/dist/tools/bridgeStatus.d.ts +0 -12
  452. package/dist/tools/bridgeStatus.js +2 -28
  453. package/dist/tools/bridgeStatus.js.map +1 -1
  454. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  455. package/dist/tools/clipboard.d.ts +2 -0
  456. package/dist/tools/closeTabs.d.ts +1 -0
  457. package/dist/tools/codeLens.d.ts +1 -0
  458. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  459. package/dist/tools/ctxGetTaskContext.d.ts +9 -0
  460. package/dist/tools/ctxGetTaskContext.js +50 -2
  461. package/dist/tools/ctxGetTaskContext.js.map +1 -1
  462. package/dist/tools/ctxQueryTraces.js +32 -24
  463. package/dist/tools/ctxQueryTraces.js.map +1 -1
  464. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  465. package/dist/tools/debug.d.ts +4 -0
  466. package/dist/tools/decorations.d.ts +2 -0
  467. package/dist/tools/detectUnusedCode.js +9 -7
  468. package/dist/tools/detectUnusedCode.js.map +1 -1
  469. package/dist/tools/documentLinks.d.ts +1 -0
  470. package/dist/tools/editText.d.ts +1 -0
  471. package/dist/tools/editText.js +2 -1
  472. package/dist/tools/editText.js.map +1 -1
  473. package/dist/tools/enrichCommit.d.ts +1 -0
  474. package/dist/tools/enrichStackTrace.js +11 -5
  475. package/dist/tools/enrichStackTrace.js.map +1 -1
  476. package/dist/tools/explainDiagnostic.d.ts +1 -0
  477. package/dist/tools/explainSymbol.d.ts +1 -0
  478. package/dist/tools/fileOperations.d.ts +3 -0
  479. package/dist/tools/fileOperations.js +2 -1
  480. package/dist/tools/fileOperations.js.map +1 -1
  481. package/dist/tools/fileWatcher.d.ts +2 -0
  482. package/dist/tools/fileWatcher.js +8 -2
  483. package/dist/tools/fileWatcher.js.map +1 -1
  484. package/dist/tools/findFiles.d.ts +1 -0
  485. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  486. package/dist/tools/fixAllLintErrors.js +10 -5
  487. package/dist/tools/fixAllLintErrors.js.map +1 -1
  488. package/dist/tools/foldingRanges.d.ts +1 -0
  489. package/dist/tools/formatDocument.d.ts +1 -0
  490. package/dist/tools/formatDocument.js +10 -5
  491. package/dist/tools/formatDocument.js.map +1 -1
  492. package/dist/tools/generateTests.d.ts +1 -0
  493. package/dist/tools/getAIComments.d.ts +1 -0
  494. package/dist/tools/getAnalyticsReport.js +5 -1
  495. package/dist/tools/getAnalyticsReport.js.map +1 -1
  496. package/dist/tools/getBufferContent.d.ts +1 -0
  497. package/dist/tools/getChangeImpact.d.ts +1 -0
  498. package/dist/tools/getChangeImpact.js +23 -14
  499. package/dist/tools/getChangeImpact.js.map +1 -1
  500. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  501. package/dist/tools/getCodeCoverage.d.ts +1 -0
  502. package/dist/tools/getCodeCoverage.js +32 -14
  503. package/dist/tools/getCodeCoverage.js.map +1 -1
  504. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  505. package/dist/tools/getDebugState.d.ts +1 -0
  506. package/dist/tools/getDiagnostics.js +32 -29
  507. package/dist/tools/getDiagnostics.js.map +1 -1
  508. package/dist/tools/getDiffFromHandoff.js +8 -2
  509. package/dist/tools/getDiffFromHandoff.js.map +1 -1
  510. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  511. package/dist/tools/getGitHotspots.d.ts +1 -0
  512. package/dist/tools/getImportedSignatures.d.ts +1 -0
  513. package/dist/tools/getImportedSignatures.js +18 -14
  514. package/dist/tools/getImportedSignatures.js.map +1 -1
  515. package/dist/tools/getPRTemplate.d.ts +1 -0
  516. package/dist/tools/getProjectContext.js +23 -10
  517. package/dist/tools/getProjectContext.js.map +1 -1
  518. package/dist/tools/getSymbolHistory.d.ts +1 -0
  519. package/dist/tools/getToolCapabilities.d.ts +10 -5
  520. package/dist/tools/getToolCapabilities.js +79 -202
  521. package/dist/tools/getToolCapabilities.js.map +1 -1
  522. package/dist/tools/getTypeSignature.d.ts +1 -0
  523. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  524. package/dist/tools/gitWrite.d.ts +11 -0
  525. package/dist/tools/github/actions.d.ts +2 -0
  526. package/dist/tools/github/composite.d.ts +3 -0
  527. package/dist/tools/github/issues.d.ts +4 -0
  528. package/dist/tools/github/pr.d.ts +7 -0
  529. package/dist/tools/handoffNote.d.ts +1 -0
  530. package/dist/tools/handoffNote.js +2 -1
  531. package/dist/tools/handoffNote.js.map +1 -1
  532. package/dist/tools/headless/lspClient.js +3 -0
  533. package/dist/tools/headless/lspClient.js.map +1 -1
  534. package/dist/tools/hoverAtCursor.d.ts +1 -0
  535. package/dist/tools/httpClient.d.ts +2 -0
  536. package/dist/tools/httpClient.js +38 -71
  537. package/dist/tools/httpClient.js.map +1 -1
  538. package/dist/tools/inlayHints.d.ts +1 -0
  539. package/dist/tools/launchQuickTask.d.ts +1 -0
  540. package/dist/tools/listClaudeTasks.d.ts +1 -0
  541. package/dist/tools/listClaudeTasks.js +10 -8
  542. package/dist/tools/listClaudeTasks.js.map +1 -1
  543. package/dist/tools/listTerminals.d.ts +1 -0
  544. package/dist/tools/lsp.d.ts +15 -0
  545. package/dist/tools/lsp.js +17 -0
  546. package/dist/tools/lsp.js.map +1 -1
  547. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  548. package/dist/tools/openDiff.d.ts +1 -0
  549. package/dist/tools/openDiff.js +4 -1
  550. package/dist/tools/openDiff.js.map +1 -1
  551. package/dist/tools/openFile.d.ts +1 -0
  552. package/dist/tools/openFile.js +4 -1
  553. package/dist/tools/openFile.js.map +1 -1
  554. package/dist/tools/openInBrowser.js +6 -1
  555. package/dist/tools/openInBrowser.js.map +1 -1
  556. package/dist/tools/organizeImports.d.ts +1 -0
  557. package/dist/tools/organizeImports.js +5 -3
  558. package/dist/tools/organizeImports.js.map +1 -1
  559. package/dist/tools/performanceReport.js +5 -3
  560. package/dist/tools/performanceReport.js.map +1 -1
  561. package/dist/tools/planPersistence.d.ts +3 -0
  562. package/dist/tools/planPersistence.js +4 -1
  563. package/dist/tools/planPersistence.js.map +1 -1
  564. package/dist/tools/previewEdit.d.ts +1 -0
  565. package/dist/tools/previewEdit.js +15 -4
  566. package/dist/tools/previewEdit.js.map +1 -1
  567. package/dist/tools/recentTracesDigest.js +54 -11
  568. package/dist/tools/recentTracesDigest.js.map +1 -1
  569. package/dist/tools/refactorAnalyze.d.ts +1 -0
  570. package/dist/tools/refactorExtractFunction.js +4 -1
  571. package/dist/tools/refactorExtractFunction.js.map +1 -1
  572. package/dist/tools/refactorPreview.d.ts +1 -0
  573. package/dist/tools/refactorPreview.js +10 -2
  574. package/dist/tools/refactorPreview.js.map +1 -1
  575. package/dist/tools/replaceBlock.d.ts +1 -0
  576. package/dist/tools/replaceBlock.js +2 -1
  577. package/dist/tools/replaceBlock.js.map +1 -1
  578. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  579. package/dist/tools/runClaudeTask.d.ts +1 -0
  580. package/dist/tools/runTests.js +15 -4
  581. package/dist/tools/runTests.js.map +1 -1
  582. package/dist/tools/screenshot.d.ts +1 -0
  583. package/dist/tools/screenshotAndAnnotate.js +6 -2
  584. package/dist/tools/screenshotAndAnnotate.js.map +1 -1
  585. package/dist/tools/searchAndReplace.d.ts +1 -0
  586. package/dist/tools/searchAndReplace.js +17 -7
  587. package/dist/tools/searchAndReplace.js.map +1 -1
  588. package/dist/tools/searchTools.js +20 -19
  589. package/dist/tools/searchTools.js.map +1 -1
  590. package/dist/tools/searchWorkspace.d.ts +1 -0
  591. package/dist/tools/selectionRanges.d.ts +1 -0
  592. package/dist/tools/semanticTokens.d.ts +1 -0
  593. package/dist/tools/signatureHelp.d.ts +1 -0
  594. package/dist/tools/spawnWorkspace.js +15 -7
  595. package/dist/tools/spawnWorkspace.js.map +1 -1
  596. package/dist/tools/terminal.d.ts +6 -0
  597. package/dist/tools/testRunners/pytest.js +6 -2
  598. package/dist/tools/testRunners/pytest.js.map +1 -1
  599. package/dist/tools/testRunners/vitestJest.js +3 -1
  600. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  601. package/dist/tools/testTraceToSource.d.ts +1 -0
  602. package/dist/tools/transaction.d.ts +4 -0
  603. package/dist/tools/transaction.js +4 -1
  604. package/dist/tools/transaction.js.map +1 -1
  605. package/dist/tools/typeHierarchy.d.ts +1 -0
  606. package/dist/tools/utils.d.ts +18 -0
  607. package/dist/tools/utils.js +103 -18
  608. package/dist/tools/utils.js.map +1 -1
  609. package/dist/tools/vscodeCommands.d.ts +2 -0
  610. package/dist/tools/vscodeTasks.d.ts +2 -0
  611. package/dist/tools/workspaceSettings.d.ts +1 -0
  612. package/dist/transport.d.ts +3 -1
  613. package/dist/transport.js +103 -52
  614. package/dist/transport.js.map +1 -1
  615. package/dist/winShim.d.ts +34 -0
  616. package/dist/winShim.js +94 -0
  617. package/dist/winShim.js.map +1 -0
  618. package/dist/wireHaltPushDispatch.d.ts +38 -0
  619. package/dist/wireHaltPushDispatch.js +71 -0
  620. package/dist/wireHaltPushDispatch.js.map +1 -0
  621. package/dist/writeFileAtomic.d.ts +23 -0
  622. package/dist/writeFileAtomic.js +121 -0
  623. package/dist/writeFileAtomic.js.map +1 -0
  624. package/package.json +23 -7
  625. package/scripts/postinstall.mjs +42 -2
  626. package/scripts/smoke/run-all.mjs +213 -0
  627. package/scripts/start-all.mjs +572 -0
  628. package/scripts/start-all.ps1 +209 -0
  629. package/scripts/start-all.sh +73 -17
  630. package/scripts/start-orchestrator.ps1 +158 -0
  631. package/scripts/start-remote.mjs +122 -0
  632. package/templates/automation-policies/recipe-authoring.json +1 -1
  633. package/templates/automation-policies/security-first.json +1 -1
  634. package/templates/automation-policies/strict-lint.json +1 -1
  635. package/templates/automation-policies/test-driven.json +1 -1
  636. package/templates/automation-policy.example.json +1 -1
  637. package/templates/co.patchwork-os.bridge.plist +2 -2
  638. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  639. package/templates/recipes/ctx-loop-test.yaml +1 -1
  640. package/templates/recipes/fix-errors-on-save.yaml +71 -0
  641. package/templates/recipes/morning-brief.yaml +5 -2
  642. package/templates/recipes/project-health-check.yaml +4 -1
  643. package/templates/recipes/sentry-to-linear.yaml +72 -38
  644. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  645. package/templates/recipes/webhook/customer-escalation.yaml +8 -9
  646. package/templates/recipes/webhook/meeting-prep.yaml +11 -5
  647. package/dist/commands/marketplace.d.ts +0 -16
  648. package/dist/commands/marketplace.js +0 -32
  649. package/dist/commands/marketplace.js.map +0 -1
  650. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  651. package/dist/recipes/legacyRecipeCompat.js +0 -131
  652. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,12 +1,19 @@
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";
10
17
  import { respond500 } from "./httpErrorResponse.js";
11
18
  import { tryHandleInboxRoute } from "./inboxRoutes.js";
12
19
  import { tryHandleMcpRoute } from "./mcpRoutes.js";
@@ -49,6 +56,7 @@ export class Server extends EventEmitter {
49
56
  logger;
50
57
  extraCorsOrigins;
51
58
  pingIntervalMs;
59
+ trustedProxies;
52
60
  httpServer;
53
61
  wss;
54
62
  pingInterval = null;
@@ -59,9 +67,25 @@ export class Server extends EventEmitter {
59
67
  oauthServer = null;
60
68
  oauthIssuerUrl = null;
61
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();
62
82
  /** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
63
83
  _explainRulesCache = null;
64
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
+ }
65
89
  /** Set by bridge to provide health data */
66
90
  healthDataFn = null;
67
91
  /** Set by bridge to provide Prometheus metrics */
@@ -80,6 +104,15 @@ export class Server extends EventEmitter {
80
104
  setRecipeTrustFn = null;
81
105
  /** Patchwork: set by bridge to generate a recipe YAML draft from a natural-language prompt. */
82
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;
83
116
  /** Patchwork: set by bridge to list installed recipes for the dashboard. */
84
117
  recipesFn = null;
85
118
  /** Patchwork: set by bridge to load raw recipe source content by name. */
@@ -102,6 +135,10 @@ export class Server extends EventEmitter {
102
135
  runsFn = null;
103
136
  /** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
104
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;
105
142
  /** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
106
143
  runPlanFn = null;
107
144
  /** Patchwork (VD-4): mocked replay of an existing run. Returns the new
@@ -109,10 +146,25 @@ export class Server extends EventEmitter {
109
146
  runReplayFn = null;
110
147
  /** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
111
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;
112
157
  /** Patchwork: admin-controlled managed settings path (highest rule precedence). */
113
158
  managedSettingsPath = undefined;
114
159
  /** Effective bridge config path to update when dashboard saves driver changes. */
115
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;
116
168
  /** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
117
169
  approvalGate = "off";
118
170
  /** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
@@ -123,6 +175,10 @@ export class Server extends EventEmitter {
123
175
  pushServiceToken = undefined;
124
176
  /** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
125
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;
126
182
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
127
183
  onApprovalDecision = undefined;
128
184
  /**
@@ -145,6 +201,33 @@ export class Server extends EventEmitter {
145
201
  * the user's preference.
146
202
  */
147
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;
148
231
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
149
232
  webhookFn = null;
150
233
  /**
@@ -157,6 +240,36 @@ export class Server extends EventEmitter {
157
240
  */
158
241
  webhookPayloads = new Map();
159
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;
160
273
  /** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
161
274
  httpMcpHandler = null;
162
275
  /** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
@@ -180,6 +293,22 @@ export class Server extends EventEmitter {
180
293
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
181
294
  launchQuickTaskFn = null;
182
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");
183
312
  /**
184
313
  * Attach an OAuth 2.0 Authorization Server.
185
314
  * When set, the bridge exposes:
@@ -196,12 +325,19 @@ export class Server extends EventEmitter {
196
325
  }
197
326
  /** Hosts accepted in the WebSocket upgrade Host header (DNS-rebinding guard). */
198
327
  allowedHosts;
199
- 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 = []) {
200
335
  super();
201
336
  this.authToken = authToken;
202
337
  this.logger = logger;
203
338
  this.extraCorsOrigins = extraCorsOrigins;
204
339
  this.pingIntervalMs = pingIntervalMs;
340
+ this.trustedProxies = trustedProxies;
205
341
  // Defense-in-depth: ensure token is non-empty so timingSafeTokenCompare
206
342
  // cannot accept a blank Authorization header against an empty token.
207
343
  if (authToken.length === 0) {
@@ -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
@@ -607,10 +799,18 @@ export class Server extends EventEmitter {
607
799
  return;
608
800
  }
609
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.
610
811
  if (this.sseSubscriberCount >= Server.MAX_SSE_SUBSCRIBERS) {
611
- res.writeHead(503, { "Content-Type": "application/json" });
612
- res.end(JSON.stringify({ error: "Too many SSE subscribers (max 20)" }));
613
- return;
812
+ const oldest = this.sseSubscribers.values().next().value;
813
+ oldest?.();
614
814
  }
615
815
  this.sseSubscriberCount++;
616
816
  // Disable socket timeout — SSE connections are long-lived by design
@@ -621,31 +821,58 @@ export class Server extends EventEmitter {
621
821
  Connection: "keep-alive",
622
822
  });
623
823
  res.flushHeaders();
624
- const unsub = this.streamFn?.((kind, entry) => {
625
- try {
626
- res.write(`data: ${JSON.stringify({ kind, ...entry })}\n\n`);
627
- }
628
- catch {
629
- // Client disconnected unsubscribe on next tick
630
- unsub?.();
631
- }
632
- }) ?? (() => { });
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
+ }) ?? (() => { });
633
863
  // Keep-alive comment ping every 15s so proxies don't close idle connections
634
- const ping = setInterval(() => {
864
+ pingHandle = setInterval(() => {
635
865
  try {
636
866
  res.write(": ping\n\n");
637
867
  }
638
868
  catch {
639
- clearInterval(ping);
640
- unsub();
869
+ cleanup();
641
870
  }
642
871
  }, 15_000);
643
- ping.unref();
644
- req.on("close", () => {
645
- this.sseSubscriberCount--;
646
- clearInterval(ping);
647
- unsub();
648
- });
872
+ pingHandle.unref();
873
+ req.on("close", cleanup);
874
+ req.on("error", cleanup);
875
+ res.on("error", cleanup);
649
876
  return;
650
877
  }
651
878
  if (req.url === "/tasks" && req.method === "GET") {
@@ -689,6 +916,33 @@ export class Server extends EventEmitter {
689
916
  respond413(res, HOOKS_BODY_CAP);
690
917
  return;
691
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
+ }
692
946
  let payload;
693
947
  if (read.body.trim()) {
694
948
  try {
@@ -702,7 +956,7 @@ export class Server extends EventEmitter {
702
956
  res.writeHead(503, { "Content-Type": "application/json" });
703
957
  res.end(JSON.stringify({
704
958
  ok: false,
705
- error: "Webhooks unavailable — start bridge with --claude-driver subprocess",
959
+ error: "Webhooks unavailable — start bridge with --driver subprocess",
706
960
  }));
707
961
  return;
708
962
  }
@@ -728,6 +982,19 @@ export class Server extends EventEmitter {
728
982
  if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
729
983
  existing.length = Server.MAX_WEBHOOK_PAYLOADS;
730
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
+ }
731
998
  this.webhookPayloads.set(hookPath, existing);
732
999
  }
733
1000
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -880,6 +1147,7 @@ export class Server extends EventEmitter {
880
1147
  if (tryHandleRecipeRoute(req, res, parsedUrl, {
881
1148
  setRecipeTrustFn: this.setRecipeTrustFn,
882
1149
  generateRecipeFn: this.generateRecipeFn,
1150
+ repairRecipeFn: this.repairRecipeFn,
883
1151
  recipesFn: this.recipesFn,
884
1152
  loadRecipeContentFn: this.loadRecipeContentFn,
885
1153
  saveRecipeContentFn: this.saveRecipeContentFn,
@@ -892,9 +1160,12 @@ export class Server extends EventEmitter {
892
1160
  setRecipeEnabledFn: this.setRecipeEnabledFn,
893
1161
  runsFn: this.runsFn,
894
1162
  runDetailFn: this.runDetailFn,
1163
+ haltSummaryFn: this.haltSummaryFn,
1164
+ judgeSummaryFn: this.judgeSummaryFn,
895
1165
  runPlanFn: this.runPlanFn,
896
1166
  runReplayFn: this.runReplayFn,
897
1167
  runRecipeFn: this.runRecipeFn,
1168
+ onRecipesChangedFn: this.onRecipesChangedFn,
898
1169
  })) {
899
1170
  return;
900
1171
  }
@@ -933,6 +1204,21 @@ export class Server extends EventEmitter {
933
1204
  return;
934
1205
  }
935
1206
  if (parsedUrl.pathname === "/settings" && req.method === "POST") {
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
+ }
936
1222
  // 16 KB — settings POSTs are short-string fields (URLs, API keys,
937
1223
  // gate level). 16 KB is generous; an authenticated attacker can't
938
1224
  // stream gigabytes here.
@@ -950,55 +1236,59 @@ export class Server extends EventEmitter {
950
1236
  try {
951
1237
  {
952
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
953
1266
  const hasWebhookUpdate = body.webhookUrl !== undefined;
954
- const raw = hasWebhookUpdate
1267
+ const webhookRaw = hasWebhookUpdate
955
1268
  ? (body.webhookUrl?.trim() ?? "")
956
1269
  : undefined;
957
- if (raw !== undefined && raw !== "" && !/^https:\/\/.+/.test(raw)) {
958
- res.writeHead(400, { "Content-Type": "application/json" });
959
- 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");
960
1274
  return;
961
1275
  }
1276
+ // approvalGate
962
1277
  const gateRaw = body.approvalGate;
963
1278
  if (gateRaw !== undefined &&
964
1279
  gateRaw !== "off" &&
965
1280
  gateRaw !== "high" &&
966
1281
  gateRaw !== "all") {
967
- res.writeHead(400, { "Content-Type": "application/json" });
968
- res.end(JSON.stringify({
969
- error: 'approvalGate must be "off", "high", or "all"',
970
- }));
1282
+ respond400('approvalGate must be "off", "high", or "all"');
971
1283
  return;
972
1284
  }
973
- const configPath = patchworkConfigPath();
974
- const cfg = loadPatchworkConfig(configPath);
975
- cfg.dashboard = {
976
- port: cfg.dashboard?.port ?? 3200,
977
- requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
978
- pushNotifications: cfg.dashboard?.pushNotifications ?? false,
979
- webhookUrl: hasWebhookUpdate
980
- ? raw || undefined
981
- : cfg.dashboard?.webhookUrl,
982
- };
983
- if (gateRaw !== undefined) {
984
- cfg.approvalGate = gateRaw;
985
- this.approvalGate = gateRaw;
986
- }
987
- // h10 toggle: must be boolean if present. Persists to
988
- // ~/.patchwork/config.json AND live-mutates the Server
989
- // field so the next /approvals POST honors it without
990
- // needing a bridge restart.
991
- if (body.enableTimeOfDayAnomaly !== undefined) {
992
- if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
993
- res.writeHead(400, { "Content-Type": "application/json" });
994
- res.end(JSON.stringify({
995
- error: "enableTimeOfDayAnomaly must be a boolean",
996
- }));
997
- return;
998
- }
999
- cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1000
- 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;
1001
1290
  }
1291
+ // driver
1002
1292
  const driverRaw = body.driver;
1003
1293
  if (driverRaw !== undefined) {
1004
1294
  const validDrivers = [
@@ -1012,26 +1302,11 @@ export class Server extends EventEmitter {
1012
1302
  "none",
1013
1303
  ];
1014
1304
  if (!validDrivers.includes(driverRaw)) {
1015
- res.writeHead(400, { "Content-Type": "application/json" });
1016
- res.end(JSON.stringify({
1017
- error: `driver must be one of: ${validDrivers.join(", ")}`,
1018
- }));
1019
- return;
1020
- }
1021
- const driver = driverRaw;
1022
- cfg.driver = driver;
1023
- try {
1024
- saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1025
- }
1026
- catch (writeErr) {
1027
- this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1028
- res.writeHead(500, { "Content-Type": "application/json" });
1029
- res.end(JSON.stringify({
1030
- error: "Failed to write bridge driver config",
1031
- }));
1305
+ respond400(`driver must be one of: ${validDrivers.join(", ")}`);
1032
1306
  return;
1033
1307
  }
1034
1308
  }
1309
+ // model
1035
1310
  if (body.model !== undefined) {
1036
1311
  const validModels = [
1037
1312
  "claude",
@@ -1041,40 +1316,198 @@ export class Server extends EventEmitter {
1041
1316
  "local",
1042
1317
  ];
1043
1318
  if (!validModels.includes(body.model)) {
1044
- res.writeHead(400, { "Content-Type": "application/json" });
1045
- res.end(JSON.stringify({
1046
- error: `model must be one of: ${validModels.join(", ")}`,
1047
- }));
1319
+ respond400(`model must be one of: ${validModels.join(", ")}`);
1048
1320
  return;
1049
1321
  }
1050
- cfg.model = body.model;
1051
- if (body.model === "local") {
1052
- if (body.localEndpoint !== undefined)
1053
- cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1054
- if (body.localModel !== undefined)
1055
- cfg.localModel = body.localModel.trim() || undefined;
1056
- }
1057
1322
  }
1323
+ // apiKey
1058
1324
  if (body.apiKey) {
1059
1325
  const { provider, key } = body.apiKey;
1060
1326
  const validProviders = ["anthropic", "openai", "google", "xai"];
1061
1327
  if (!validProviders.includes(provider) ||
1062
1328
  typeof key !== "string") {
1063
- res.writeHead(400, { "Content-Type": "application/json" });
1064
- res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1329
+ respond400("Invalid apiKey provider or key");
1330
+ return;
1331
+ }
1332
+ }
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;
1368
+ }
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");
1065
1402
  return;
1066
1403
  }
1067
- // Provider keys go to the secure store (Keychain/DPAPI/Secret
1068
- // Service / AES-256-GCM file fallback) — never persisted to
1069
- // ~/.patchwork/config.json. Empty string clears.
1404
+ let parsed;
1070
1405
  try {
1071
- saveApiKeyToSecureStore(provider, key);
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;
1463
+ }
1464
+ if (pushTokenTrimmed !== undefined) {
1465
+ cfg.pushServiceToken = pushTokenTrimmed || undefined;
1466
+ }
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);
1072
1505
  }
1073
1506
  catch (writeErr) {
1074
- this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1507
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1075
1508
  res.writeHead(500, { "Content-Type": "application/json" });
1076
1509
  res.end(JSON.stringify({
1077
- error: "Failed to write provider API key",
1510
+ error: "Failed to write bridge driver config",
1078
1511
  }));
1079
1512
  return;
1080
1513
  }
@@ -1083,46 +1516,551 @@ export class Server extends EventEmitter {
1083
1516
  savePatchworkConfig(cfg, configPath);
1084
1517
  }
1085
1518
  catch (writeErr) {
1086
- this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1519
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1087
1520
  res.writeHead(500, { "Content-Type": "application/json" });
1088
- res.end(JSON.stringify({
1089
- error: "Failed to write patchwork config",
1090
- }));
1521
+ res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
1091
1522
  return;
1092
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
+ }
1093
1545
  if (hasWebhookUpdate) {
1094
- this.approvalWebhookUrl = raw || undefined;
1546
+ this.approvalWebhookUrl = webhookRaw || undefined;
1095
1547
  }
1096
- if (body.pushServiceUrl !== undefined) {
1097
- const pushUrl = body.pushServiceUrl.trim();
1098
- if (pushUrl && !pushUrl.startsWith("https://")) {
1099
- res.writeHead(400, { "Content-Type": "application/json" });
1100
- res.end(JSON.stringify({ error: "pushServiceUrl must be HTTPS" }));
1101
- return;
1102
- }
1103
- this.pushServiceUrl = pushUrl || undefined;
1548
+ if (pushUrlTrimmed !== undefined) {
1549
+ this.pushServiceUrl = pushUrlTrimmed || undefined;
1550
+ }
1551
+ if (pushTokenTrimmed !== undefined) {
1552
+ this.pushServiceToken = pushTokenTrimmed || undefined;
1104
1553
  }
1105
- if (body.pushServiceToken !== undefined) {
1106
- this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1554
+ if (pushBaseTrimmed !== undefined) {
1555
+ this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1107
1556
  }
1108
- if (body.pushServiceBaseUrl !== undefined) {
1109
- this.pushServiceBaseUrl =
1110
- body.pushServiceBaseUrl.trim() || undefined;
1557
+ if (ntfyTopicTrimmed !== undefined) {
1558
+ this.ntfyTopic = ntfyTopicTrimmed || undefined;
1111
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.
1112
1568
  const restartRequired = driverRaw !== undefined ||
1113
1569
  body.apiKey !== undefined ||
1114
- 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
+ }
1115
1619
  res.writeHead(200, { "Content-Type": "application/json" });
1116
1620
  res.end(JSON.stringify({ ok: true, restartRequired }));
1117
1621
  }
1118
1622
  }
1119
1623
  catch (err) {
1120
- this.logger.error(`[/config/patchwork] error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1121
- res.writeHead(400, { "Content-Type": "application/json" });
1122
- res.end(JSON.stringify({ error: "Invalid request body" }));
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" }));
1123
1633
  }
1124
1634
  return;
1125
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") {
1687
+ res.writeHead(400, { "Content-Type": "application/json" });
1688
+ res.end(JSON.stringify({
1689
+ error: "invalid_request",
1690
+ reason: "engage must be boolean",
1691
+ }));
1692
+ return;
1693
+ }
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
+ }
1759
+ }
1760
+ // /feature-flags — list + 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.
1889
+ try {
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" });
1960
+ res.end(JSON.stringify({
1961
+ error: "invalid_request",
1962
+ reason: `${key} must be a boolean`,
1963
+ }));
1964
+ return;
1965
+ }
1966
+ }
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;
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
+ }
1126
2064
  // CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
1127
2065
  if (parsedUrl.pathname === "/notify" && req.method === "POST") {
1128
2066
  // 8 KB — notify payloads carry an event name + small arg map
@@ -1208,7 +2146,9 @@ export class Server extends EventEmitter {
1208
2146
  path: parsedUrl.pathname,
1209
2147
  body: parsedBody,
1210
2148
  query: parsedUrl.searchParams,
1211
- approvalToken: req.headers["x-approval-token"],
2149
+ approvalToken: req.headers["x-approval-token"] ??
2150
+ parsedUrl.searchParams.get("token") ??
2151
+ undefined,
1212
2152
  }, {
1213
2153
  queue: getApprovalQueue(),
1214
2154
  workspace: process.cwd(),
@@ -1219,6 +2159,8 @@ export class Server extends EventEmitter {
1219
2159
  pushServiceUrl: this.pushServiceUrl,
1220
2160
  pushServiceToken: this.pushServiceToken,
1221
2161
  pushServiceBaseUrl: this.pushServiceBaseUrl,
2162
+ ntfyTopic: this.ntfyTopic,
2163
+ ntfyServer: this.ntfyServer,
1222
2164
  activityLog: this.activityLog,
1223
2165
  // RecipeRunLog satisfies RecipeRunQuerier structurally
1224
2166
  // — the cast bridges TS contravariance: RecipeRunQuerier's
@@ -1269,7 +2211,7 @@ export class Server extends EventEmitter {
1269
2211
  res.writeHead(503, { "Content-Type": "application/json" });
1270
2212
  res.end(JSON.stringify({
1271
2213
  ok: false,
1272
- error: "Quick tasks unavailable — requires --claude-driver subprocess",
2214
+ error: "Quick tasks unavailable — requires --driver subprocess",
1273
2215
  }));
1274
2216
  return;
1275
2217
  }
@@ -1424,6 +2366,41 @@ export class Server extends EventEmitter {
1424
2366
  this.emit("connection", ws);
1425
2367
  });
1426
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
+ }
1427
2404
  async listen(port, bindAddress = "127.0.0.1") {
1428
2405
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
1429
2406
  if (!LOOPBACK.has(bindAddress)) {