patchwork-os 0.2.0-beta.2 → 0.2.0-beta.4

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 (261) hide show
  1. package/README.bridge.md +5 -5
  2. package/README.md +244 -30
  3. package/dist/activityLog.d.ts +6 -0
  4. package/dist/activityLog.js +10 -1
  5. package/dist/activityLog.js.map +1 -1
  6. package/dist/analyticsPrefs.d.ts +35 -2
  7. package/dist/analyticsPrefs.js +120 -21
  8. package/dist/analyticsPrefs.js.map +1 -1
  9. package/dist/analyticsSend.js +5 -1
  10. package/dist/analyticsSend.js.map +1 -1
  11. package/dist/approvalHttp.js +25 -8
  12. package/dist/approvalHttp.js.map +1 -1
  13. package/dist/approvalQueue.d.ts +44 -1
  14. package/dist/approvalQueue.js +117 -0
  15. package/dist/approvalQueue.js.map +1 -1
  16. package/dist/automation.d.ts +3 -3
  17. package/dist/automation.js +12 -5
  18. package/dist/automation.js.map +1 -1
  19. package/dist/bridge.d.ts +2 -0
  20. package/dist/bridge.js +140 -8
  21. package/dist/bridge.js.map +1 -1
  22. package/dist/bridgeLockDiscovery.d.ts +27 -1
  23. package/dist/bridgeLockDiscovery.js +38 -11
  24. package/dist/bridgeLockDiscovery.js.map +1 -1
  25. package/dist/claudeOrchestrator.js +27 -10
  26. package/dist/claudeOrchestrator.js.map +1 -1
  27. package/dist/commands/dashboard.js +8 -1
  28. package/dist/commands/dashboard.js.map +1 -1
  29. package/dist/commands/install.js +3 -0
  30. package/dist/commands/install.js.map +1 -1
  31. package/dist/commands/patchworkInit.d.ts +5 -0
  32. package/dist/commands/patchworkInit.js +89 -7
  33. package/dist/commands/patchworkInit.js.map +1 -1
  34. package/dist/commands/recipe.d.ts +51 -0
  35. package/dist/commands/recipe.js +353 -2
  36. package/dist/commands/recipe.js.map +1 -1
  37. package/dist/commands/recipeInstall.js +6 -3
  38. package/dist/commands/recipeInstall.js.map +1 -1
  39. package/dist/commands/task.js +2 -2
  40. package/dist/commands/task.js.map +1 -1
  41. package/dist/commitIssueLinkLog.d.ts +16 -0
  42. package/dist/commitIssueLinkLog.js +87 -4
  43. package/dist/commitIssueLinkLog.js.map +1 -1
  44. package/dist/config.d.ts +29 -3
  45. package/dist/config.js +77 -21
  46. package/dist/config.js.map +1 -1
  47. package/dist/connectorRoutes.js +1 -1
  48. package/dist/connectorRoutes.js.map +1 -1
  49. package/dist/connectors/asana.js +4 -3
  50. package/dist/connectors/asana.js.map +1 -1
  51. package/dist/connectors/confluence.js +35 -0
  52. package/dist/connectors/confluence.js.map +1 -1
  53. package/dist/connectors/datadog.js +33 -4
  54. package/dist/connectors/datadog.js.map +1 -1
  55. package/dist/connectors/discord.js +5 -4
  56. package/dist/connectors/discord.js.map +1 -1
  57. package/dist/connectors/gitlab.js +7 -1
  58. package/dist/connectors/gitlab.js.map +1 -1
  59. package/dist/connectors/mcpOAuth.js +71 -6
  60. package/dist/connectors/mcpOAuth.js.map +1 -1
  61. package/dist/connectors/slack.d.ts +1 -1
  62. package/dist/connectors/slack.js +56 -4
  63. package/dist/connectors/slack.js.map +1 -1
  64. package/dist/connectors/tokenStorage.js +56 -14
  65. package/dist/connectors/tokenStorage.js.map +1 -1
  66. package/dist/decisionTraceLog.d.ts +28 -0
  67. package/dist/decisionTraceLog.js +115 -7
  68. package/dist/decisionTraceLog.js.map +1 -1
  69. package/dist/drivers/claude/subprocess.js +22 -3
  70. package/dist/drivers/claude/subprocess.js.map +1 -1
  71. package/dist/drivers/gemini/index.js +19 -3
  72. package/dist/drivers/gemini/index.js.map +1 -1
  73. package/dist/extensionClient.d.ts +29 -4
  74. package/dist/extensionClient.js +26 -11
  75. package/dist/extensionClient.js.map +1 -1
  76. package/dist/featureFlags.d.ts +76 -0
  77. package/dist/featureFlags.js +153 -3
  78. package/dist/featureFlags.js.map +1 -1
  79. package/dist/fileLockSync.d.ts +67 -0
  80. package/dist/fileLockSync.js +126 -0
  81. package/dist/fileLockSync.js.map +1 -0
  82. package/dist/fp/automationInterpreter.d.ts +6 -0
  83. package/dist/fp/automationInterpreter.js +15 -2
  84. package/dist/fp/automationInterpreter.js.map +1 -1
  85. package/dist/fp/automationState.d.ts +1 -1
  86. package/dist/fp/automationState.js +10 -0
  87. package/dist/fp/automationState.js.map +1 -1
  88. package/dist/fp/commandDescription.js +7 -1
  89. package/dist/fp/commandDescription.js.map +1 -1
  90. package/dist/fsWatchWithFallback.d.ts +36 -0
  91. package/dist/fsWatchWithFallback.js +127 -0
  92. package/dist/fsWatchWithFallback.js.map +1 -0
  93. package/dist/index.js +797 -75
  94. package/dist/index.js.map +1 -1
  95. package/dist/installGuard.js +6 -2
  96. package/dist/installGuard.js.map +1 -1
  97. package/dist/lockfile.js +31 -4
  98. package/dist/lockfile.js.map +1 -1
  99. package/dist/patchworkConfig.js +13 -3
  100. package/dist/patchworkConfig.js.map +1 -1
  101. package/dist/pluginLoader.js +10 -1
  102. package/dist/pluginLoader.js.map +1 -1
  103. package/dist/pluginWatcher.js +6 -13
  104. package/dist/pluginWatcher.js.map +1 -1
  105. package/dist/preToolUseHook.js +3 -2
  106. package/dist/preToolUseHook.js.map +1 -1
  107. package/dist/processTree.d.ts +34 -0
  108. package/dist/processTree.js +105 -0
  109. package/dist/processTree.js.map +1 -0
  110. package/dist/prompts.js +3 -3
  111. package/dist/prompts.js.map +1 -1
  112. package/dist/recipeOrchestration.js +35 -1
  113. package/dist/recipeOrchestration.js.map +1 -1
  114. package/dist/recipeRoutes.d.ts +37 -0
  115. package/dist/recipeRoutes.js +236 -33
  116. package/dist/recipeRoutes.js.map +1 -1
  117. package/dist/recipes/agentExecutor.d.ts +25 -5
  118. package/dist/recipes/agentExecutor.js.map +1 -1
  119. package/dist/recipes/chainedRunner.js +16 -2
  120. package/dist/recipes/chainedRunner.js.map +1 -1
  121. package/dist/recipes/connectorPreflight.d.ts +53 -0
  122. package/dist/recipes/connectorPreflight.js +143 -0
  123. package/dist/recipes/connectorPreflight.js.map +1 -0
  124. package/dist/recipes/githubInstallSource.d.ts +62 -0
  125. package/dist/recipes/githubInstallSource.js +125 -0
  126. package/dist/recipes/githubInstallSource.js.map +1 -0
  127. package/dist/recipes/haltCategory.d.ts +80 -0
  128. package/dist/recipes/haltCategory.js +125 -0
  129. package/dist/recipes/haltCategory.js.map +1 -0
  130. package/dist/recipes/idempotencyKey.d.ts +126 -0
  131. package/dist/recipes/idempotencyKey.js +297 -0
  132. package/dist/recipes/idempotencyKey.js.map +1 -0
  133. package/dist/recipes/installer.js +48 -2
  134. package/dist/recipes/installer.js.map +1 -1
  135. package/dist/recipes/judgeSummary.d.ts +50 -0
  136. package/dist/recipes/judgeSummary.js +47 -0
  137. package/dist/recipes/judgeSummary.js.map +1 -0
  138. package/dist/recipes/judgeVerdict.d.ts +48 -0
  139. package/dist/recipes/judgeVerdict.js +174 -0
  140. package/dist/recipes/judgeVerdict.js.map +1 -0
  141. package/dist/recipes/migrations/index.d.ts +9 -0
  142. package/dist/recipes/migrations/index.js +133 -0
  143. package/dist/recipes/migrations/index.js.map +1 -1
  144. package/dist/recipes/parser.js +82 -4
  145. package/dist/recipes/parser.js.map +1 -1
  146. package/dist/recipes/runBudget.d.ts +70 -0
  147. package/dist/recipes/runBudget.js +109 -0
  148. package/dist/recipes/runBudget.js.map +1 -0
  149. package/dist/recipes/scheduler.d.ts +17 -0
  150. package/dist/recipes/scheduler.js +34 -2
  151. package/dist/recipes/scheduler.js.map +1 -1
  152. package/dist/recipes/schema.d.ts +30 -0
  153. package/dist/recipes/toolRegistry.js +19 -0
  154. package/dist/recipes/toolRegistry.js.map +1 -1
  155. package/dist/recipes/tools/http.d.ts +10 -0
  156. package/dist/recipes/tools/http.js +176 -0
  157. package/dist/recipes/tools/http.js.map +1 -0
  158. package/dist/recipes/tools/index.d.ts +1 -0
  159. package/dist/recipes/tools/index.js +1 -0
  160. package/dist/recipes/tools/index.js.map +1 -1
  161. package/dist/recipes/validation.js +1 -1
  162. package/dist/recipes/validation.js.map +1 -1
  163. package/dist/recipes/yamlRunner.d.ts +75 -8
  164. package/dist/recipes/yamlRunner.js +174 -28
  165. package/dist/recipes/yamlRunner.js.map +1 -1
  166. package/dist/resources.js +21 -13
  167. package/dist/resources.js.map +1 -1
  168. package/dist/runLog.d.ts +28 -0
  169. package/dist/runLog.js +19 -3
  170. package/dist/runLog.js.map +1 -1
  171. package/dist/sanitizeParsedJson.d.ts +39 -0
  172. package/dist/sanitizeParsedJson.js +55 -0
  173. package/dist/sanitizeParsedJson.js.map +1 -0
  174. package/dist/server.d.ts +79 -0
  175. package/dist/server.js +356 -3
  176. package/dist/server.js.map +1 -1
  177. package/dist/sessionCheckpoint.d.ts +8 -0
  178. package/dist/sessionCheckpoint.js +18 -2
  179. package/dist/sessionCheckpoint.js.map +1 -1
  180. package/dist/streamableHttp.js +17 -6
  181. package/dist/streamableHttp.js.map +1 -1
  182. package/dist/tools/bridgeDoctor.js +6 -2
  183. package/dist/tools/bridgeDoctor.js.map +1 -1
  184. package/dist/tools/detectUnusedCode.js +9 -7
  185. package/dist/tools/detectUnusedCode.js.map +1 -1
  186. package/dist/tools/editText.js +2 -1
  187. package/dist/tools/editText.js.map +1 -1
  188. package/dist/tools/fileOperations.js +2 -1
  189. package/dist/tools/fileOperations.js.map +1 -1
  190. package/dist/tools/fileWatcher.js +8 -2
  191. package/dist/tools/fileWatcher.js.map +1 -1
  192. package/dist/tools/fixAllLintErrors.js +10 -5
  193. package/dist/tools/fixAllLintErrors.js.map +1 -1
  194. package/dist/tools/formatDocument.js +10 -5
  195. package/dist/tools/formatDocument.js.map +1 -1
  196. package/dist/tools/getCodeCoverage.js +7 -3
  197. package/dist/tools/getCodeCoverage.js.map +1 -1
  198. package/dist/tools/handoffNote.js +2 -1
  199. package/dist/tools/handoffNote.js.map +1 -1
  200. package/dist/tools/headless/lspClient.js +3 -0
  201. package/dist/tools/headless/lspClient.js.map +1 -1
  202. package/dist/tools/lsp.js +17 -0
  203. package/dist/tools/lsp.js.map +1 -1
  204. package/dist/tools/openDiff.js +4 -1
  205. package/dist/tools/openDiff.js.map +1 -1
  206. package/dist/tools/openFile.js +4 -1
  207. package/dist/tools/openFile.js.map +1 -1
  208. package/dist/tools/organizeImports.js +5 -3
  209. package/dist/tools/organizeImports.js.map +1 -1
  210. package/dist/tools/previewEdit.js +7 -2
  211. package/dist/tools/previewEdit.js.map +1 -1
  212. package/dist/tools/recentTracesDigest.js +56 -11
  213. package/dist/tools/recentTracesDigest.js.map +1 -1
  214. package/dist/tools/refactorExtractFunction.js +4 -1
  215. package/dist/tools/refactorExtractFunction.js.map +1 -1
  216. package/dist/tools/refactorPreview.js +10 -2
  217. package/dist/tools/refactorPreview.js.map +1 -1
  218. package/dist/tools/replaceBlock.js +2 -1
  219. package/dist/tools/replaceBlock.js.map +1 -1
  220. package/dist/tools/searchAndReplace.js +2 -1
  221. package/dist/tools/searchAndReplace.js.map +1 -1
  222. package/dist/tools/spawnWorkspace.js +15 -7
  223. package/dist/tools/spawnWorkspace.js.map +1 -1
  224. package/dist/tools/testRunners/vitestJest.js +3 -1
  225. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  226. package/dist/tools/transaction.js +4 -1
  227. package/dist/tools/transaction.js.map +1 -1
  228. package/dist/tools/utils.js +68 -8
  229. package/dist/tools/utils.js.map +1 -1
  230. package/dist/transport.d.ts +1 -1
  231. package/dist/transport.js +18 -4
  232. package/dist/transport.js.map +1 -1
  233. package/dist/winShim.d.ts +34 -0
  234. package/dist/winShim.js +94 -0
  235. package/dist/winShim.js.map +1 -0
  236. package/dist/writeFileAtomic.d.ts +23 -0
  237. package/dist/writeFileAtomic.js +94 -0
  238. package/dist/writeFileAtomic.js.map +1 -0
  239. package/package.json +17 -6
  240. package/scripts/postinstall.mjs +42 -2
  241. package/scripts/smoke/run-all.mjs +213 -0
  242. package/scripts/start-all.mjs +572 -0
  243. package/scripts/start-all.ps1 +209 -0
  244. package/scripts/start-all.sh +73 -17
  245. package/scripts/start-orchestrator.ps1 +158 -0
  246. package/scripts/start-remote.mjs +122 -0
  247. package/templates/automation-policies/recipe-authoring.json +1 -1
  248. package/templates/automation-policies/security-first.json +1 -1
  249. package/templates/automation-policies/strict-lint.json +1 -1
  250. package/templates/automation-policies/test-driven.json +1 -1
  251. package/templates/automation-policy.example.json +1 -1
  252. package/templates/co.patchwork-os.bridge.plist +1 -1
  253. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  254. package/templates/recipes/ctx-loop-test.yaml +1 -1
  255. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  256. package/dist/commands/marketplace.d.ts +0 -16
  257. package/dist/commands/marketplace.js +0 -32
  258. package/dist/commands/marketplace.js.map +0 -1
  259. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  260. package/dist/recipes/legacyRecipeCompat.js +0 -131
  261. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,12 +1,15 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
1
2
  import { EventEmitter } from "node:events";
2
3
  import http from "node:http";
3
4
  import { WebSocket, WebSocketServer as WsServer } from "ws";
5
+ import { getAnalyticsPrefsAll, getTelemetryPrefs, setTelemetryPrefs, } from "./analyticsPrefs.js";
4
6
  import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
5
7
  import { getApprovalQueue } from "./approvalQueue.js";
6
8
  import { saveBridgeConfigDriver } from "./config.js";
7
9
  import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
8
10
  import { timingSafeStringEqual } from "./crypto.js";
9
11
  import { renderDashboardHtml } from "./dashboard.js";
12
+ import { EnvLockedFlagError, getEnvLockedValue, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, setFlag, } from "./featureFlags.js";
10
13
  import { respond500 } from "./httpErrorResponse.js";
11
14
  import { tryHandleInboxRoute } from "./inboxRoutes.js";
12
15
  import { tryHandleMcpRoute } from "./mcpRoutes.js";
@@ -103,6 +106,10 @@ export class Server extends EventEmitter {
103
106
  runsFn = null;
104
107
  /** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
105
108
  runDetailFn = null;
109
+ /** Patchwork (PR1c): aggregate halt-reason categories across recent runs. */
110
+ haltSummaryFn = null;
111
+ /** Patchwork (PR3b): aggregate judge-step verdicts across recent runs. */
112
+ judgeSummaryFn = null;
106
113
  /** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
107
114
  runPlanFn = null;
108
115
  /** Patchwork (VD-4): mocked replay of an existing run. Returns the new
@@ -110,10 +117,25 @@ export class Server extends EventEmitter {
110
117
  runReplayFn = null;
111
118
  /** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
112
119
  runRecipeFn = null;
120
+ /**
121
+ * Patchwork: set by bridge to re-prime the recipe scheduler when the
122
+ * on-disk recipe set changes (install / save / delete). Lets cron-
123
+ * triggered recipes start firing without a bridge restart. Optional —
124
+ * tests + headless tooling leave it null; the install handler treats
125
+ * the callback as best-effort fire-and-forget.
126
+ */
127
+ onRecipesChangedFn = null;
113
128
  /** Patchwork: admin-controlled managed settings path (highest rule precedence). */
114
129
  managedSettingsPath = undefined;
115
130
  /** Effective bridge config path to update when dashboard saves driver changes. */
116
131
  bridgeConfigPath = undefined;
132
+ /**
133
+ * Shared secret for HMAC-SHA256 verification of POST /hooks/* requests
134
+ * carrying `X-Hub-Signature-256`. When null (default), HMAC auth is
135
+ * disabled and /hooks/* requires the bridge bearer token like every
136
+ * other route. Set by Bridge constructor from `config.webhookSecret`.
137
+ */
138
+ webhookSecret = null;
117
139
  /** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
118
140
  approvalGate = "off";
119
141
  /** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
@@ -150,6 +172,33 @@ export class Server extends EventEmitter {
150
172
  * the user's preference.
151
173
  */
152
174
  enableTimeOfDayAnomaly = false;
175
+ /**
176
+ * Patchwork: set by bridge to record a kill-switch audit trace.
177
+ * `/kill-switch` POST emits one entry on every state transition (no-op
178
+ * toggles do not emit). When unset, the handler logs via this.logger
179
+ * instead — see step 5 of issue #422.
180
+ *
181
+ * Trace encoding (v2-I6 from #422): we encode kill-switch events via
182
+ * the existing `DecisionTrace` schema rather than extending its
183
+ * `traceType` union, to keep schema migration off the kill-switch
184
+ * critical path. Fields used:
185
+ * ref = "kill-switch.writes"
186
+ * problem = "<short reason>" or "engage" / "release" if no reason
187
+ * solution = "ENGAGED at <ts>" or "RELEASED at <ts>"
188
+ * tags = ["kill-switch", "engage" | "release", "actor:http"]
189
+ *
190
+ * `ctxQueryTraces({tag: "kill-switch"})` returns the full audit
191
+ * history; pair with `tag: "engage"` / `tag: "release"` to filter
192
+ * direction.
193
+ */
194
+ recordKillSwitchTraceFn = null;
195
+ /**
196
+ * Set by bridge to broadcast a `kind: "kill-switch"` SSE event from
197
+ * `/stream` when the kill-switch state changes (issue #422 v2, pitfall I8).
198
+ * Bridge wires this to `activityLog.broadcastKillSwitch()` or an equivalent
199
+ * that notifies all active SSE listeners so the dashboard updates in <1s.
200
+ */
201
+ broadcastKillSwitchEventFn = null;
153
202
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
154
203
  webhookFn = null;
155
204
  /**
@@ -215,6 +264,22 @@ export class Server extends EventEmitter {
215
264
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
216
265
  launchQuickTaskFn = null;
217
266
  setRecipeEnabledFn = null;
267
+ /** Set by bridge to check if restart is safe (no in-flight tool calls). */
268
+ restartCheckFn = null;
269
+ /**
270
+ * Called when /restart decides it is safe to shut down. Defaults to
271
+ * `process.kill(process.pid, 'SIGTERM')`. Override in tests to a no-op so
272
+ * the Vitest runner process is not actually killed.
273
+ */
274
+ restartKillFn = () => process.kill(process.pid, "SIGTERM");
275
+ /**
276
+ * Called when /shutdown decides it is safe to exit. Defaults to
277
+ * `process.kill(process.pid, 'SIGTERM')`. Bridge overrides this to run its
278
+ * internal shutdown sequence directly — necessary on Windows where
279
+ * `process.kill(pid, 'SIGTERM')` is TerminateProcess and cleanup handlers
280
+ * never fire.
281
+ */
282
+ shutdownFn = () => process.kill(process.pid, "SIGTERM");
218
283
  /**
219
284
  * Attach an OAuth 2.0 Authorization Server.
220
285
  * When set, the bridge exposes:
@@ -418,6 +483,14 @@ export class Server extends EventEmitter {
418
483
  const isPhoneApprovalPath = req.method === "POST" &&
419
484
  /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
420
485
  !!req.headers["x-approval-token"];
486
+ // GitHub-style webhook bypass: when --webhook-secret is configured,
487
+ // POST /hooks/* requests carrying X-Hub-Signature-256 bypass the
488
+ // bearer-token gate. Signature itself is verified inside the
489
+ // /hooks/* handler after the body has been read.
490
+ const isHmacWebhookCandidate = req.method === "POST" &&
491
+ parsedUrl.pathname.startsWith("/hooks/") &&
492
+ !!req.headers["x-hub-signature-256"] &&
493
+ this.webhookSecret !== null;
421
494
  // Rate-limit the phone bypass surface. Only applies when this is
422
495
  // actually a phone-path request that's relying on the bypass — a
423
496
  // properly-authenticated bearer caller is unaffected. Counted +
@@ -464,7 +537,10 @@ export class Server extends EventEmitter {
464
537
  }
465
538
  }
466
539
  // oauthResolved is the bridge token if the OAuth token is valid; null otherwise
467
- if (!isStaticToken && !oauthResolved && !isPhoneApprovalPath) {
540
+ if (!isStaticToken &&
541
+ !oauthResolved &&
542
+ !isPhoneApprovalPath &&
543
+ !isHmacWebhookCandidate) {
468
544
  // RFC 6750: only include error= when a token was actually presented but invalid
469
545
  const tokenPresented = bearer.length > 0;
470
546
  const wwwAuth = this.oauthServer && this.oauthIssuerUrl
@@ -776,6 +852,33 @@ export class Server extends EventEmitter {
776
852
  respond413(res, HOOKS_BODY_CAP);
777
853
  return;
778
854
  }
855
+ // HMAC-SHA256 verification for GitHub-style webhooks. Signature is
856
+ // computed over the raw on-the-wire bytes (read.bytes), not the
857
+ // utf-8-decoded string — non-UTF-8 or denormalized payloads must
858
+ // round-trip identically to validate.
859
+ const sigHeader = req.headers["x-hub-signature-256"];
860
+ if (typeof sigHeader === "string" && sigHeader.length > 0) {
861
+ if (!this.webhookSecret) {
862
+ res.writeHead(401, { "Content-Type": "application/json" });
863
+ res.end(JSON.stringify({ error: "webhook_secret_not_configured" }));
864
+ return;
865
+ }
866
+ const expected = "sha256=" +
867
+ createHmac("sha256", this.webhookSecret)
868
+ .update(read.bytes)
869
+ .digest("hex");
870
+ const expectedBuf = Buffer.from(expected, "utf-8");
871
+ const providedBuf = Buffer.from(sigHeader, "utf-8");
872
+ // timingSafeEqual throws on length mismatch — length-check first
873
+ // so the constant-time path is only taken on equal-length inputs.
874
+ const sigOk = expectedBuf.length === providedBuf.length &&
875
+ timingSafeEqual(expectedBuf, providedBuf);
876
+ if (!sigOk) {
877
+ res.writeHead(401, { "Content-Type": "application/json" });
878
+ res.end(JSON.stringify({ error: "invalid_signature" }));
879
+ return;
880
+ }
881
+ }
779
882
  let payload;
780
883
  if (read.body.trim()) {
781
884
  try {
@@ -789,7 +892,7 @@ export class Server extends EventEmitter {
789
892
  res.writeHead(503, { "Content-Type": "application/json" });
790
893
  res.end(JSON.stringify({
791
894
  ok: false,
792
- error: "Webhooks unavailable — start bridge with --claude-driver subprocess",
895
+ error: "Webhooks unavailable — start bridge with --driver subprocess",
793
896
  }));
794
897
  return;
795
898
  }
@@ -992,9 +1095,12 @@ export class Server extends EventEmitter {
992
1095
  setRecipeEnabledFn: this.setRecipeEnabledFn,
993
1096
  runsFn: this.runsFn,
994
1097
  runDetailFn: this.runDetailFn,
1098
+ haltSummaryFn: this.haltSummaryFn,
1099
+ judgeSummaryFn: this.judgeSummaryFn,
995
1100
  runPlanFn: this.runPlanFn,
996
1101
  runReplayFn: this.runReplayFn,
997
1102
  runRecipeFn: this.runRecipeFn,
1103
+ onRecipesChangedFn: this.onRecipesChangedFn,
998
1104
  })) {
999
1105
  return;
1000
1106
  }
@@ -1261,6 +1367,253 @@ export class Server extends EventEmitter {
1261
1367
  }
1262
1368
  return;
1263
1369
  }
1370
+ // /kill-switch — dedicated endpoint for the global write-tier kill switch.
1371
+ // See issue #422 v2: not folded into /settings because kill-switch has
1372
+ // audit + idempotency + env-lock semantics nothing else on /settings has.
1373
+ //
1374
+ // POST {engage: boolean, reason?: string} → toggle; idempotent.
1375
+ // 200 {engaged, changed, locked: false} — accepted
1376
+ // 200 {engaged, changed: false, locked: false} — no-op (already in that state)
1377
+ // 409 {error: "env_locked", flag, frozenValue, lockedReason}
1378
+ // — env-locked, cannot toggle
1379
+ // 400 {error: "invalid_request"} — malformed body
1380
+ //
1381
+ // GET → status. 200 {engaged, locked, lockedReason?, lockedValue?}
1382
+ //
1383
+ // Audit emit: state transitions log to this.logger.info (always)
1384
+ // AND record via this.recordKillSwitchTraceFn (when wired by the
1385
+ // bridge — see src/bridge.ts where it threads decisionTraceLog
1386
+ // through to ~/.patchwork/decision_traces.jsonl). Tests / headless
1387
+ // contexts that don't wire the callback still get the log line.
1388
+ if (parsedUrl.pathname === "/kill-switch") {
1389
+ if (req.method === "GET") {
1390
+ const engaged = isWriteKillSwitchActive();
1391
+ const locked = isEnvLockedFor(KILL_SWITCH_WRITES);
1392
+ const body = { engaged, locked };
1393
+ if (locked) {
1394
+ const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
1395
+ body.lockedValue = lockedValue;
1396
+ body.lockedReason = `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`;
1397
+ }
1398
+ res.writeHead(200, { "Content-Type": "application/json" });
1399
+ res.end(JSON.stringify(body));
1400
+ return;
1401
+ }
1402
+ if (req.method === "POST") {
1403
+ // 1 KB — body is `{engage: bool, reason?: string}`; reason is a
1404
+ // short audit note, 1 KB is generous.
1405
+ const KS_BODY_CAP = 1 * 1024;
1406
+ const parsed = await readJsonBody(req, KS_BODY_CAP);
1407
+ if (!parsed.ok) {
1408
+ if (parsed.code === "too_large") {
1409
+ respond413(res, KS_BODY_CAP);
1410
+ return;
1411
+ }
1412
+ res.writeHead(400, { "Content-Type": "application/json" });
1413
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1414
+ return;
1415
+ }
1416
+ const body = parsed.value ?? {};
1417
+ if (typeof body.engage !== "boolean") {
1418
+ res.writeHead(400, { "Content-Type": "application/json" });
1419
+ res.end(JSON.stringify({
1420
+ error: "invalid_request",
1421
+ reason: "engage must be boolean",
1422
+ }));
1423
+ return;
1424
+ }
1425
+ const reason = typeof body.reason === "string" && body.reason.trim().length > 0
1426
+ ? body.reason.trim().slice(0, 500)
1427
+ : undefined;
1428
+ // v2-B2 + I3: surface env-lock conflict as structured 409 so CLI
1429
+ // + dashboard can distinguish "you sent garbage" from
1430
+ // "policy-locked by sysadmin via PATCHWORK_FLAG_*".
1431
+ if (isEnvLockedFor(KILL_SWITCH_WRITES)) {
1432
+ const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
1433
+ res.writeHead(409, { "Content-Type": "application/json" });
1434
+ res.end(JSON.stringify({
1435
+ error: "env_locked",
1436
+ flag: KILL_SWITCH_WRITES,
1437
+ frozenValue: lockedValue,
1438
+ lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`,
1439
+ }));
1440
+ return;
1441
+ }
1442
+ // v2-I12: idempotent. State transitions emit audit; no-ops don't.
1443
+ const prev = isWriteKillSwitchActive();
1444
+ const next = body.engage;
1445
+ const changed = prev !== next;
1446
+ if (changed) {
1447
+ try {
1448
+ setFlag(KILL_SWITCH_WRITES, next, true);
1449
+ }
1450
+ catch (err) {
1451
+ // Belt-and-suspenders: setFlag now throws EnvLockedFlagError if
1452
+ // the flag was env-locked (we already checked isEnvLockedFor above,
1453
+ // but a race with lockKillSwitchEnv() in tests warrants this).
1454
+ if (err instanceof EnvLockedFlagError) {
1455
+ res.writeHead(409, { "Content-Type": "application/json" });
1456
+ res.end(JSON.stringify({
1457
+ error: "env_locked",
1458
+ flag: KILL_SWITCH_WRITES,
1459
+ frozenValue: err.frozenValue,
1460
+ lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${err.frozenValue ? "1" : "0"} at bridge startup`,
1461
+ }));
1462
+ return;
1463
+ }
1464
+ throw err;
1465
+ }
1466
+ // v2-I6: audit emit on every state transition; no-ops skip.
1467
+ // The bridge wires recordKillSwitchTraceFn to write a
1468
+ // DecisionTrace entry to ~/.patchwork/decision_traces.jsonl
1469
+ // (see src/bridge.ts). The logger.info line stays as a
1470
+ // secondary signal in the bridge log and is the only output
1471
+ // when the trace fn is unset (tests, headless contexts).
1472
+ this.logger.info(`[kill-switch] ${next ? "ENGAGED" : "RELEASED"}${reason ? ` (reason: ${reason})` : ""} — actor=http`);
1473
+ this.recordKillSwitchTraceFn?.({
1474
+ engaged: next,
1475
+ reason,
1476
+ ts: Date.now(),
1477
+ });
1478
+ // v2-I8: broadcast SSE kind:"kill-switch" so dashboard updates
1479
+ // in <1s without changing the poll cadence.
1480
+ this.broadcastKillSwitchEventFn?.(next, reason);
1481
+ }
1482
+ res.writeHead(200, { "Content-Type": "application/json" });
1483
+ res.end(JSON.stringify({
1484
+ engaged: next,
1485
+ changed,
1486
+ locked: false,
1487
+ }));
1488
+ return;
1489
+ }
1490
+ }
1491
+ // /telemetry-prefs — read/write per-flag telemetry preferences.
1492
+ // GET → {crashReports, usageStats, localDiagnostics}
1493
+ // POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
1494
+ if (parsedUrl.pathname === "/telemetry-prefs") {
1495
+ if (req.method === "GET") {
1496
+ const prefs = getTelemetryPrefs();
1497
+ const all = getAnalyticsPrefsAll();
1498
+ const response = { ...prefs };
1499
+ if (all?.lastSentAt !== undefined) {
1500
+ response.lastSentAt = all.lastSentAt;
1501
+ }
1502
+ res.writeHead(200, { "Content-Type": "application/json" });
1503
+ res.end(JSON.stringify(response));
1504
+ return;
1505
+ }
1506
+ if (req.method === "POST") {
1507
+ const TP_BODY_CAP = 1 * 1024;
1508
+ const parsed = await readJsonBody(req, TP_BODY_CAP);
1509
+ if (!parsed.ok) {
1510
+ if (parsed.code === "too_large") {
1511
+ respond413(res, TP_BODY_CAP);
1512
+ return;
1513
+ }
1514
+ res.writeHead(400, { "Content-Type": "application/json" });
1515
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1516
+ return;
1517
+ }
1518
+ const body = parsed.value ?? {};
1519
+ const update = {};
1520
+ if (typeof body.crashReports === "boolean") {
1521
+ update.crashReports = body.crashReports;
1522
+ }
1523
+ if (typeof body.usageStats === "boolean") {
1524
+ update.usageStats = body.usageStats;
1525
+ }
1526
+ if (typeof body.localDiagnostics === "boolean") {
1527
+ update.localDiagnostics = body.localDiagnostics;
1528
+ }
1529
+ setTelemetryPrefs(update);
1530
+ const prefs = getTelemetryPrefs();
1531
+ res.writeHead(200, { "Content-Type": "application/json" });
1532
+ res.end(JSON.stringify(prefs));
1533
+ return;
1534
+ }
1535
+ }
1536
+ // /restart — graceful bridge restart endpoint.
1537
+ // POST → triggers SIGTERM if no active work; returns 409 if busy.
1538
+ // Safety checks: rejects restart if sessions have in-flight tool calls.
1539
+ if (parsedUrl.pathname === "/restart" && req.method === "POST") {
1540
+ if (!this.restartCheckFn) {
1541
+ res.writeHead(503, { "Content-Type": "application/json" });
1542
+ res.end(JSON.stringify({
1543
+ ok: false,
1544
+ error: "restart_unavailable",
1545
+ reason: "Restart endpoint not configured",
1546
+ }));
1547
+ return;
1548
+ }
1549
+ const check = this.restartCheckFn();
1550
+ // Reject restart if there's active work
1551
+ if (check.inFlightCalls > 0) {
1552
+ 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"}`);
1553
+ res.writeHead(409, { "Content-Type": "application/json" });
1554
+ res.end(JSON.stringify({
1555
+ ok: false,
1556
+ error: "restart_blocked",
1557
+ reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
1558
+ activeSessions: check.totalSessions,
1559
+ inFlightCalls: check.inFlightCalls,
1560
+ busySessions: check.busySessions,
1561
+ }));
1562
+ return;
1563
+ }
1564
+ // Safe to restart — log and trigger SIGTERM
1565
+ this.logger.info(`[/restart] Initiating graceful restart — ${check.totalSessions} session${check.totalSessions === 1 ? "" : "s"}, 0 in-flight calls`);
1566
+ res.writeHead(202, { "Content-Type": "application/json" });
1567
+ res.end(JSON.stringify({
1568
+ ok: true,
1569
+ message: "Restart initiated. Bridge will shut down gracefully.",
1570
+ activeSessions: check.totalSessions,
1571
+ }));
1572
+ // Trigger shutdown after response is sent (100ms delay to ensure response delivery).
1573
+ // Uses this.restartKillFn so tests can override without killing the runner.
1574
+ const killFn = this.restartKillFn;
1575
+ setTimeout(() => {
1576
+ this.logger.info("[/restart] Sending SIGTERM to self");
1577
+ killFn();
1578
+ }, 100);
1579
+ return;
1580
+ }
1581
+ // /shutdown — graceful exit endpoint. POST → triggers the bridge's
1582
+ // internal shutdown sequence (lockfile unlink, HTTP close, telemetry
1583
+ // flush). Same in-flight safety check as /restart; pass `?force=1` to
1584
+ // skip it. Necessary on Windows where `process.kill(pid, 'SIGTERM')`
1585
+ // is TerminateProcess and cleanup handlers never fire — the bridge
1586
+ // wires `shutdownFn` to call the shutdown sequence directly.
1587
+ if (parsedUrl.pathname === "/shutdown" && req.method === "POST") {
1588
+ const force = parsedUrl.searchParams.get("force") === "1";
1589
+ if (!force && this.restartCheckFn) {
1590
+ const check = this.restartCheckFn();
1591
+ if (check.inFlightCalls > 0) {
1592
+ this.logger.warn(`[/shutdown] Rejected — ${check.inFlightCalls} in-flight tool call${check.inFlightCalls === 1 ? "" : "s"}`);
1593
+ res.writeHead(409, { "Content-Type": "application/json" });
1594
+ res.end(JSON.stringify({
1595
+ ok: false,
1596
+ error: "shutdown_blocked",
1597
+ reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
1598
+ inFlightCalls: check.inFlightCalls,
1599
+ busySessions: check.busySessions,
1600
+ }));
1601
+ return;
1602
+ }
1603
+ }
1604
+ this.logger.info("[/shutdown] Initiating graceful shutdown");
1605
+ res.writeHead(202, { "Content-Type": "application/json" });
1606
+ res.end(JSON.stringify({
1607
+ ok: true,
1608
+ message: "Shutdown initiated.",
1609
+ }));
1610
+ const shutdownFn = this.shutdownFn;
1611
+ setTimeout(() => {
1612
+ this.logger.info("[/shutdown] Calling bridge shutdown sequence");
1613
+ shutdownFn();
1614
+ }, 100);
1615
+ return;
1616
+ }
1264
1617
  // CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
1265
1618
  if (parsedUrl.pathname === "/notify" && req.method === "POST") {
1266
1619
  // 8 KB — notify payloads carry an event name + small arg map
@@ -1409,7 +1762,7 @@ export class Server extends EventEmitter {
1409
1762
  res.writeHead(503, { "Content-Type": "application/json" });
1410
1763
  res.end(JSON.stringify({
1411
1764
  ok: false,
1412
- error: "Quick tasks unavailable — requires --claude-driver subprocess",
1765
+ error: "Quick tasks unavailable — requires --driver subprocess",
1413
1766
  }));
1414
1767
  return;
1415
1768
  }