patchwork-os 0.2.0-beta.1 → 0.2.0-beta.3

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 (191) hide show
  1. package/README.bridge.md +5 -5
  2. package/README.md +156 -12
  3. package/deploy/deploy-dashboard.sh +25 -1
  4. package/deploy/macos/README.md +153 -0
  5. package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
  6. package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
  7. package/deploy/macos/install-mac-bridge.sh +244 -0
  8. package/deploy/macos/uninstall-mac-bridge.sh +22 -0
  9. package/dist/activityLog.d.ts +6 -0
  10. package/dist/activityLog.js +8 -0
  11. package/dist/activityLog.js.map +1 -1
  12. package/dist/analyticsPrefs.d.ts +35 -2
  13. package/dist/analyticsPrefs.js +120 -21
  14. package/dist/analyticsPrefs.js.map +1 -1
  15. package/dist/analyticsSend.js +5 -1
  16. package/dist/analyticsSend.js.map +1 -1
  17. package/dist/approvalHttp.d.ts +14 -0
  18. package/dist/approvalHttp.js +172 -1
  19. package/dist/approvalHttp.js.map +1 -1
  20. package/dist/approvalQueue.d.ts +27 -2
  21. package/dist/approvalQueue.js +44 -7
  22. package/dist/approvalQueue.js.map +1 -1
  23. package/dist/automation.d.ts +34 -3
  24. package/dist/automation.js +85 -10
  25. package/dist/automation.js.map +1 -1
  26. package/dist/bridge.d.ts +2 -0
  27. package/dist/bridge.js +114 -8
  28. package/dist/bridge.js.map +1 -1
  29. package/dist/bridgeLockDiscovery.d.ts +27 -1
  30. package/dist/bridgeLockDiscovery.js +37 -11
  31. package/dist/bridgeLockDiscovery.js.map +1 -1
  32. package/dist/claudeOrchestrator.js +5 -2
  33. package/dist/claudeOrchestrator.js.map +1 -1
  34. package/dist/commands/patchworkInit.d.ts +5 -0
  35. package/dist/commands/patchworkInit.js +86 -7
  36. package/dist/commands/patchworkInit.js.map +1 -1
  37. package/dist/commands/recipe.d.ts +51 -0
  38. package/dist/commands/recipe.js +363 -3
  39. package/dist/commands/recipe.js.map +1 -1
  40. package/dist/commands/recipeInstall.js +6 -3
  41. package/dist/commands/recipeInstall.js.map +1 -1
  42. package/dist/commands/task.js +2 -2
  43. package/dist/commands/task.js.map +1 -1
  44. package/dist/config.d.ts +17 -2
  45. package/dist/config.js +54 -17
  46. package/dist/config.js.map +1 -1
  47. package/dist/connectors/baseConnector.js +25 -3
  48. package/dist/connectors/baseConnector.js.map +1 -1
  49. package/dist/connectors/tokenStorage.js +46 -10
  50. package/dist/connectors/tokenStorage.js.map +1 -1
  51. package/dist/drivers/gemini/index.d.ts +22 -0
  52. package/dist/drivers/gemini/index.js +240 -129
  53. package/dist/drivers/gemini/index.js.map +1 -1
  54. package/dist/drivers/local/index.d.ts +17 -0
  55. package/dist/drivers/local/index.js +99 -0
  56. package/dist/drivers/local/index.js.map +1 -1
  57. package/dist/drivers/openai/index.js +30 -2
  58. package/dist/drivers/openai/index.js.map +1 -1
  59. package/dist/extensionClient.d.ts +8 -0
  60. package/dist/extensionClient.js +24 -2
  61. package/dist/extensionClient.js.map +1 -1
  62. package/dist/featureFlags.d.ts +76 -0
  63. package/dist/featureFlags.js +166 -2
  64. package/dist/featureFlags.js.map +1 -1
  65. package/dist/fp/automationInterpreter.d.ts +9 -1
  66. package/dist/fp/automationInterpreter.js +151 -34
  67. package/dist/fp/automationInterpreter.js.map +1 -1
  68. package/dist/fp/automationProgram.d.ts +30 -0
  69. package/dist/fp/automationProgram.js.map +1 -1
  70. package/dist/fp/automationState.d.ts +23 -4
  71. package/dist/fp/automationState.js +28 -4
  72. package/dist/fp/automationState.js.map +1 -1
  73. package/dist/fp/interpreterContext.d.ts +66 -1
  74. package/dist/fp/interpreterContext.js +140 -1
  75. package/dist/fp/interpreterContext.js.map +1 -1
  76. package/dist/fp/policyParser.js +29 -1
  77. package/dist/fp/policyParser.js.map +1 -1
  78. package/dist/index.js +765 -69
  79. package/dist/index.js.map +1 -1
  80. package/dist/lockfile.js +4 -1
  81. package/dist/lockfile.js.map +1 -1
  82. package/dist/oauth.d.ts +9 -0
  83. package/dist/oauth.js +33 -0
  84. package/dist/oauth.js.map +1 -1
  85. package/dist/patchworkConfig.d.ts +16 -0
  86. package/dist/patchworkConfig.js +5 -0
  87. package/dist/patchworkConfig.js.map +1 -1
  88. package/dist/recipeOrchestration.js +35 -1
  89. package/dist/recipeOrchestration.js.map +1 -1
  90. package/dist/recipeRoutes.d.ts +36 -0
  91. package/dist/recipeRoutes.js +231 -32
  92. package/dist/recipeRoutes.js.map +1 -1
  93. package/dist/recipes/agentExecutor.d.ts +25 -5
  94. package/dist/recipes/agentExecutor.js.map +1 -1
  95. package/dist/recipes/chainedRunner.js +16 -2
  96. package/dist/recipes/chainedRunner.js.map +1 -1
  97. package/dist/recipes/connectorPreflight.d.ts +53 -0
  98. package/dist/recipes/connectorPreflight.js +79 -0
  99. package/dist/recipes/connectorPreflight.js.map +1 -0
  100. package/dist/recipes/githubInstallSource.d.ts +62 -0
  101. package/dist/recipes/githubInstallSource.js +125 -0
  102. package/dist/recipes/githubInstallSource.js.map +1 -0
  103. package/dist/recipes/haltCategory.d.ts +80 -0
  104. package/dist/recipes/haltCategory.js +125 -0
  105. package/dist/recipes/haltCategory.js.map +1 -0
  106. package/dist/recipes/idempotencyKey.d.ts +126 -0
  107. package/dist/recipes/idempotencyKey.js +298 -0
  108. package/dist/recipes/idempotencyKey.js.map +1 -0
  109. package/dist/recipes/judgeSummary.d.ts +50 -0
  110. package/dist/recipes/judgeSummary.js +47 -0
  111. package/dist/recipes/judgeSummary.js.map +1 -0
  112. package/dist/recipes/judgeVerdict.d.ts +48 -0
  113. package/dist/recipes/judgeVerdict.js +174 -0
  114. package/dist/recipes/judgeVerdict.js.map +1 -0
  115. package/dist/recipes/migrations/index.d.ts +9 -0
  116. package/dist/recipes/migrations/index.js +133 -0
  117. package/dist/recipes/migrations/index.js.map +1 -1
  118. package/dist/recipes/runBudget.d.ts +70 -0
  119. package/dist/recipes/runBudget.js +109 -0
  120. package/dist/recipes/runBudget.js.map +1 -0
  121. package/dist/recipes/scheduler.d.ts +7 -0
  122. package/dist/recipes/scheduler.js +31 -14
  123. package/dist/recipes/scheduler.js.map +1 -1
  124. package/dist/recipes/schema.d.ts +36 -0
  125. package/dist/recipes/toolRegistry.js +19 -0
  126. package/dist/recipes/toolRegistry.js.map +1 -1
  127. package/dist/recipes/tools/file.js +5 -2
  128. package/dist/recipes/tools/file.js.map +1 -1
  129. package/dist/recipes/tools/http.d.ts +10 -0
  130. package/dist/recipes/tools/http.js +176 -0
  131. package/dist/recipes/tools/http.js.map +1 -0
  132. package/dist/recipes/tools/index.d.ts +1 -0
  133. package/dist/recipes/tools/index.js +1 -0
  134. package/dist/recipes/tools/index.js.map +1 -1
  135. package/dist/recipes/validation.js +1 -1
  136. package/dist/recipes/validation.js.map +1 -1
  137. package/dist/recipes/yamlRunner.d.ts +88 -7
  138. package/dist/recipes/yamlRunner.js +216 -25
  139. package/dist/recipes/yamlRunner.js.map +1 -1
  140. package/dist/recipesHttp.d.ts +3 -1
  141. package/dist/recipesHttp.js +9 -3
  142. package/dist/recipesHttp.js.map +1 -1
  143. package/dist/runLog.d.ts +28 -0
  144. package/dist/runLog.js +5 -0
  145. package/dist/runLog.js.map +1 -1
  146. package/dist/server.d.ts +111 -1
  147. package/dist/server.js +480 -6
  148. package/dist/server.js.map +1 -1
  149. package/dist/streamableHttp.d.ts +9 -4
  150. package/dist/streamableHttp.js +34 -15
  151. package/dist/streamableHttp.js.map +1 -1
  152. package/dist/tools/bridgeDoctor.js +6 -2
  153. package/dist/tools/bridgeDoctor.js.map +1 -1
  154. package/dist/tools/ccRoutines.d.ts +221 -0
  155. package/dist/tools/ccRoutines.js +264 -0
  156. package/dist/tools/ccRoutines.js.map +1 -0
  157. package/dist/tools/getCodeCoverage.js +7 -3
  158. package/dist/tools/getCodeCoverage.js.map +1 -1
  159. package/dist/tools/index.js +6 -0
  160. package/dist/tools/index.js.map +1 -1
  161. package/dist/tools/openInBrowser.js +6 -1
  162. package/dist/tools/openInBrowser.js.map +1 -1
  163. package/dist/tools/recentTracesDigest.js +56 -11
  164. package/dist/tools/recentTracesDigest.js.map +1 -1
  165. package/dist/tools/testRunners/vitestJest.js +3 -1
  166. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  167. package/dist/tools/utils.js +13 -7
  168. package/dist/tools/utils.js.map +1 -1
  169. package/package.json +16 -5
  170. package/scripts/postinstall.mjs +27 -0
  171. package/scripts/smoke/run-all.mjs +162 -0
  172. package/scripts/start-all.mjs +513 -0
  173. package/scripts/start-all.ps1 +209 -0
  174. package/scripts/start-all.sh +73 -17
  175. package/scripts/start-orchestrator.ps1 +158 -0
  176. package/scripts/start-remote.mjs +122 -0
  177. package/templates/automation-policies/recipe-authoring.json +1 -1
  178. package/templates/automation-policies/security-first.json +1 -1
  179. package/templates/automation-policies/strict-lint.json +1 -1
  180. package/templates/automation-policies/test-driven.json +1 -1
  181. package/templates/automation-policy.example.json +1 -1
  182. package/templates/co.patchwork-os.bridge.plist +1 -1
  183. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  184. package/templates/recipes/ctx-loop-test.yaml +1 -1
  185. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  186. package/dist/commands/marketplace.d.ts +0 -16
  187. package/dist/commands/marketplace.js +0 -32
  188. package/dist/commands/marketplace.js.map +0 -1
  189. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  190. package/dist/recipes/legacyRecipeCompat.js +0 -131
  191. 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";
@@ -49,6 +52,7 @@ export class Server extends EventEmitter {
49
52
  logger;
50
53
  extraCorsOrigins;
51
54
  pingIntervalMs;
55
+ trustedProxies;
52
56
  httpServer;
53
57
  wss;
54
58
  pingInterval = null;
@@ -102,6 +106,10 @@ export class Server extends EventEmitter {
102
106
  runsFn = null;
103
107
  /** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
104
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;
105
113
  /** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
106
114
  runPlanFn = null;
107
115
  /** Patchwork (VD-4): mocked replay of an existing run. Returns the new
@@ -109,10 +117,25 @@ export class Server extends EventEmitter {
109
117
  runReplayFn = null;
110
118
  /** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
111
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;
112
128
  /** Patchwork: admin-controlled managed settings path (highest rule precedence). */
113
129
  managedSettingsPath = undefined;
114
130
  /** Effective bridge config path to update when dashboard saves driver changes. */
115
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;
116
139
  /** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
117
140
  approvalGate = "off";
118
141
  /** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
@@ -123,6 +146,10 @@ export class Server extends EventEmitter {
123
146
  pushServiceToken = undefined;
124
147
  /** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
125
148
  pushServiceBaseUrl = undefined;
149
+ /** Patchwork: ntfy.sh topic for direct phone-path approvals via action buttons. */
150
+ ntfyTopic = undefined;
151
+ /** Patchwork: ntfy server (default https://ntfy.sh; override for self-hosted). */
152
+ ntfyServer = undefined;
126
153
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
127
154
  onApprovalDecision = undefined;
128
155
  /**
@@ -145,6 +172,33 @@ export class Server extends EventEmitter {
145
172
  * the user's preference.
146
173
  */
147
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;
148
202
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
149
203
  webhookFn = null;
150
204
  /**
@@ -157,6 +211,36 @@ export class Server extends EventEmitter {
157
211
  */
158
212
  webhookPayloads = new Map();
159
213
  static MAX_WEBHOOK_PAYLOADS = 5;
214
+ /**
215
+ * Per-list FIFO bounds the per-recipe payload count, but the Map itself
216
+ * needs a key cap so a recipe-rename loop or a scanner hitting many
217
+ * distinct legitimate hookPaths can't grow `webhookPayloads.size`
218
+ * without bound. 1000 is generous for any realistic operator deployment
219
+ * (5 payloads × 1000 recipes = ~5000 entries × ~10 KB each = 50 MB).
220
+ * On overflow, evict the oldest *recipe* (Map iteration order is
221
+ * insertion order in JS), not the largest list.
222
+ */
223
+ static MAX_WEBHOOK_RECIPES = 1000;
224
+ /**
225
+ * Per-IP rate limit on the unauthenticated phone-path approval endpoints
226
+ * (`POST /approve/:callId` and `POST /reject/:callId` when
227
+ * `x-approval-token` is present). The auth gate intentionally bypasses
228
+ * bearer auth for those paths so a phone can dispatch without a bridge
229
+ * token; without rate limiting, an attacker who learns a callId (via
230
+ * webhook target leak, bearer-authed `/approvals` reader, etc.) can
231
+ * spray garbage tokens to DoS the legitimate approver. PR #380 bumped
232
+ * the per-callId failure cap to 1000 (memory bound, not security
233
+ * bound); this is the HTTP-layer spray defense flagged as the proper
234
+ * fix in that commit.
235
+ *
236
+ * 60 attempts per IP per minute is generous for legitimate retries
237
+ * (phone re-tap, network flake) and tight enough to bound brute-force
238
+ * attempts during the 5-minute approval TTL to 300 — well within the
239
+ * per-callId cap, so no legit retry budget is consumed by sprayers.
240
+ */
241
+ approvalIpCounts = new Map();
242
+ static APPROVAL_IP_MAX = 60;
243
+ static APPROVAL_IP_WINDOW_MS = 60_000;
160
244
  /** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
161
245
  httpMcpHandler = null;
162
246
  /** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
@@ -180,6 +264,8 @@ export class Server extends EventEmitter {
180
264
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
181
265
  launchQuickTaskFn = null;
182
266
  setRecipeEnabledFn = null;
267
+ /** Set by bridge to check if restart is safe (no in-flight tool calls). */
268
+ restartCheckFn = null;
183
269
  /**
184
270
  * Attach an OAuth 2.0 Authorization Server.
185
271
  * When set, the bridge exposes:
@@ -196,12 +282,19 @@ export class Server extends EventEmitter {
196
282
  }
197
283
  /** Hosts accepted in the WebSocket upgrade Host header (DNS-rebinding guard). */
198
284
  allowedHosts;
199
- constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000) {
285
+ constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000,
286
+ // Reverse-proxy hops whose X-Forwarded-For values we trust. Empty by
287
+ // default — the per-IP rate limiter buckets on the direct socket peer.
288
+ // Behind nginx/Caddy/Cloudflare set this to the proxy's IP so distinct
289
+ // real clients get distinct buckets; otherwise every request looks
290
+ // like 127.0.0.1 and a single sprayer DoSes the legit approver.
291
+ trustedProxies = []) {
200
292
  super();
201
293
  this.authToken = authToken;
202
294
  this.logger = logger;
203
295
  this.extraCorsOrigins = extraCorsOrigins;
204
296
  this.pingIntervalMs = pingIntervalMs;
297
+ this.trustedProxies = trustedProxies;
205
298
  // Defense-in-depth: ensure token is non-empty so timingSafeTokenCompare
206
299
  // cannot accept a blank Authorization header against an empty token.
207
300
  if (authToken.length === 0) {
@@ -376,8 +469,64 @@ export class Server extends EventEmitter {
376
469
  const isPhoneApprovalPath = req.method === "POST" &&
377
470
  /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
378
471
  !!req.headers["x-approval-token"];
472
+ // GitHub-style webhook bypass: when --webhook-secret is configured,
473
+ // POST /hooks/* requests carrying X-Hub-Signature-256 bypass the
474
+ // bearer-token gate. Signature itself is verified inside the
475
+ // /hooks/* handler after the body has been read.
476
+ const isHmacWebhookCandidate = req.method === "POST" &&
477
+ parsedUrl.pathname.startsWith("/hooks/") &&
478
+ !!req.headers["x-hub-signature-256"] &&
479
+ this.webhookSecret !== null;
480
+ // Rate-limit the phone bypass surface. Only applies when this is
481
+ // actually a phone-path request that's relying on the bypass — a
482
+ // properly-authenticated bearer caller is unaffected. Counted +
483
+ // checked *before* dispatch so a sprayer can't burn the per-callId
484
+ // failure budget at line-rate.
485
+ if (isPhoneApprovalPath && !isStaticToken && !oauthResolved) {
486
+ const remoteIp = this.getClientIp(req);
487
+ if (!remoteIp) {
488
+ // Fail closed — without an attributable IP we cannot bucket
489
+ // safely, and the original "unknown" string was a single shared
490
+ // counter every IP-less request rolled up into.
491
+ res.writeHead(400, { "Content-Type": "application/json" });
492
+ res.end(JSON.stringify({
493
+ error: "bad_request",
494
+ error_description: "could not determine client IP",
495
+ }));
496
+ return;
497
+ }
498
+ const now = Date.now();
499
+ const entry = this.approvalIpCounts.get(remoteIp);
500
+ if (entry && now - entry.windowStart < Server.APPROVAL_IP_WINDOW_MS) {
501
+ entry.count++;
502
+ if (entry.count > Server.APPROVAL_IP_MAX) {
503
+ res.writeHead(429, { "Content-Type": "application/json" });
504
+ res.end(JSON.stringify({
505
+ error: "too_many_requests",
506
+ error_description: "per-IP approval endpoint rate limit reached",
507
+ }));
508
+ return;
509
+ }
510
+ }
511
+ else {
512
+ this.approvalIpCounts.set(remoteIp, { count: 1, windowStart: now });
513
+ }
514
+ // GC stale entries opportunistically — bounded growth alongside
515
+ // the same Map. 200 is well above any legitimate concurrent IP
516
+ // count; well below memory pressure.
517
+ if (this.approvalIpCounts.size > 200) {
518
+ for (const [k, v] of this.approvalIpCounts) {
519
+ if (now - v.windowStart > Server.APPROVAL_IP_WINDOW_MS) {
520
+ this.approvalIpCounts.delete(k);
521
+ }
522
+ }
523
+ }
524
+ }
379
525
  // oauthResolved is the bridge token if the OAuth token is valid; null otherwise
380
- if (!isStaticToken && !oauthResolved && !isPhoneApprovalPath) {
526
+ if (!isStaticToken &&
527
+ !oauthResolved &&
528
+ !isPhoneApprovalPath &&
529
+ !isHmacWebhookCandidate) {
381
530
  // RFC 6750: only include error= when a token was actually presented but invalid
382
531
  const tokenPresented = bearer.length > 0;
383
532
  const wwwAuth = this.oauthServer && this.oauthIssuerUrl
@@ -689,6 +838,34 @@ export class Server extends EventEmitter {
689
838
  respond413(res, HOOKS_BODY_CAP);
690
839
  return;
691
840
  }
841
+ // HMAC-SHA256 verification for GitHub-style webhooks. The signature
842
+ // must be computed over the raw request bytes — readBodyWithCap
843
+ // utf-8-decodes the body, so we re-encode here. The byte sequence
844
+ // round-trips identically for any valid utf-8 input (which JSON is).
845
+ const sigHeader = req.headers["x-hub-signature-256"];
846
+ if (typeof sigHeader === "string" && sigHeader.length > 0) {
847
+ if (!this.webhookSecret) {
848
+ res.writeHead(401, { "Content-Type": "application/json" });
849
+ res.end(JSON.stringify({ error: "webhook_secret_not_configured" }));
850
+ return;
851
+ }
852
+ const rawBody = Buffer.from(read.body, "utf-8");
853
+ const expected = "sha256=" +
854
+ createHmac("sha256", this.webhookSecret)
855
+ .update(rawBody)
856
+ .digest("hex");
857
+ const expectedBuf = Buffer.from(expected, "utf-8");
858
+ const providedBuf = Buffer.from(sigHeader, "utf-8");
859
+ // timingSafeEqual throws on length mismatch — length-check first
860
+ // so the constant-time path is only taken on equal-length inputs.
861
+ const sigOk = expectedBuf.length === providedBuf.length &&
862
+ timingSafeEqual(expectedBuf, providedBuf);
863
+ if (!sigOk) {
864
+ res.writeHead(401, { "Content-Type": "application/json" });
865
+ res.end(JSON.stringify({ error: "invalid_signature" }));
866
+ return;
867
+ }
868
+ }
692
869
  let payload;
693
870
  if (read.body.trim()) {
694
871
  try {
@@ -702,7 +879,7 @@ export class Server extends EventEmitter {
702
879
  res.writeHead(503, { "Content-Type": "application/json" });
703
880
  res.end(JSON.stringify({
704
881
  ok: false,
705
- error: "Webhooks unavailable — start bridge with --claude-driver subprocess",
882
+ error: "Webhooks unavailable — start bridge with --driver subprocess",
706
883
  }));
707
884
  return;
708
885
  }
@@ -728,6 +905,19 @@ export class Server extends EventEmitter {
728
905
  if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
729
906
  existing.length = Server.MAX_WEBHOOK_PAYLOADS;
730
907
  }
908
+ // LRU eviction: Map.set() on an existing key keeps original
909
+ // insertion-order position (per spec), so a hot recipe registered
910
+ // at startup would otherwise be evicted in favor of a cold
911
+ // scanner-spam recipe just because it was registered earlier.
912
+ // Delete + re-set re-anchors the key to the END of insertion
913
+ // order, making `.keys().next()` correctly point at the
914
+ // least-recently-fired recipe.
915
+ this.webhookPayloads.delete(hookPath);
916
+ if (this.webhookPayloads.size >= Server.MAX_WEBHOOK_RECIPES) {
917
+ const oldest = this.webhookPayloads.keys().next().value;
918
+ if (oldest !== undefined)
919
+ this.webhookPayloads.delete(oldest);
920
+ }
731
921
  this.webhookPayloads.set(hookPath, existing);
732
922
  }
733
923
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -892,9 +1082,12 @@ export class Server extends EventEmitter {
892
1082
  setRecipeEnabledFn: this.setRecipeEnabledFn,
893
1083
  runsFn: this.runsFn,
894
1084
  runDetailFn: this.runDetailFn,
1085
+ haltSummaryFn: this.haltSummaryFn,
1086
+ judgeSummaryFn: this.judgeSummaryFn,
895
1087
  runPlanFn: this.runPlanFn,
896
1088
  runReplayFn: this.runReplayFn,
897
1089
  runRecipeFn: this.runRecipeFn,
1090
+ onRecipesChangedFn: this.onRecipesChangedFn,
898
1091
  })) {
899
1092
  return;
900
1093
  }
@@ -1106,8 +1299,46 @@ export class Server extends EventEmitter {
1106
1299
  this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1107
1300
  }
1108
1301
  if (body.pushServiceBaseUrl !== undefined) {
1109
- this.pushServiceBaseUrl =
1110
- body.pushServiceBaseUrl.trim() || undefined;
1302
+ const baseUrl = body.pushServiceBaseUrl.trim();
1303
+ // pushServiceBaseUrl is the bridge callback origin embedded in
1304
+ // the SW's approveUrl/rejectUrl. If it can be set to plain
1305
+ // http:// or to a host the operator didn't intend, the SW will
1306
+ // POST the one-shot approvalToken there — letting an attacker
1307
+ // who sets this redirect every approval to attacker.tld and
1308
+ // replay tokens to the real bridge for silent auto-approve.
1309
+ if (baseUrl && !baseUrl.startsWith("https://")) {
1310
+ res.writeHead(400, { "Content-Type": "application/json" });
1311
+ res.end(JSON.stringify({ error: "pushServiceBaseUrl must be HTTPS" }));
1312
+ return;
1313
+ }
1314
+ this.pushServiceBaseUrl = baseUrl || undefined;
1315
+ }
1316
+ if (body.ntfyTopic !== undefined) {
1317
+ const topic = body.ntfyTopic.trim();
1318
+ // Topic acts as a bearer token on the public ntfy.sh server —
1319
+ // anyone subscribed sees the approval payload + single-use
1320
+ // approvalToken. Reject empty / whitespace / control chars to
1321
+ // avoid silent misconfiguration.
1322
+ if (topic && !/^[A-Za-z0-9_-]{1,64}$/.test(topic)) {
1323
+ res.writeHead(400, { "Content-Type": "application/json" });
1324
+ res.end(JSON.stringify({
1325
+ error: "ntfyTopic must match [A-Za-z0-9_-]{1,64}",
1326
+ }));
1327
+ return;
1328
+ }
1329
+ this.ntfyTopic = topic || undefined;
1330
+ }
1331
+ if (body.ntfyServer !== undefined) {
1332
+ const server = body.ntfyServer.trim();
1333
+ // Same reasoning as pushServiceBaseUrl — the bridge sends the
1334
+ // single-use token to this URL. http:// would expose it on the
1335
+ // wire; a malicious value would exfiltrate every approval.
1336
+ if (server && !server.startsWith("https://")) {
1337
+ res.writeHead(400, { "Content-Type": "application/json" });
1338
+ res.end(JSON.stringify({ error: "ntfyServer must be HTTPS" }));
1339
+ return;
1340
+ }
1341
+ this.ntfyServer = server || undefined;
1111
1342
  }
1112
1343
  const restartRequired = driverRaw !== undefined ||
1113
1344
  body.apiKey !== undefined ||
@@ -1123,6 +1354,212 @@ export class Server extends EventEmitter {
1123
1354
  }
1124
1355
  return;
1125
1356
  }
1357
+ // /kill-switch — dedicated endpoint for the global write-tier kill switch.
1358
+ // See issue #422 v2: not folded into /settings because kill-switch has
1359
+ // audit + idempotency + env-lock semantics nothing else on /settings has.
1360
+ //
1361
+ // POST {engage: boolean, reason?: string} → toggle; idempotent.
1362
+ // 200 {engaged, changed, locked: false} — accepted
1363
+ // 200 {engaged, changed: false, locked: false} — no-op (already in that state)
1364
+ // 409 {error: "env_locked", flag, frozenValue, lockedReason}
1365
+ // — env-locked, cannot toggle
1366
+ // 400 {error: "invalid_request"} — malformed body
1367
+ //
1368
+ // GET → status. 200 {engaged, locked, lockedReason?, lockedValue?}
1369
+ //
1370
+ // Audit emit is stubbed with this.logger.info — full plumbing
1371
+ // (decisionTraceLog into Server) lands in step 5 of the #422 series.
1372
+ if (parsedUrl.pathname === "/kill-switch") {
1373
+ if (req.method === "GET") {
1374
+ const engaged = isWriteKillSwitchActive();
1375
+ const locked = isEnvLockedFor(KILL_SWITCH_WRITES);
1376
+ const body = { engaged, locked };
1377
+ if (locked) {
1378
+ const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
1379
+ body.lockedValue = lockedValue;
1380
+ body.lockedReason = `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`;
1381
+ }
1382
+ res.writeHead(200, { "Content-Type": "application/json" });
1383
+ res.end(JSON.stringify(body));
1384
+ return;
1385
+ }
1386
+ if (req.method === "POST") {
1387
+ // 1 KB — body is `{engage: bool, reason?: string}`; reason is a
1388
+ // short audit note, 1 KB is generous.
1389
+ const KS_BODY_CAP = 1 * 1024;
1390
+ const parsed = await readJsonBody(req, KS_BODY_CAP);
1391
+ if (!parsed.ok) {
1392
+ if (parsed.code === "too_large") {
1393
+ respond413(res, KS_BODY_CAP);
1394
+ return;
1395
+ }
1396
+ res.writeHead(400, { "Content-Type": "application/json" });
1397
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1398
+ return;
1399
+ }
1400
+ const body = parsed.value ?? {};
1401
+ if (typeof body.engage !== "boolean") {
1402
+ res.writeHead(400, { "Content-Type": "application/json" });
1403
+ res.end(JSON.stringify({
1404
+ error: "invalid_request",
1405
+ reason: "engage must be boolean",
1406
+ }));
1407
+ return;
1408
+ }
1409
+ const reason = typeof body.reason === "string" && body.reason.trim().length > 0
1410
+ ? body.reason.trim().slice(0, 500)
1411
+ : undefined;
1412
+ // v2-B2 + I3: surface env-lock conflict as structured 409 so CLI
1413
+ // + dashboard can distinguish "you sent garbage" from
1414
+ // "policy-locked by sysadmin via PATCHWORK_FLAG_*".
1415
+ if (isEnvLockedFor(KILL_SWITCH_WRITES)) {
1416
+ const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
1417
+ res.writeHead(409, { "Content-Type": "application/json" });
1418
+ res.end(JSON.stringify({
1419
+ error: "env_locked",
1420
+ flag: KILL_SWITCH_WRITES,
1421
+ frozenValue: lockedValue,
1422
+ lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`,
1423
+ }));
1424
+ return;
1425
+ }
1426
+ // v2-I12: idempotent. State transitions emit audit; no-ops don't.
1427
+ const prev = isWriteKillSwitchActive();
1428
+ const next = body.engage;
1429
+ const changed = prev !== next;
1430
+ if (changed) {
1431
+ try {
1432
+ setFlag(KILL_SWITCH_WRITES, next, true);
1433
+ }
1434
+ catch (err) {
1435
+ // Belt-and-suspenders: setFlag now throws EnvLockedFlagError if
1436
+ // the flag was env-locked (we already checked isEnvLockedFor above,
1437
+ // but a race with lockKillSwitchEnv() in tests warrants this).
1438
+ if (err instanceof EnvLockedFlagError) {
1439
+ res.writeHead(409, { "Content-Type": "application/json" });
1440
+ res.end(JSON.stringify({
1441
+ error: "env_locked",
1442
+ flag: KILL_SWITCH_WRITES,
1443
+ frozenValue: err.frozenValue,
1444
+ lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${err.frozenValue ? "1" : "0"} at bridge startup`,
1445
+ }));
1446
+ return;
1447
+ }
1448
+ throw err;
1449
+ }
1450
+ // v2-I6: audit emit on every state transition; no-ops skip.
1451
+ // When the bridge wires recordKillSwitchTraceFn (step 5),
1452
+ // this writes to ~/.patchwork/decision_traces.jsonl. The
1453
+ // logger.info line stays as a secondary signal in the
1454
+ // bridge log; it's the only output when the trace fn is
1455
+ // unset (tests, headless contexts).
1456
+ this.logger.info(`[kill-switch] ${next ? "ENGAGED" : "RELEASED"}${reason ? ` (reason: ${reason})` : ""} — actor=http`);
1457
+ this.recordKillSwitchTraceFn?.({
1458
+ engaged: next,
1459
+ reason,
1460
+ ts: Date.now(),
1461
+ });
1462
+ // v2-I8: broadcast SSE kind:"kill-switch" so dashboard updates
1463
+ // in <1s without changing the poll cadence.
1464
+ this.broadcastKillSwitchEventFn?.(next, reason);
1465
+ }
1466
+ res.writeHead(200, { "Content-Type": "application/json" });
1467
+ res.end(JSON.stringify({
1468
+ engaged: next,
1469
+ changed,
1470
+ locked: false,
1471
+ }));
1472
+ return;
1473
+ }
1474
+ }
1475
+ // /telemetry-prefs — read/write per-flag telemetry preferences.
1476
+ // GET → {crashReports, usageStats, localDiagnostics}
1477
+ // POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
1478
+ if (parsedUrl.pathname === "/telemetry-prefs") {
1479
+ if (req.method === "GET") {
1480
+ const prefs = getTelemetryPrefs();
1481
+ const all = getAnalyticsPrefsAll();
1482
+ const response = { ...prefs };
1483
+ if (all?.lastSentAt !== undefined) {
1484
+ response.lastSentAt = all.lastSentAt;
1485
+ }
1486
+ res.writeHead(200, { "Content-Type": "application/json" });
1487
+ res.end(JSON.stringify(response));
1488
+ return;
1489
+ }
1490
+ if (req.method === "POST") {
1491
+ const TP_BODY_CAP = 1 * 1024;
1492
+ const parsed = await readJsonBody(req, TP_BODY_CAP);
1493
+ if (!parsed.ok) {
1494
+ if (parsed.code === "too_large") {
1495
+ respond413(res, TP_BODY_CAP);
1496
+ return;
1497
+ }
1498
+ res.writeHead(400, { "Content-Type": "application/json" });
1499
+ res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
1500
+ return;
1501
+ }
1502
+ const body = parsed.value ?? {};
1503
+ const update = {};
1504
+ if (typeof body.crashReports === "boolean") {
1505
+ update.crashReports = body.crashReports;
1506
+ }
1507
+ if (typeof body.usageStats === "boolean") {
1508
+ update.usageStats = body.usageStats;
1509
+ }
1510
+ if (typeof body.localDiagnostics === "boolean") {
1511
+ update.localDiagnostics = body.localDiagnostics;
1512
+ }
1513
+ setTelemetryPrefs(update);
1514
+ const prefs = getTelemetryPrefs();
1515
+ res.writeHead(200, { "Content-Type": "application/json" });
1516
+ res.end(JSON.stringify(prefs));
1517
+ return;
1518
+ }
1519
+ }
1520
+ // /restart — graceful bridge restart endpoint.
1521
+ // POST → triggers SIGTERM if no active work; returns 409 if busy.
1522
+ // Safety checks: rejects restart if sessions have in-flight tool calls.
1523
+ if (parsedUrl.pathname === "/restart" && req.method === "POST") {
1524
+ if (!this.restartCheckFn) {
1525
+ res.writeHead(503, { "Content-Type": "application/json" });
1526
+ res.end(JSON.stringify({
1527
+ ok: false,
1528
+ error: "restart_unavailable",
1529
+ reason: "Restart endpoint not configured",
1530
+ }));
1531
+ return;
1532
+ }
1533
+ const check = this.restartCheckFn();
1534
+ // Reject restart if there's active work
1535
+ if (check.inFlightCalls > 0) {
1536
+ 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"}`);
1537
+ res.writeHead(409, { "Content-Type": "application/json" });
1538
+ res.end(JSON.stringify({
1539
+ ok: false,
1540
+ error: "restart_blocked",
1541
+ reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
1542
+ activeSessions: check.totalSessions,
1543
+ inFlightCalls: check.inFlightCalls,
1544
+ busySessions: check.busySessions,
1545
+ }));
1546
+ return;
1547
+ }
1548
+ // Safe to restart — log and trigger SIGTERM
1549
+ this.logger.info(`[/restart] Initiating graceful restart — ${check.totalSessions} session${check.totalSessions === 1 ? "" : "s"}, 0 in-flight calls`);
1550
+ res.writeHead(202, { "Content-Type": "application/json" });
1551
+ res.end(JSON.stringify({
1552
+ ok: true,
1553
+ message: "Restart initiated. Bridge will shut down gracefully.",
1554
+ activeSessions: check.totalSessions,
1555
+ }));
1556
+ // Trigger SIGTERM after response is sent (100ms delay to ensure response delivery)
1557
+ setTimeout(() => {
1558
+ this.logger.info("[/restart] Sending SIGTERM to self");
1559
+ process.kill(process.pid, "SIGTERM");
1560
+ }, 100);
1561
+ return;
1562
+ }
1126
1563
  // CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
1127
1564
  if (parsedUrl.pathname === "/notify" && req.method === "POST") {
1128
1565
  // 8 KB — notify payloads carry an event name + small arg map
@@ -1219,6 +1656,8 @@ export class Server extends EventEmitter {
1219
1656
  pushServiceUrl: this.pushServiceUrl,
1220
1657
  pushServiceToken: this.pushServiceToken,
1221
1658
  pushServiceBaseUrl: this.pushServiceBaseUrl,
1659
+ ntfyTopic: this.ntfyTopic,
1660
+ ntfyServer: this.ntfyServer,
1222
1661
  activityLog: this.activityLog,
1223
1662
  // RecipeRunLog satisfies RecipeRunQuerier structurally
1224
1663
  // — the cast bridges TS contravariance: RecipeRunQuerier's
@@ -1269,7 +1708,7 @@ export class Server extends EventEmitter {
1269
1708
  res.writeHead(503, { "Content-Type": "application/json" });
1270
1709
  res.end(JSON.stringify({
1271
1710
  ok: false,
1272
- error: "Quick tasks unavailable — requires --claude-driver subprocess",
1711
+ error: "Quick tasks unavailable — requires --driver subprocess",
1273
1712
  }));
1274
1713
  return;
1275
1714
  }
@@ -1424,6 +1863,41 @@ export class Server extends EventEmitter {
1424
1863
  this.emit("connection", ws);
1425
1864
  });
1426
1865
  }
1866
+ /**
1867
+ * Resolve the client IP for rate-limit bucketing on the phone-path
1868
+ * approval bypass. Default: the direct socket peer. When trustedProxies
1869
+ * is set AND the socket peer is one of them, the rightmost X-Forwarded-For
1870
+ * entry not in the trusted list is the real client. XFF from an untrusted
1871
+ * peer is ignored — that header is spoofable by anyone who can reach the
1872
+ * server directly. Returns null when no IP can be determined; the caller
1873
+ * should fail closed.
1874
+ */
1875
+ getClientIp(req) {
1876
+ const socketIp = req.socket?.remoteAddress;
1877
+ if (socketIp &&
1878
+ this.trustedProxies.length > 0 &&
1879
+ this.trustedProxies.includes(socketIp)) {
1880
+ const xffRaw = req.headers["x-forwarded-for"];
1881
+ const xffStr = Array.isArray(xffRaw) ? xffRaw[0] : xffRaw;
1882
+ if (typeof xffStr === "string" && xffStr.length > 0) {
1883
+ const hops = xffStr
1884
+ .split(",")
1885
+ .map((s) => s.trim())
1886
+ .filter((s) => s.length > 0);
1887
+ // Walk right→left (proxies append to the right). The rightmost hop
1888
+ // we DON'T trust is the client; everything to its right is our own
1889
+ // hop chain.
1890
+ for (let i = hops.length - 1; i >= 0; i--) {
1891
+ const hop = hops[i];
1892
+ if (hop !== undefined && !this.trustedProxies.includes(hop)) {
1893
+ return hop.slice(0, 64);
1894
+ }
1895
+ }
1896
+ // Edge case: every hop is in trustedProxies — unusual; fall through.
1897
+ }
1898
+ }
1899
+ return socketIp ? socketIp.slice(0, 64) : null;
1900
+ }
1427
1901
  async listen(port, bindAddress = "127.0.0.1") {
1428
1902
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
1429
1903
  if (!LOOPBACK.has(bindAddress)) {