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

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 (143) hide show
  1. package/README.md +4 -1
  2. package/deploy/deploy-dashboard.sh +25 -1
  3. package/deploy/macos/README.md +153 -0
  4. package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
  5. package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
  6. package/deploy/macos/install-mac-bridge.sh +244 -0
  7. package/deploy/macos/uninstall-mac-bridge.sh +22 -0
  8. package/dist/analyticsAggregator.d.ts +5 -1
  9. package/dist/analyticsAggregator.js +15 -4
  10. package/dist/analyticsAggregator.js.map +1 -1
  11. package/dist/analyticsPrefs.d.ts +11 -0
  12. package/dist/analyticsPrefs.js +33 -0
  13. package/dist/analyticsPrefs.js.map +1 -1
  14. package/dist/approvalHttp.d.ts +14 -0
  15. package/dist/approvalHttp.js +172 -1
  16. package/dist/approvalHttp.js.map +1 -1
  17. package/dist/approvalQueue.d.ts +27 -2
  18. package/dist/approvalQueue.js +44 -7
  19. package/dist/approvalQueue.js.map +1 -1
  20. package/dist/automation.d.ts +34 -3
  21. package/dist/automation.js +85 -10
  22. package/dist/automation.js.map +1 -1
  23. package/dist/bridge.js +39 -27
  24. package/dist/bridge.js.map +1 -1
  25. package/dist/claudeDriver.d.ts +0 -16
  26. package/dist/claudeDriver.js +19 -20
  27. package/dist/claudeDriver.js.map +1 -1
  28. package/dist/claudeMdPatch.d.ts +9 -3
  29. package/dist/claudeMdPatch.js +79 -13
  30. package/dist/claudeMdPatch.js.map +1 -1
  31. package/dist/claudeOrchestrator.d.ts +12 -0
  32. package/dist/claudeOrchestrator.js +7 -2
  33. package/dist/claudeOrchestrator.js.map +1 -1
  34. package/dist/commands/marketplace.d.ts +15 -10
  35. package/dist/commands/marketplace.js +27 -115
  36. package/dist/commands/marketplace.js.map +1 -1
  37. package/dist/commands/recipe.js +10 -1
  38. package/dist/commands/recipe.js.map +1 -1
  39. package/dist/commitIssueLinkLog.d.ts +8 -0
  40. package/dist/commitIssueLinkLog.js +53 -1
  41. package/dist/commitIssueLinkLog.js.map +1 -1
  42. package/dist/config.d.ts +11 -3
  43. package/dist/config.js +32 -2
  44. package/dist/config.js.map +1 -1
  45. package/dist/connectorRoutes.js +63 -372
  46. package/dist/connectorRoutes.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/jira.js +18 -1
  50. package/dist/connectors/jira.js.map +1 -1
  51. package/dist/drivers/claude/subprocess.d.ts +12 -2
  52. package/dist/drivers/claude/subprocess.js +79 -6
  53. package/dist/drivers/claude/subprocess.js.map +1 -1
  54. package/dist/drivers/gemini/api.d.ts +18 -0
  55. package/dist/drivers/gemini/api.js +29 -0
  56. package/dist/drivers/gemini/api.js.map +1 -0
  57. package/dist/drivers/gemini/index.d.ts +22 -0
  58. package/dist/drivers/gemini/index.js +240 -129
  59. package/dist/drivers/gemini/index.js.map +1 -1
  60. package/dist/drivers/index.d.ts +3 -1
  61. package/dist/drivers/index.js +9 -1
  62. package/dist/drivers/index.js.map +1 -1
  63. package/dist/drivers/local/index.d.ts +43 -0
  64. package/dist/drivers/local/index.js +140 -0
  65. package/dist/drivers/local/index.js.map +1 -0
  66. package/dist/drivers/openai/index.js +30 -2
  67. package/dist/drivers/openai/index.js.map +1 -1
  68. package/dist/extensionClient.d.ts +8 -0
  69. package/dist/extensionClient.js +24 -2
  70. package/dist/extensionClient.js.map +1 -1
  71. package/dist/fp/automationInterpreter.d.ts +9 -1
  72. package/dist/fp/automationInterpreter.js +151 -34
  73. package/dist/fp/automationInterpreter.js.map +1 -1
  74. package/dist/fp/automationProgram.d.ts +30 -0
  75. package/dist/fp/automationProgram.js.map +1 -1
  76. package/dist/fp/automationState.d.ts +23 -4
  77. package/dist/fp/automationState.js +28 -4
  78. package/dist/fp/automationState.js.map +1 -1
  79. package/dist/fp/interpreterContext.d.ts +66 -1
  80. package/dist/fp/interpreterContext.js +140 -1
  81. package/dist/fp/interpreterContext.js.map +1 -1
  82. package/dist/fp/policyParser.js +29 -1
  83. package/dist/fp/policyParser.js.map +1 -1
  84. package/dist/httpErrorResponse.d.ts +36 -0
  85. package/dist/httpErrorResponse.js +46 -0
  86. package/dist/httpErrorResponse.js.map +1 -0
  87. package/dist/inboxRoutes.js +90 -11
  88. package/dist/inboxRoutes.js.map +1 -1
  89. package/dist/index.d.ts +1 -1
  90. package/dist/index.js +3 -2
  91. package/dist/index.js.map +1 -1
  92. package/dist/oauth.d.ts +22 -0
  93. package/dist/oauth.js +46 -0
  94. package/dist/oauth.js.map +1 -1
  95. package/dist/oauthRoutes.js +3 -8
  96. package/dist/oauthRoutes.js.map +1 -1
  97. package/dist/patchworkConfig.d.ts +30 -1
  98. package/dist/patchworkConfig.js +99 -4
  99. package/dist/patchworkConfig.js.map +1 -1
  100. package/dist/preToolUseHook.js +7 -1
  101. package/dist/preToolUseHook.js.map +1 -1
  102. package/dist/prompts.js +4 -0
  103. package/dist/prompts.js.map +1 -1
  104. package/dist/recipeOrchestration.js +13 -3
  105. package/dist/recipeOrchestration.js.map +1 -1
  106. package/dist/recipeRoutes.d.ts +5 -0
  107. package/dist/recipeRoutes.js +57 -33
  108. package/dist/recipeRoutes.js.map +1 -1
  109. package/dist/recipes/agentExecutor.d.ts +10 -1
  110. package/dist/recipes/agentExecutor.js +5 -4
  111. package/dist/recipes/agentExecutor.js.map +1 -1
  112. package/dist/recipes/scheduler.d.ts +7 -0
  113. package/dist/recipes/scheduler.js +30 -13
  114. package/dist/recipes/scheduler.js.map +1 -1
  115. package/dist/recipes/schema.d.ts +6 -0
  116. package/dist/recipes/tools/file.js +5 -2
  117. package/dist/recipes/tools/file.js.map +1 -1
  118. package/dist/recipes/tools/gmail.js +18 -1
  119. package/dist/recipes/tools/gmail.js.map +1 -1
  120. package/dist/recipes/yamlRunner.d.ts +32 -2
  121. package/dist/recipes/yamlRunner.js +71 -6
  122. package/dist/recipes/yamlRunner.js.map +1 -1
  123. package/dist/recipesHttp.d.ts +17 -1
  124. package/dist/recipesHttp.js +68 -4
  125. package/dist/recipesHttp.js.map +1 -1
  126. package/dist/server.d.ts +52 -1
  127. package/dist/server.js +427 -248
  128. package/dist/server.js.map +1 -1
  129. package/dist/streamableHttp.d.ts +9 -4
  130. package/dist/streamableHttp.js +17 -9
  131. package/dist/streamableHttp.js.map +1 -1
  132. package/dist/tools/openInBrowser.js +6 -1
  133. package/dist/tools/openInBrowser.js.map +1 -1
  134. package/dist/tools/runCommand.js +5 -0
  135. package/dist/tools/runCommand.js.map +1 -1
  136. package/dist/tools/terminal.js +4 -0
  137. package/dist/tools/terminal.js.map +1 -1
  138. package/dist/tools/utils.d.ts +4 -0
  139. package/dist/tools/utils.js +62 -0
  140. package/dist/tools/utils.js.map +1 -1
  141. package/package.json +2 -2
  142. package/scripts/start-all.sh +4 -2
  143. package/templates/recipes/approval-queue-ui-test.yaml +205 -0
package/dist/server.js CHANGED
@@ -7,11 +7,12 @@ import { saveBridgeConfigDriver } from "./config.js";
7
7
  import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
8
8
  import { timingSafeStringEqual } from "./crypto.js";
9
9
  import { renderDashboardHtml } from "./dashboard.js";
10
+ import { respond500 } from "./httpErrorResponse.js";
10
11
  import { tryHandleInboxRoute } from "./inboxRoutes.js";
11
12
  import { tryHandleMcpRoute } from "./mcpRoutes.js";
12
13
  import { tryHandleOAuthRoute } from "./oauthRoutes.js";
13
- import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
14
- import { tryHandleRecipeRoute } from "./recipeRoutes.js";
14
+ import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveApiKeyToSecureStore, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
15
+ import { readBodyWithCap, readJsonBody, respond413, tryHandleRecipeRoute, } from "./recipeRoutes.js";
15
16
  import { PACKAGE_VERSION } from "./version.js";
16
17
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
17
18
  const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -48,6 +49,7 @@ export class Server extends EventEmitter {
48
49
  logger;
49
50
  extraCorsOrigins;
50
51
  pingIntervalMs;
52
+ trustedProxies;
51
53
  httpServer;
52
54
  wss;
53
55
  pingInterval = null;
@@ -87,6 +89,8 @@ export class Server extends EventEmitter {
87
89
  saveRecipeContentFn = null;
88
90
  /** Patchwork: set by bridge to delete a recipe by name. */
89
91
  deleteRecipeContentFn = null;
92
+ /** Patchwork: set by bridge to archive a recipe (move to .archive/). */
93
+ archiveRecipeFn = null;
90
94
  /** Patchwork: set by bridge to promote a variant recipe to the canonical name. */
91
95
  promoteRecipeVariantFn = null;
92
96
  /** Patchwork: set by bridge to duplicate a recipe as a variant. */
@@ -120,6 +124,10 @@ export class Server extends EventEmitter {
120
124
  pushServiceToken = undefined;
121
125
  /** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
122
126
  pushServiceBaseUrl = undefined;
127
+ /** Patchwork: ntfy.sh topic for direct phone-path approvals via action buttons. */
128
+ ntfyTopic = undefined;
129
+ /** Patchwork: ntfy server (default https://ntfy.sh; override for self-hosted). */
130
+ ntfyServer = undefined;
123
131
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
124
132
  onApprovalDecision = undefined;
125
133
  /**
@@ -154,6 +162,36 @@ export class Server extends EventEmitter {
154
162
  */
155
163
  webhookPayloads = new Map();
156
164
  static MAX_WEBHOOK_PAYLOADS = 5;
165
+ /**
166
+ * Per-list FIFO bounds the per-recipe payload count, but the Map itself
167
+ * needs a key cap so a recipe-rename loop or a scanner hitting many
168
+ * distinct legitimate hookPaths can't grow `webhookPayloads.size`
169
+ * without bound. 1000 is generous for any realistic operator deployment
170
+ * (5 payloads × 1000 recipes = ~5000 entries × ~10 KB each = 50 MB).
171
+ * On overflow, evict the oldest *recipe* (Map iteration order is
172
+ * insertion order in JS), not the largest list.
173
+ */
174
+ static MAX_WEBHOOK_RECIPES = 1000;
175
+ /**
176
+ * Per-IP rate limit on the unauthenticated phone-path approval endpoints
177
+ * (`POST /approve/:callId` and `POST /reject/:callId` when
178
+ * `x-approval-token` is present). The auth gate intentionally bypasses
179
+ * bearer auth for those paths so a phone can dispatch without a bridge
180
+ * token; without rate limiting, an attacker who learns a callId (via
181
+ * webhook target leak, bearer-authed `/approvals` reader, etc.) can
182
+ * spray garbage tokens to DoS the legitimate approver. PR #380 bumped
183
+ * the per-callId failure cap to 1000 (memory bound, not security
184
+ * bound); this is the HTTP-layer spray defense flagged as the proper
185
+ * fix in that commit.
186
+ *
187
+ * 60 attempts per IP per minute is generous for legitimate retries
188
+ * (phone re-tap, network flake) and tight enough to bound brute-force
189
+ * attempts during the 5-minute approval TTL to 300 — well within the
190
+ * per-callId cap, so no legit retry budget is consumed by sprayers.
191
+ */
192
+ approvalIpCounts = new Map();
193
+ static APPROVAL_IP_MAX = 60;
194
+ static APPROVAL_IP_WINDOW_MS = 60_000;
157
195
  /** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
158
196
  httpMcpHandler = null;
159
197
  /** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
@@ -193,12 +231,19 @@ export class Server extends EventEmitter {
193
231
  }
194
232
  /** Hosts accepted in the WebSocket upgrade Host header (DNS-rebinding guard). */
195
233
  allowedHosts;
196
- constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000) {
234
+ constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000,
235
+ // Reverse-proxy hops whose X-Forwarded-For values we trust. Empty by
236
+ // default — the per-IP rate limiter buckets on the direct socket peer.
237
+ // Behind nginx/Caddy/Cloudflare set this to the proxy's IP so distinct
238
+ // real clients get distinct buckets; otherwise every request looks
239
+ // like 127.0.0.1 and a single sprayer DoSes the legit approver.
240
+ trustedProxies = []) {
197
241
  super();
198
242
  this.authToken = authToken;
199
243
  this.logger = logger;
200
244
  this.extraCorsOrigins = extraCorsOrigins;
201
245
  this.pingIntervalMs = pingIntervalMs;
246
+ this.trustedProxies = trustedProxies;
202
247
  // Defense-in-depth: ensure token is non-empty so timingSafeTokenCompare
203
248
  // cannot accept a blank Authorization header against an empty token.
204
249
  if (authToken.length === 0) {
@@ -347,10 +392,7 @@ export class Server extends EventEmitter {
347
392
  res.end(JSON.stringify(body, null, 2));
348
393
  }
349
394
  catch (err) {
350
- res.writeHead(500, { "Content-Type": "application/json" });
351
- res.end(JSON.stringify({
352
- error: err instanceof Error ? err.message : String(err),
353
- }));
395
+ respond500(res, err);
354
396
  }
355
397
  return;
356
398
  }
@@ -376,6 +418,51 @@ export class Server extends EventEmitter {
376
418
  const isPhoneApprovalPath = req.method === "POST" &&
377
419
  /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
378
420
  !!req.headers["x-approval-token"];
421
+ // Rate-limit the phone bypass surface. Only applies when this is
422
+ // actually a phone-path request that's relying on the bypass — a
423
+ // properly-authenticated bearer caller is unaffected. Counted +
424
+ // checked *before* dispatch so a sprayer can't burn the per-callId
425
+ // failure budget at line-rate.
426
+ if (isPhoneApprovalPath && !isStaticToken && !oauthResolved) {
427
+ const remoteIp = this.getClientIp(req);
428
+ if (!remoteIp) {
429
+ // Fail closed — without an attributable IP we cannot bucket
430
+ // safely, and the original "unknown" string was a single shared
431
+ // counter every IP-less request rolled up into.
432
+ res.writeHead(400, { "Content-Type": "application/json" });
433
+ res.end(JSON.stringify({
434
+ error: "bad_request",
435
+ error_description: "could not determine client IP",
436
+ }));
437
+ return;
438
+ }
439
+ const now = Date.now();
440
+ const entry = this.approvalIpCounts.get(remoteIp);
441
+ if (entry && now - entry.windowStart < Server.APPROVAL_IP_WINDOW_MS) {
442
+ entry.count++;
443
+ if (entry.count > Server.APPROVAL_IP_MAX) {
444
+ res.writeHead(429, { "Content-Type": "application/json" });
445
+ res.end(JSON.stringify({
446
+ error: "too_many_requests",
447
+ error_description: "per-IP approval endpoint rate limit reached",
448
+ }));
449
+ return;
450
+ }
451
+ }
452
+ else {
453
+ this.approvalIpCounts.set(remoteIp, { count: 1, windowStart: now });
454
+ }
455
+ // GC stale entries opportunistically — bounded growth alongside
456
+ // the same Map. 200 is well above any legitimate concurrent IP
457
+ // count; well below memory pressure.
458
+ if (this.approvalIpCounts.size > 200) {
459
+ for (const [k, v] of this.approvalIpCounts) {
460
+ if (now - v.windowStart > Server.APPROVAL_IP_WINDOW_MS) {
461
+ this.approvalIpCounts.delete(k);
462
+ }
463
+ }
464
+ }
465
+ }
379
466
  // oauthResolved is the bridge token if the OAuth token is valid; null otherwise
380
467
  if (!isStaticToken && !oauthResolved && !isPhoneApprovalPath) {
381
468
  // RFC 6750: only include error= when a token was actually presented but invalid
@@ -399,10 +486,7 @@ export class Server extends EventEmitter {
399
486
  res.end(body);
400
487
  }
401
488
  catch (err) {
402
- res.writeHead(500, { "Content-Type": "application/json" });
403
- res.end(JSON.stringify({
404
- error: err instanceof Error ? err.message : String(err),
405
- }));
489
+ respond500(res, err);
406
490
  }
407
491
  return;
408
492
  }
@@ -418,10 +502,7 @@ export class Server extends EventEmitter {
418
502
  res.end(JSON.stringify(data));
419
503
  }
420
504
  catch (err) {
421
- res.writeHead(500, { "Content-Type": "application/json" });
422
- res.end(JSON.stringify({
423
- error: err instanceof Error ? err.message : String(err),
424
- }));
505
+ respond500(res, err);
425
506
  }
426
507
  return;
427
508
  }
@@ -436,10 +517,7 @@ export class Server extends EventEmitter {
436
517
  res.end(JSON.stringify({ events: data, count: data.length }));
437
518
  }
438
519
  catch (err) {
439
- res.writeHead(500, { "Content-Type": "application/json" });
440
- res.end(JSON.stringify({
441
- error: err instanceof Error ? err.message : String(err),
442
- }));
520
+ respond500(res, err);
443
521
  }
444
522
  return;
445
523
  }
@@ -475,10 +553,7 @@ export class Server extends EventEmitter {
475
553
  res.end(JSON.stringify(data));
476
554
  }
477
555
  catch (err) {
478
- res.writeHead(500, { "Content-Type": "application/json" });
479
- res.end(JSON.stringify({
480
- error: err instanceof Error ? err.message : String(err),
481
- }));
556
+ respond500(res, err);
482
557
  }
483
558
  return;
484
559
  }
@@ -549,12 +624,7 @@ export class Server extends EventEmitter {
549
624
  }
550
625
  }
551
626
  catch (err) {
552
- if (!res.headersSent) {
553
- res.writeHead(500, { "Content-Type": "application/json" });
554
- res.end(JSON.stringify({
555
- error: err instanceof Error ? err.message : String(err),
556
- }));
557
- }
627
+ respond500(res, err);
558
628
  }
559
629
  })();
560
630
  return;
@@ -577,10 +647,7 @@ export class Server extends EventEmitter {
577
647
  res.end(JSON.stringify(data));
578
648
  }
579
649
  catch (err) {
580
- res.writeHead(500, { "Content-Type": "application/json" });
581
- res.end(JSON.stringify({
582
- error: err instanceof Error ? err.message : String(err),
583
- }));
650
+ respond500(res, err);
584
651
  }
585
652
  return;
586
653
  }
@@ -594,10 +661,7 @@ export class Server extends EventEmitter {
594
661
  res.end(JSON.stringify(data));
595
662
  }
596
663
  catch (err) {
597
- res.writeHead(500, { "Content-Type": "application/json" });
598
- res.end(JSON.stringify({
599
- error: err instanceof Error ? err.message : String(err),
600
- }));
664
+ respond500(res, err);
601
665
  }
602
666
  return;
603
667
  }
@@ -625,10 +689,7 @@ export class Server extends EventEmitter {
625
689
  }
626
690
  }
627
691
  catch (err) {
628
- res.writeHead(500, { "Content-Type": "application/json" });
629
- res.end(JSON.stringify({
630
- error: err instanceof Error ? err.message : String(err),
631
- }));
692
+ respond500(res, err);
632
693
  }
633
694
  return;
634
695
  }
@@ -681,10 +742,7 @@ export class Server extends EventEmitter {
681
742
  res.end(JSON.stringify(data));
682
743
  }
683
744
  catch (err) {
684
- res.writeHead(500, { "Content-Type": "application/json" });
685
- res.end(JSON.stringify({
686
- error: err instanceof Error ? err.message : String(err),
687
- }));
745
+ respond500(res, err);
688
746
  }
689
747
  return;
690
748
  }
@@ -703,67 +761,77 @@ export class Server extends EventEmitter {
703
761
  }
704
762
  }
705
763
  catch (err) {
706
- res.writeHead(500, { "Content-Type": "application/json" });
707
- res.end(JSON.stringify({
708
- error: err instanceof Error ? err.message : String(err),
709
- }));
764
+ respond500(res, err);
710
765
  }
711
766
  return;
712
767
  }
713
768
  if (parsedUrl.pathname?.startsWith("/hooks/") && req.method === "POST") {
714
769
  const hookPath = parsedUrl.pathname.substring("/hooks".length);
715
- const chunks = [];
716
- req.on("data", (c) => chunks.push(c));
717
- req.on("end", () => {
718
- void (async () => {
719
- let payload;
720
- if (chunks.length > 0) {
721
- const body = Buffer.concat(chunks).toString("utf-8");
722
- if (body.trim()) {
723
- try {
724
- payload = JSON.parse(body);
725
- }
726
- catch {
727
- payload = body;
728
- }
729
- }
730
- }
731
- if (!this.webhookFn) {
732
- res.writeHead(503, { "Content-Type": "application/json" });
733
- res.end(JSON.stringify({
734
- ok: false,
735
- error: "Webhooks unavailable — start bridge with --claude-driver subprocess",
736
- }));
737
- return;
738
- }
739
- const result = await this.webhookFn(hookPath, payload);
740
- const status = result.ok
741
- ? 200
742
- : result.error === "not_found"
743
- ? 404
744
- : 400;
745
- // Record in ring buffer so the dashboard can show "last
746
- // payload" per recipe. Skip not_found so unknown paths don't
747
- // pollute the buffer with garbage / scanner traffic.
748
- if (result.error !== "not_found") {
749
- const existing = this.webhookPayloads.get(hookPath) ?? [];
750
- existing.unshift({
751
- receivedAt: Date.now(),
752
- payload,
753
- ok: result.ok,
754
- error: result.error,
755
- taskId: result.taskId,
756
- recipeName: result.name,
757
- });
758
- if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
759
- existing.length = Server.MAX_WEBHOOK_PAYLOADS;
760
- }
761
- this.webhookPayloads.set(hookPath, existing);
762
- }
763
- res.writeHead(status, { "Content-Type": "application/json" });
764
- res.end(JSON.stringify(result));
765
- })();
766
- });
770
+ // 256 KB — webhook payloads from GitHub/Linear/etc. are typically
771
+ // 1–25 KB; 256 KB matches RECIPE_ROUTE_BODY_CAPS.content as a
772
+ // generous ceiling that still bounds an authenticated DoS vector.
773
+ const HOOKS_BODY_CAP = 256 * 1024;
774
+ const read = await readBodyWithCap(req, HOOKS_BODY_CAP);
775
+ if (!read.ok) {
776
+ respond413(res, HOOKS_BODY_CAP);
777
+ return;
778
+ }
779
+ let payload;
780
+ if (read.body.trim()) {
781
+ try {
782
+ payload = JSON.parse(read.body);
783
+ }
784
+ catch {
785
+ payload = read.body;
786
+ }
787
+ }
788
+ if (!this.webhookFn) {
789
+ res.writeHead(503, { "Content-Type": "application/json" });
790
+ res.end(JSON.stringify({
791
+ ok: false,
792
+ error: "Webhooks unavailable — start bridge with --claude-driver subprocess",
793
+ }));
794
+ return;
795
+ }
796
+ const result = await this.webhookFn(hookPath, payload);
797
+ const status = result.ok
798
+ ? 200
799
+ : result.error === "not_found"
800
+ ? 404
801
+ : 400;
802
+ // Record in ring buffer so the dashboard can show "last
803
+ // payload" per recipe. Skip not_found so unknown paths don't
804
+ // pollute the buffer with garbage / scanner traffic.
805
+ if (result.error !== "not_found") {
806
+ const existing = this.webhookPayloads.get(hookPath) ?? [];
807
+ existing.unshift({
808
+ receivedAt: Date.now(),
809
+ payload,
810
+ ok: result.ok,
811
+ error: result.error,
812
+ taskId: result.taskId,
813
+ recipeName: result.name,
814
+ });
815
+ if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
816
+ existing.length = Server.MAX_WEBHOOK_PAYLOADS;
817
+ }
818
+ // LRU eviction: Map.set() on an existing key keeps original
819
+ // insertion-order position (per spec), so a hot recipe registered
820
+ // at startup would otherwise be evicted in favor of a cold
821
+ // scanner-spam recipe just because it was registered earlier.
822
+ // Delete + re-set re-anchors the key to the END of insertion
823
+ // order, making `.keys().next()` correctly point at the
824
+ // least-recently-fired recipe.
825
+ this.webhookPayloads.delete(hookPath);
826
+ if (this.webhookPayloads.size >= Server.MAX_WEBHOOK_RECIPES) {
827
+ const oldest = this.webhookPayloads.keys().next().value;
828
+ if (oldest !== undefined)
829
+ this.webhookPayloads.delete(oldest);
830
+ }
831
+ this.webhookPayloads.set(hookPath, existing);
832
+ }
833
+ res.writeHead(status, { "Content-Type": "application/json" });
834
+ res.end(JSON.stringify(result));
767
835
  return;
768
836
  }
769
837
  if (parsedUrl.pathname?.startsWith("/webhook-payloads/") &&
@@ -916,6 +984,7 @@ export class Server extends EventEmitter {
916
984
  loadRecipeContentFn: this.loadRecipeContentFn,
917
985
  saveRecipeContentFn: this.saveRecipeContentFn,
918
986
  deleteRecipeContentFn: this.deleteRecipeContentFn,
987
+ archiveRecipeFn: this.archiveRecipeFn,
919
988
  duplicateRecipeFn: this.duplicateRecipeFn,
920
989
  promoteRecipeVariantFn: this.promoteRecipeVariantFn,
921
990
  lintRecipeContentFn: this.lintRecipeContentFn,
@@ -943,10 +1012,7 @@ export class Server extends EventEmitter {
943
1012
  res.end(JSON.stringify(data));
944
1013
  }
945
1014
  catch (err) {
946
- res.writeHead(500, { "Content-Type": "application/json" });
947
- res.end(JSON.stringify({
948
- error: err instanceof Error ? err.message : String(err),
949
- }));
1015
+ respond500(res, err);
950
1016
  }
951
1017
  return;
952
1018
  }
@@ -962,19 +1028,28 @@ export class Server extends EventEmitter {
962
1028
  res.end(JSON.stringify(data));
963
1029
  }
964
1030
  catch (err) {
965
- res.writeHead(500, { "Content-Type": "application/json" });
966
- res.end(JSON.stringify({
967
- error: err instanceof Error ? err.message : String(err),
968
- }));
1031
+ respond500(res, err);
969
1032
  }
970
1033
  return;
971
1034
  }
972
1035
  if (parsedUrl.pathname === "/settings" && req.method === "POST") {
973
- const chunks = [];
974
- req.on("data", (c) => chunks.push(c));
975
- req.on("end", () => {
976
- try {
977
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1036
+ // 16 KB — settings POSTs are short-string fields (URLs, API keys,
1037
+ // gate level). 16 KB is generous; an authenticated attacker can't
1038
+ // stream gigabytes here.
1039
+ const SETTINGS_BODY_CAP = 16 * 1024;
1040
+ const parsed = await readJsonBody(req, SETTINGS_BODY_CAP);
1041
+ if (!parsed.ok) {
1042
+ if (parsed.code === "too_large") {
1043
+ respond413(res, SETTINGS_BODY_CAP);
1044
+ return;
1045
+ }
1046
+ res.writeHead(400, { "Content-Type": "application/json" });
1047
+ res.end(JSON.stringify({ error: "Invalid request body" }));
1048
+ return;
1049
+ }
1050
+ try {
1051
+ {
1052
+ const body = parsed.value ?? {};
978
1053
  const hasWebhookUpdate = body.webhookUrl !== undefined;
979
1054
  const raw = hasWebhookUpdate
980
1055
  ? (body.webhookUrl?.trim() ?? "")
@@ -1032,6 +1107,8 @@ export class Server extends EventEmitter {
1032
1107
  "openai",
1033
1108
  "grok",
1034
1109
  "gemini",
1110
+ "gemini-api",
1111
+ "local",
1035
1112
  "none",
1036
1113
  ];
1037
1114
  if (!validDrivers.includes(driverRaw)) {
@@ -1043,7 +1120,17 @@ export class Server extends EventEmitter {
1043
1120
  }
1044
1121
  const driver = driverRaw;
1045
1122
  cfg.driver = driver;
1046
- saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1123
+ try {
1124
+ saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1125
+ }
1126
+ catch (writeErr) {
1127
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1128
+ res.writeHead(500, { "Content-Type": "application/json" });
1129
+ res.end(JSON.stringify({
1130
+ error: "Failed to write bridge driver config",
1131
+ }));
1132
+ return;
1133
+ }
1047
1134
  }
1048
1135
  if (body.model !== undefined) {
1049
1136
  const validModels = [
@@ -1077,9 +1164,32 @@ export class Server extends EventEmitter {
1077
1164
  res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1078
1165
  return;
1079
1166
  }
1080
- cfg.apiKeys = { ...cfg.apiKeys, [provider]: key || undefined };
1167
+ // Provider keys go to the secure store (Keychain/DPAPI/Secret
1168
+ // Service / AES-256-GCM file fallback) — never persisted to
1169
+ // ~/.patchwork/config.json. Empty string clears.
1170
+ try {
1171
+ saveApiKeyToSecureStore(provider, key);
1172
+ }
1173
+ catch (writeErr) {
1174
+ this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1175
+ res.writeHead(500, { "Content-Type": "application/json" });
1176
+ res.end(JSON.stringify({
1177
+ error: "Failed to write provider API key",
1178
+ }));
1179
+ return;
1180
+ }
1181
+ }
1182
+ try {
1183
+ savePatchworkConfig(cfg, configPath);
1184
+ }
1185
+ catch (writeErr) {
1186
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1187
+ res.writeHead(500, { "Content-Type": "application/json" });
1188
+ res.end(JSON.stringify({
1189
+ error: "Failed to write patchwork config",
1190
+ }));
1191
+ return;
1081
1192
  }
1082
- savePatchworkConfig(cfg, configPath);
1083
1193
  if (hasWebhookUpdate) {
1084
1194
  this.approvalWebhookUrl = raw || undefined;
1085
1195
  }
@@ -1096,8 +1206,46 @@ export class Server extends EventEmitter {
1096
1206
  this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1097
1207
  }
1098
1208
  if (body.pushServiceBaseUrl !== undefined) {
1099
- this.pushServiceBaseUrl =
1100
- body.pushServiceBaseUrl.trim() || undefined;
1209
+ const baseUrl = body.pushServiceBaseUrl.trim();
1210
+ // pushServiceBaseUrl is the bridge callback origin embedded in
1211
+ // the SW's approveUrl/rejectUrl. If it can be set to plain
1212
+ // http:// or to a host the operator didn't intend, the SW will
1213
+ // POST the one-shot approvalToken there — letting an attacker
1214
+ // who sets this redirect every approval to attacker.tld and
1215
+ // replay tokens to the real bridge for silent auto-approve.
1216
+ if (baseUrl && !baseUrl.startsWith("https://")) {
1217
+ res.writeHead(400, { "Content-Type": "application/json" });
1218
+ res.end(JSON.stringify({ error: "pushServiceBaseUrl must be HTTPS" }));
1219
+ return;
1220
+ }
1221
+ this.pushServiceBaseUrl = baseUrl || undefined;
1222
+ }
1223
+ if (body.ntfyTopic !== undefined) {
1224
+ const topic = body.ntfyTopic.trim();
1225
+ // Topic acts as a bearer token on the public ntfy.sh server —
1226
+ // anyone subscribed sees the approval payload + single-use
1227
+ // approvalToken. Reject empty / whitespace / control chars to
1228
+ // avoid silent misconfiguration.
1229
+ if (topic && !/^[A-Za-z0-9_-]{1,64}$/.test(topic)) {
1230
+ res.writeHead(400, { "Content-Type": "application/json" });
1231
+ res.end(JSON.stringify({
1232
+ error: "ntfyTopic must match [A-Za-z0-9_-]{1,64}",
1233
+ }));
1234
+ return;
1235
+ }
1236
+ this.ntfyTopic = topic || undefined;
1237
+ }
1238
+ if (body.ntfyServer !== undefined) {
1239
+ const server = body.ntfyServer.trim();
1240
+ // Same reasoning as pushServiceBaseUrl — the bridge sends the
1241
+ // single-use token to this URL. http:// would expose it on the
1242
+ // wire; a malicious value would exfiltrate every approval.
1243
+ if (server && !server.startsWith("https://")) {
1244
+ res.writeHead(400, { "Content-Type": "application/json" });
1245
+ res.end(JSON.stringify({ error: "ntfyServer must be HTTPS" }));
1246
+ return;
1247
+ }
1248
+ this.ntfyServer = server || undefined;
1101
1249
  }
1102
1250
  const restartRequired = driverRaw !== undefined ||
1103
1251
  body.apiKey !== undefined ||
@@ -1105,44 +1253,45 @@ export class Server extends EventEmitter {
1105
1253
  res.writeHead(200, { "Content-Type": "application/json" });
1106
1254
  res.end(JSON.stringify({ ok: true, restartRequired }));
1107
1255
  }
1108
- catch (err) {
1109
- res.writeHead(400, { "Content-Type": "application/json" });
1110
- res.end(JSON.stringify({
1111
- error: err instanceof Error ? err.message : String(err),
1112
- }));
1113
- }
1114
- });
1256
+ }
1257
+ catch (err) {
1258
+ this.logger.error(`[/config/patchwork] error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1259
+ res.writeHead(400, { "Content-Type": "application/json" });
1260
+ res.end(JSON.stringify({ error: "Invalid request body" }));
1261
+ }
1115
1262
  return;
1116
1263
  }
1117
1264
  // CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
1118
1265
  if (parsedUrl.pathname === "/notify" && req.method === "POST") {
1119
- const chunks = [];
1120
- req.on("data", (c) => chunks.push(c));
1121
- req.on("end", () => {
1122
- try {
1123
- const body = Buffer.concat(chunks).toString("utf-8");
1124
- const parsed = JSON.parse(body);
1125
- const event = parsed.event ?? "";
1126
- const args = parsed.args ?? {};
1127
- if (!this.notifyFn) {
1128
- res.writeHead(503, { "Content-Type": "application/json" });
1129
- res.end(JSON.stringify({
1130
- ok: false,
1131
- error: "Automation not enabled",
1132
- }));
1133
- return;
1134
- }
1135
- const result = this.notifyFn(event, args);
1136
- res.writeHead(result.ok ? 200 : 400, {
1137
- "Content-Type": "application/json",
1138
- });
1139
- res.end(JSON.stringify(result));
1140
- }
1141
- catch {
1142
- res.writeHead(400, { "Content-Type": "application/json" });
1143
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1266
+ // 8 KB — notify payloads carry an event name + small arg map
1267
+ // (taskId, prompt, tool name). 8 KB fits everything we send today
1268
+ // with headroom.
1269
+ const NOTIFY_BODY_CAP = 8 * 1024;
1270
+ const parsed = await readJsonBody(req, NOTIFY_BODY_CAP);
1271
+ if (!parsed.ok) {
1272
+ if (parsed.code === "too_large") {
1273
+ respond413(res, NOTIFY_BODY_CAP);
1274
+ return;
1144
1275
  }
1276
+ res.writeHead(400, { "Content-Type": "application/json" });
1277
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1278
+ return;
1279
+ }
1280
+ const event = parsed.value?.event ?? "";
1281
+ const args = parsed.value?.args ?? {};
1282
+ if (!this.notifyFn) {
1283
+ res.writeHead(503, { "Content-Type": "application/json" });
1284
+ res.end(JSON.stringify({
1285
+ ok: false,
1286
+ error: "Automation not enabled",
1287
+ }));
1288
+ return;
1289
+ }
1290
+ const result = this.notifyFn(event, args);
1291
+ res.writeHead(result.ok ? 200 : 400, {
1292
+ "Content-Type": "application/json",
1145
1293
  });
1294
+ res.end(JSON.stringify(result));
1146
1295
  return;
1147
1296
  }
1148
1297
  // Single-approval detail lookup for the dashboard detail page.
@@ -1160,10 +1309,7 @@ export class Server extends EventEmitter {
1160
1309
  res.end(JSON.stringify(data));
1161
1310
  }
1162
1311
  catch (err) {
1163
- res.writeHead(500, { "Content-Type": "application/json" });
1164
- res.end(JSON.stringify({
1165
- error: err instanceof Error ? err.message : String(err),
1166
- }));
1312
+ respond500(res, err);
1167
1313
  }
1168
1314
  return;
1169
1315
  }
@@ -1177,101 +1323,101 @@ export class Server extends EventEmitter {
1177
1323
  if (parsedUrl.pathname === "/approvals" ||
1178
1324
  parsedUrl.pathname === "/cc-permissions" ||
1179
1325
  /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname)) {
1180
- const chunks = [];
1181
- req.on("data", (c) => chunks.push(c));
1182
- req.on("end", async () => {
1183
- let parsedBody;
1184
- if (chunks.length > 0) {
1185
- try {
1186
- parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1187
- }
1188
- catch {
1189
- res.writeHead(400, { "Content-Type": "application/json" });
1190
- res.end(JSON.stringify({ error: "invalid JSON body" }));
1191
- return;
1192
- }
1193
- }
1194
- try {
1195
- const result = await routeApprovalRequest({
1196
- method: req.method ?? "GET",
1197
- path: parsedUrl.pathname,
1198
- body: parsedBody,
1199
- query: parsedUrl.searchParams,
1200
- approvalToken: req.headers["x-approval-token"],
1201
- }, {
1202
- queue: getApprovalQueue(),
1203
- workspace: process.cwd(),
1204
- managedSettingsPath: this.managedSettingsPath,
1205
- onDecision: this.onApprovalDecision,
1206
- webhookUrl: this.approvalWebhookUrl,
1207
- approvalGate: this.approvalGate,
1208
- pushServiceUrl: this.pushServiceUrl,
1209
- pushServiceToken: this.pushServiceToken,
1210
- pushServiceBaseUrl: this.pushServiceBaseUrl,
1211
- activityLog: this.activityLog,
1212
- // RecipeRunLog satisfies RecipeRunQuerier structurally
1213
- // — the cast bridges TS contravariance: RecipeRunQuerier's
1214
- // narrow query interface (`status?: string`) is deliberately
1215
- // loose so tests can mock it; RecipeRunLog's stricter
1216
- // RunStatus union is a strict subset and fails the param
1217
- // contravariance check despite being safe at runtime.
1218
- recipeRunLog: this.recipeRunLog,
1219
- enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
1220
- });
1221
- res.writeHead(result.status, {
1222
- "Content-Type": "application/json",
1223
- });
1224
- res.end(JSON.stringify(result.body));
1225
- }
1226
- catch (err) {
1227
- res.writeHead(500, { "Content-Type": "application/json" });
1228
- res.end(JSON.stringify({
1229
- error: err instanceof Error ? err.message : String(err),
1230
- }));
1326
+ // 32 KB — approvals carry decision + reason + optional permission
1327
+ // rule patches; 32 KB matches RECIPE_ROUTE_BODY_CAPS.run. Critical
1328
+ // here: /approve/:id and /reject/:id are reachable via the
1329
+ // x-approval-token phone-path bypass at line 609-612 — without a
1330
+ // cap an unbounded body read happens *before* token validation.
1331
+ const APPROVALS_BODY_CAP = 32 * 1024;
1332
+ const parsed = await readJsonBody(req, APPROVALS_BODY_CAP);
1333
+ if (!parsed.ok) {
1334
+ if (parsed.code === "too_large") {
1335
+ respond413(res, APPROVALS_BODY_CAP);
1336
+ return;
1231
1337
  }
1232
- });
1338
+ res.writeHead(400, { "Content-Type": "application/json" });
1339
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
1340
+ return;
1341
+ }
1342
+ const parsedBody = parsed.value;
1343
+ try {
1344
+ const result = await routeApprovalRequest({
1345
+ method: req.method ?? "GET",
1346
+ path: parsedUrl.pathname,
1347
+ body: parsedBody,
1348
+ query: parsedUrl.searchParams,
1349
+ approvalToken: req.headers["x-approval-token"],
1350
+ }, {
1351
+ queue: getApprovalQueue(),
1352
+ workspace: process.cwd(),
1353
+ managedSettingsPath: this.managedSettingsPath,
1354
+ onDecision: this.onApprovalDecision,
1355
+ webhookUrl: this.approvalWebhookUrl,
1356
+ approvalGate: this.approvalGate,
1357
+ pushServiceUrl: this.pushServiceUrl,
1358
+ pushServiceToken: this.pushServiceToken,
1359
+ pushServiceBaseUrl: this.pushServiceBaseUrl,
1360
+ ntfyTopic: this.ntfyTopic,
1361
+ ntfyServer: this.ntfyServer,
1362
+ activityLog: this.activityLog,
1363
+ // RecipeRunLog satisfies RecipeRunQuerier structurally
1364
+ // — the cast bridges TS contravariance: RecipeRunQuerier's
1365
+ // narrow query interface (`status?: string`) is deliberately
1366
+ // loose so tests can mock it; RecipeRunLog's stricter
1367
+ // RunStatus union is a strict subset and fails the param
1368
+ // contravariance check despite being safe at runtime.
1369
+ recipeRunLog: this.recipeRunLog,
1370
+ enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
1371
+ });
1372
+ res.writeHead(result.status, {
1373
+ "Content-Type": "application/json",
1374
+ });
1375
+ res.end(JSON.stringify(result.body));
1376
+ }
1377
+ catch (err) {
1378
+ respond500(res, err);
1379
+ }
1233
1380
  return;
1234
1381
  }
1235
1382
  // Quick-task launch endpoint — mirrors /notify pattern. Bearer auth already checked above.
1236
1383
  if (parsedUrl.pathname === "/launch-quick-task" &&
1237
1384
  req.method === "POST") {
1238
- const chunks = [];
1239
- req.on("data", (c) => chunks.push(c));
1240
- req.on("end", () => {
1241
- void (async () => {
1242
- try {
1243
- const body = Buffer.concat(chunks).toString("utf-8");
1244
- const parsed = JSON.parse(body);
1245
- const presetId = parsed.presetId;
1246
- const source = parsed.source ?? "cli";
1247
- if (typeof presetId !== "string" || !presetId) {
1248
- res.writeHead(400, { "Content-Type": "application/json" });
1249
- res.end(JSON.stringify({
1250
- ok: false,
1251
- error: "presetId required",
1252
- }));
1253
- return;
1254
- }
1255
- if (!this.launchQuickTaskFn) {
1256
- res.writeHead(503, { "Content-Type": "application/json" });
1257
- res.end(JSON.stringify({
1258
- ok: false,
1259
- error: "Quick tasks unavailable — requires --claude-driver subprocess",
1260
- }));
1261
- return;
1262
- }
1263
- const result = await this.launchQuickTaskFn(presetId, source);
1264
- res.writeHead(result.ok ? 200 : 429, {
1265
- "Content-Type": "application/json",
1266
- });
1267
- res.end(JSON.stringify(result));
1268
- }
1269
- catch {
1270
- res.writeHead(400, { "Content-Type": "application/json" });
1271
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1272
- }
1273
- })();
1385
+ // 4 KB — body is `{ presetId?: string; source?: string }`, two
1386
+ // short strings. 4 KB is plenty.
1387
+ const QUICK_TASK_BODY_CAP = 4 * 1024;
1388
+ const parsed = await readJsonBody(req, QUICK_TASK_BODY_CAP);
1389
+ if (!parsed.ok) {
1390
+ if (parsed.code === "too_large") {
1391
+ respond413(res, QUICK_TASK_BODY_CAP);
1392
+ return;
1393
+ }
1394
+ res.writeHead(400, { "Content-Type": "application/json" });
1395
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1396
+ return;
1397
+ }
1398
+ const presetId = parsed.value?.presetId;
1399
+ const source = parsed.value?.source ?? "cli";
1400
+ if (typeof presetId !== "string" || !presetId) {
1401
+ res.writeHead(400, { "Content-Type": "application/json" });
1402
+ res.end(JSON.stringify({
1403
+ ok: false,
1404
+ error: "presetId required",
1405
+ }));
1406
+ return;
1407
+ }
1408
+ if (!this.launchQuickTaskFn) {
1409
+ res.writeHead(503, { "Content-Type": "application/json" });
1410
+ res.end(JSON.stringify({
1411
+ ok: false,
1412
+ error: "Quick tasks unavailable — requires --claude-driver subprocess",
1413
+ }));
1414
+ return;
1415
+ }
1416
+ const result = await this.launchQuickTaskFn(presetId, source);
1417
+ res.writeHead(result.ok ? 200 : 429, {
1418
+ "Content-Type": "application/json",
1274
1419
  });
1420
+ res.end(JSON.stringify(result));
1275
1421
  return;
1276
1422
  }
1277
1423
  // MCP Streamable HTTP transport — POST/GET/DELETE /mcp.
@@ -1283,11 +1429,9 @@ export class Server extends EventEmitter {
1283
1429
  req.method === "GET" ||
1284
1430
  req.method === "DELETE") {
1285
1431
  this.httpMcpHandler(req, res).catch((err) => {
1286
- this.logger.error(`HTTP MCP handler error: ${err instanceof Error ? err.message : String(err)}`);
1287
- if (!res.headersSent) {
1288
- res.writeHead(500, { "Content-Type": "application/json" });
1289
- res.end(JSON.stringify({ error: String(err) }));
1290
- }
1432
+ // respond500 logs the underlying error detail server-side; no
1433
+ // need to also funnel it through this.logger.
1434
+ respond500(res, err, "/mcp HTTP handler");
1291
1435
  });
1292
1436
  return;
1293
1437
  }
@@ -1420,6 +1564,41 @@ export class Server extends EventEmitter {
1420
1564
  this.emit("connection", ws);
1421
1565
  });
1422
1566
  }
1567
+ /**
1568
+ * Resolve the client IP for rate-limit bucketing on the phone-path
1569
+ * approval bypass. Default: the direct socket peer. When trustedProxies
1570
+ * is set AND the socket peer is one of them, the rightmost X-Forwarded-For
1571
+ * entry not in the trusted list is the real client. XFF from an untrusted
1572
+ * peer is ignored — that header is spoofable by anyone who can reach the
1573
+ * server directly. Returns null when no IP can be determined; the caller
1574
+ * should fail closed.
1575
+ */
1576
+ getClientIp(req) {
1577
+ const socketIp = req.socket?.remoteAddress;
1578
+ if (socketIp &&
1579
+ this.trustedProxies.length > 0 &&
1580
+ this.trustedProxies.includes(socketIp)) {
1581
+ const xffRaw = req.headers["x-forwarded-for"];
1582
+ const xffStr = Array.isArray(xffRaw) ? xffRaw[0] : xffRaw;
1583
+ if (typeof xffStr === "string" && xffStr.length > 0) {
1584
+ const hops = xffStr
1585
+ .split(",")
1586
+ .map((s) => s.trim())
1587
+ .filter((s) => s.length > 0);
1588
+ // Walk right→left (proxies append to the right). The rightmost hop
1589
+ // we DON'T trust is the client; everything to its right is our own
1590
+ // hop chain.
1591
+ for (let i = hops.length - 1; i >= 0; i--) {
1592
+ const hop = hops[i];
1593
+ if (hop !== undefined && !this.trustedProxies.includes(hop)) {
1594
+ return hop.slice(0, 64);
1595
+ }
1596
+ }
1597
+ // Edge case: every hop is in trustedProxies — unusual; fall through.
1598
+ }
1599
+ }
1600
+ return socketIp ? socketIp.slice(0, 64) : null;
1601
+ }
1423
1602
  async listen(port, bindAddress = "127.0.0.1") {
1424
1603
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
1425
1604
  if (!LOOPBACK.has(bindAddress)) {