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

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 (92) hide show
  1. package/README.md +4 -1
  2. package/dist/analyticsAggregator.d.ts +5 -1
  3. package/dist/analyticsAggregator.js +15 -4
  4. package/dist/analyticsAggregator.js.map +1 -1
  5. package/dist/analyticsPrefs.d.ts +11 -0
  6. package/dist/analyticsPrefs.js +33 -0
  7. package/dist/analyticsPrefs.js.map +1 -1
  8. package/dist/bridge.js +36 -26
  9. package/dist/bridge.js.map +1 -1
  10. package/dist/claudeDriver.d.ts +0 -16
  11. package/dist/claudeDriver.js +19 -20
  12. package/dist/claudeDriver.js.map +1 -1
  13. package/dist/claudeMdPatch.d.ts +9 -3
  14. package/dist/claudeMdPatch.js +79 -13
  15. package/dist/claudeMdPatch.js.map +1 -1
  16. package/dist/claudeOrchestrator.d.ts +12 -0
  17. package/dist/claudeOrchestrator.js +2 -0
  18. package/dist/claudeOrchestrator.js.map +1 -1
  19. package/dist/commands/marketplace.d.ts +15 -10
  20. package/dist/commands/marketplace.js +27 -115
  21. package/dist/commands/marketplace.js.map +1 -1
  22. package/dist/commitIssueLinkLog.d.ts +8 -0
  23. package/dist/commitIssueLinkLog.js +53 -1
  24. package/dist/commitIssueLinkLog.js.map +1 -1
  25. package/dist/config.d.ts +3 -3
  26. package/dist/config.js +13 -2
  27. package/dist/config.js.map +1 -1
  28. package/dist/connectorRoutes.js +63 -372
  29. package/dist/connectorRoutes.js.map +1 -1
  30. package/dist/connectors/jira.js +18 -1
  31. package/dist/connectors/jira.js.map +1 -1
  32. package/dist/drivers/claude/subprocess.d.ts +12 -2
  33. package/dist/drivers/claude/subprocess.js +79 -6
  34. package/dist/drivers/claude/subprocess.js.map +1 -1
  35. package/dist/drivers/gemini/api.d.ts +18 -0
  36. package/dist/drivers/gemini/api.js +29 -0
  37. package/dist/drivers/gemini/api.js.map +1 -0
  38. package/dist/drivers/index.d.ts +3 -1
  39. package/dist/drivers/index.js +9 -1
  40. package/dist/drivers/index.js.map +1 -1
  41. package/dist/drivers/local/index.d.ts +26 -0
  42. package/dist/drivers/local/index.js +41 -0
  43. package/dist/drivers/local/index.js.map +1 -0
  44. package/dist/httpErrorResponse.d.ts +36 -0
  45. package/dist/httpErrorResponse.js +46 -0
  46. package/dist/httpErrorResponse.js.map +1 -0
  47. package/dist/inboxRoutes.js +90 -11
  48. package/dist/inboxRoutes.js.map +1 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +3 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/oauth.d.ts +13 -0
  53. package/dist/oauth.js +13 -0
  54. package/dist/oauth.js.map +1 -1
  55. package/dist/oauthRoutes.js +3 -8
  56. package/dist/oauthRoutes.js.map +1 -1
  57. package/dist/patchworkConfig.d.ts +14 -1
  58. package/dist/patchworkConfig.js +99 -4
  59. package/dist/patchworkConfig.js.map +1 -1
  60. package/dist/preToolUseHook.js +7 -1
  61. package/dist/preToolUseHook.js.map +1 -1
  62. package/dist/prompts.js +4 -0
  63. package/dist/prompts.js.map +1 -1
  64. package/dist/recipeOrchestration.js +13 -3
  65. package/dist/recipeOrchestration.js.map +1 -1
  66. package/dist/recipeRoutes.d.ts +5 -0
  67. package/dist/recipeRoutes.js +57 -33
  68. package/dist/recipeRoutes.js.map +1 -1
  69. package/dist/recipes/agentExecutor.d.ts +10 -1
  70. package/dist/recipes/agentExecutor.js +5 -4
  71. package/dist/recipes/agentExecutor.js.map +1 -1
  72. package/dist/recipes/tools/gmail.js +18 -1
  73. package/dist/recipes/tools/gmail.js.map +1 -1
  74. package/dist/recipes/yamlRunner.d.ts +15 -2
  75. package/dist/recipes/yamlRunner.js +11 -3
  76. package/dist/recipes/yamlRunner.js.map +1 -1
  77. package/dist/recipesHttp.d.ts +14 -0
  78. package/dist/recipesHttp.js +59 -1
  79. package/dist/recipesHttp.js.map +1 -1
  80. package/dist/server.d.ts +6 -0
  81. package/dist/server.js +249 -245
  82. package/dist/server.js.map +1 -1
  83. package/dist/tools/runCommand.js +5 -0
  84. package/dist/tools/runCommand.js.map +1 -1
  85. package/dist/tools/terminal.js +4 -0
  86. package/dist/tools/terminal.js.map +1 -1
  87. package/dist/tools/utils.d.ts +4 -0
  88. package/dist/tools/utils.js +59 -0
  89. package/dist/tools/utils.js.map +1 -1
  90. package/package.json +1 -1
  91. package/scripts/start-all.sh +4 -2
  92. 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;
@@ -87,6 +88,8 @@ export class Server extends EventEmitter {
87
88
  saveRecipeContentFn = null;
88
89
  /** Patchwork: set by bridge to delete a recipe by name. */
89
90
  deleteRecipeContentFn = null;
91
+ /** Patchwork: set by bridge to archive a recipe (move to .archive/). */
92
+ archiveRecipeFn = null;
90
93
  /** Patchwork: set by bridge to promote a variant recipe to the canonical name. */
91
94
  promoteRecipeVariantFn = null;
92
95
  /** Patchwork: set by bridge to duplicate a recipe as a variant. */
@@ -347,10 +350,7 @@ export class Server extends EventEmitter {
347
350
  res.end(JSON.stringify(body, null, 2));
348
351
  }
349
352
  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
- }));
353
+ respond500(res, err);
354
354
  }
355
355
  return;
356
356
  }
@@ -399,10 +399,7 @@ export class Server extends EventEmitter {
399
399
  res.end(body);
400
400
  }
401
401
  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
- }));
402
+ respond500(res, err);
406
403
  }
407
404
  return;
408
405
  }
@@ -418,10 +415,7 @@ export class Server extends EventEmitter {
418
415
  res.end(JSON.stringify(data));
419
416
  }
420
417
  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
- }));
418
+ respond500(res, err);
425
419
  }
426
420
  return;
427
421
  }
@@ -436,10 +430,7 @@ export class Server extends EventEmitter {
436
430
  res.end(JSON.stringify({ events: data, count: data.length }));
437
431
  }
438
432
  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
- }));
433
+ respond500(res, err);
443
434
  }
444
435
  return;
445
436
  }
@@ -475,10 +466,7 @@ export class Server extends EventEmitter {
475
466
  res.end(JSON.stringify(data));
476
467
  }
477
468
  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
- }));
469
+ respond500(res, err);
482
470
  }
483
471
  return;
484
472
  }
@@ -549,12 +537,7 @@ export class Server extends EventEmitter {
549
537
  }
550
538
  }
551
539
  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
- }
540
+ respond500(res, err);
558
541
  }
559
542
  })();
560
543
  return;
@@ -577,10 +560,7 @@ export class Server extends EventEmitter {
577
560
  res.end(JSON.stringify(data));
578
561
  }
579
562
  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
- }));
563
+ respond500(res, err);
584
564
  }
585
565
  return;
586
566
  }
@@ -594,10 +574,7 @@ export class Server extends EventEmitter {
594
574
  res.end(JSON.stringify(data));
595
575
  }
596
576
  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
- }));
577
+ respond500(res, err);
601
578
  }
602
579
  return;
603
580
  }
@@ -625,10 +602,7 @@ export class Server extends EventEmitter {
625
602
  }
626
603
  }
627
604
  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
- }));
605
+ respond500(res, err);
632
606
  }
633
607
  return;
634
608
  }
@@ -681,10 +655,7 @@ export class Server extends EventEmitter {
681
655
  res.end(JSON.stringify(data));
682
656
  }
683
657
  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
- }));
658
+ respond500(res, err);
688
659
  }
689
660
  return;
690
661
  }
@@ -703,67 +674,64 @@ export class Server extends EventEmitter {
703
674
  }
704
675
  }
705
676
  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
- }));
677
+ respond500(res, err);
710
678
  }
711
679
  return;
712
680
  }
713
681
  if (parsedUrl.pathname?.startsWith("/hooks/") && req.method === "POST") {
714
682
  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
- });
683
+ // 256 KB — webhook payloads from GitHub/Linear/etc. are typically
684
+ // 1–25 KB; 256 KB matches RECIPE_ROUTE_BODY_CAPS.content as a
685
+ // generous ceiling that still bounds an authenticated DoS vector.
686
+ const HOOKS_BODY_CAP = 256 * 1024;
687
+ const read = await readBodyWithCap(req, HOOKS_BODY_CAP);
688
+ if (!read.ok) {
689
+ respond413(res, HOOKS_BODY_CAP);
690
+ return;
691
+ }
692
+ let payload;
693
+ if (read.body.trim()) {
694
+ try {
695
+ payload = JSON.parse(read.body);
696
+ }
697
+ catch {
698
+ payload = read.body;
699
+ }
700
+ }
701
+ if (!this.webhookFn) {
702
+ res.writeHead(503, { "Content-Type": "application/json" });
703
+ res.end(JSON.stringify({
704
+ ok: false,
705
+ error: "Webhooks unavailable — start bridge with --claude-driver subprocess",
706
+ }));
707
+ return;
708
+ }
709
+ const result = await this.webhookFn(hookPath, payload);
710
+ const status = result.ok
711
+ ? 200
712
+ : result.error === "not_found"
713
+ ? 404
714
+ : 400;
715
+ // Record in ring buffer so the dashboard can show "last
716
+ // payload" per recipe. Skip not_found so unknown paths don't
717
+ // pollute the buffer with garbage / scanner traffic.
718
+ if (result.error !== "not_found") {
719
+ const existing = this.webhookPayloads.get(hookPath) ?? [];
720
+ existing.unshift({
721
+ receivedAt: Date.now(),
722
+ payload,
723
+ ok: result.ok,
724
+ error: result.error,
725
+ taskId: result.taskId,
726
+ recipeName: result.name,
727
+ });
728
+ if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
729
+ existing.length = Server.MAX_WEBHOOK_PAYLOADS;
730
+ }
731
+ this.webhookPayloads.set(hookPath, existing);
732
+ }
733
+ res.writeHead(status, { "Content-Type": "application/json" });
734
+ res.end(JSON.stringify(result));
767
735
  return;
768
736
  }
769
737
  if (parsedUrl.pathname?.startsWith("/webhook-payloads/") &&
@@ -916,6 +884,7 @@ export class Server extends EventEmitter {
916
884
  loadRecipeContentFn: this.loadRecipeContentFn,
917
885
  saveRecipeContentFn: this.saveRecipeContentFn,
918
886
  deleteRecipeContentFn: this.deleteRecipeContentFn,
887
+ archiveRecipeFn: this.archiveRecipeFn,
919
888
  duplicateRecipeFn: this.duplicateRecipeFn,
920
889
  promoteRecipeVariantFn: this.promoteRecipeVariantFn,
921
890
  lintRecipeContentFn: this.lintRecipeContentFn,
@@ -943,10 +912,7 @@ export class Server extends EventEmitter {
943
912
  res.end(JSON.stringify(data));
944
913
  }
945
914
  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
- }));
915
+ respond500(res, err);
950
916
  }
951
917
  return;
952
918
  }
@@ -962,19 +928,28 @@ export class Server extends EventEmitter {
962
928
  res.end(JSON.stringify(data));
963
929
  }
964
930
  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
- }));
931
+ respond500(res, err);
969
932
  }
970
933
  return;
971
934
  }
972
935
  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"));
936
+ // 16 KB — settings POSTs are short-string fields (URLs, API keys,
937
+ // gate level). 16 KB is generous; an authenticated attacker can't
938
+ // stream gigabytes here.
939
+ const SETTINGS_BODY_CAP = 16 * 1024;
940
+ const parsed = await readJsonBody(req, SETTINGS_BODY_CAP);
941
+ if (!parsed.ok) {
942
+ if (parsed.code === "too_large") {
943
+ respond413(res, SETTINGS_BODY_CAP);
944
+ return;
945
+ }
946
+ res.writeHead(400, { "Content-Type": "application/json" });
947
+ res.end(JSON.stringify({ error: "Invalid request body" }));
948
+ return;
949
+ }
950
+ try {
951
+ {
952
+ const body = parsed.value ?? {};
978
953
  const hasWebhookUpdate = body.webhookUrl !== undefined;
979
954
  const raw = hasWebhookUpdate
980
955
  ? (body.webhookUrl?.trim() ?? "")
@@ -1032,6 +1007,8 @@ export class Server extends EventEmitter {
1032
1007
  "openai",
1033
1008
  "grok",
1034
1009
  "gemini",
1010
+ "gemini-api",
1011
+ "local",
1035
1012
  "none",
1036
1013
  ];
1037
1014
  if (!validDrivers.includes(driverRaw)) {
@@ -1043,7 +1020,17 @@ export class Server extends EventEmitter {
1043
1020
  }
1044
1021
  const driver = driverRaw;
1045
1022
  cfg.driver = driver;
1046
- saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1023
+ try {
1024
+ saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1025
+ }
1026
+ catch (writeErr) {
1027
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1028
+ res.writeHead(500, { "Content-Type": "application/json" });
1029
+ res.end(JSON.stringify({
1030
+ error: "Failed to write bridge driver config",
1031
+ }));
1032
+ return;
1033
+ }
1047
1034
  }
1048
1035
  if (body.model !== undefined) {
1049
1036
  const validModels = [
@@ -1077,9 +1064,32 @@ export class Server extends EventEmitter {
1077
1064
  res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1078
1065
  return;
1079
1066
  }
1080
- cfg.apiKeys = { ...cfg.apiKeys, [provider]: key || undefined };
1067
+ // Provider keys go to the secure store (Keychain/DPAPI/Secret
1068
+ // Service / AES-256-GCM file fallback) — never persisted to
1069
+ // ~/.patchwork/config.json. Empty string clears.
1070
+ try {
1071
+ saveApiKeyToSecureStore(provider, key);
1072
+ }
1073
+ catch (writeErr) {
1074
+ this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1075
+ res.writeHead(500, { "Content-Type": "application/json" });
1076
+ res.end(JSON.stringify({
1077
+ error: "Failed to write provider API key",
1078
+ }));
1079
+ return;
1080
+ }
1081
+ }
1082
+ try {
1083
+ savePatchworkConfig(cfg, configPath);
1084
+ }
1085
+ catch (writeErr) {
1086
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1087
+ res.writeHead(500, { "Content-Type": "application/json" });
1088
+ res.end(JSON.stringify({
1089
+ error: "Failed to write patchwork config",
1090
+ }));
1091
+ return;
1081
1092
  }
1082
- savePatchworkConfig(cfg, configPath);
1083
1093
  if (hasWebhookUpdate) {
1084
1094
  this.approvalWebhookUrl = raw || undefined;
1085
1095
  }
@@ -1105,44 +1115,45 @@ export class Server extends EventEmitter {
1105
1115
  res.writeHead(200, { "Content-Type": "application/json" });
1106
1116
  res.end(JSON.stringify({ ok: true, restartRequired }));
1107
1117
  }
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
- });
1118
+ }
1119
+ catch (err) {
1120
+ this.logger.error(`[/config/patchwork] error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1121
+ res.writeHead(400, { "Content-Type": "application/json" });
1122
+ res.end(JSON.stringify({ error: "Invalid request body" }));
1123
+ }
1115
1124
  return;
1116
1125
  }
1117
1126
  // CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
1118
1127
  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" }));
1128
+ // 8 KB — notify payloads carry an event name + small arg map
1129
+ // (taskId, prompt, tool name). 8 KB fits everything we send today
1130
+ // with headroom.
1131
+ const NOTIFY_BODY_CAP = 8 * 1024;
1132
+ const parsed = await readJsonBody(req, NOTIFY_BODY_CAP);
1133
+ if (!parsed.ok) {
1134
+ if (parsed.code === "too_large") {
1135
+ respond413(res, NOTIFY_BODY_CAP);
1136
+ return;
1144
1137
  }
1138
+ res.writeHead(400, { "Content-Type": "application/json" });
1139
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1140
+ return;
1141
+ }
1142
+ const event = parsed.value?.event ?? "";
1143
+ const args = parsed.value?.args ?? {};
1144
+ if (!this.notifyFn) {
1145
+ res.writeHead(503, { "Content-Type": "application/json" });
1146
+ res.end(JSON.stringify({
1147
+ ok: false,
1148
+ error: "Automation not enabled",
1149
+ }));
1150
+ return;
1151
+ }
1152
+ const result = this.notifyFn(event, args);
1153
+ res.writeHead(result.ok ? 200 : 400, {
1154
+ "Content-Type": "application/json",
1145
1155
  });
1156
+ res.end(JSON.stringify(result));
1146
1157
  return;
1147
1158
  }
1148
1159
  // Single-approval detail lookup for the dashboard detail page.
@@ -1160,10 +1171,7 @@ export class Server extends EventEmitter {
1160
1171
  res.end(JSON.stringify(data));
1161
1172
  }
1162
1173
  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
- }));
1174
+ respond500(res, err);
1167
1175
  }
1168
1176
  return;
1169
1177
  }
@@ -1177,101 +1185,99 @@ export class Server extends EventEmitter {
1177
1185
  if (parsedUrl.pathname === "/approvals" ||
1178
1186
  parsedUrl.pathname === "/cc-permissions" ||
1179
1187
  /^\/(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
- }));
1188
+ // 32 KB — approvals carry decision + reason + optional permission
1189
+ // rule patches; 32 KB matches RECIPE_ROUTE_BODY_CAPS.run. Critical
1190
+ // here: /approve/:id and /reject/:id are reachable via the
1191
+ // x-approval-token phone-path bypass at line 609-612 — without a
1192
+ // cap an unbounded body read happens *before* token validation.
1193
+ const APPROVALS_BODY_CAP = 32 * 1024;
1194
+ const parsed = await readJsonBody(req, APPROVALS_BODY_CAP);
1195
+ if (!parsed.ok) {
1196
+ if (parsed.code === "too_large") {
1197
+ respond413(res, APPROVALS_BODY_CAP);
1198
+ return;
1231
1199
  }
1232
- });
1200
+ res.writeHead(400, { "Content-Type": "application/json" });
1201
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
1202
+ return;
1203
+ }
1204
+ const parsedBody = parsed.value;
1205
+ try {
1206
+ const result = await routeApprovalRequest({
1207
+ method: req.method ?? "GET",
1208
+ path: parsedUrl.pathname,
1209
+ body: parsedBody,
1210
+ query: parsedUrl.searchParams,
1211
+ approvalToken: req.headers["x-approval-token"],
1212
+ }, {
1213
+ queue: getApprovalQueue(),
1214
+ workspace: process.cwd(),
1215
+ managedSettingsPath: this.managedSettingsPath,
1216
+ onDecision: this.onApprovalDecision,
1217
+ webhookUrl: this.approvalWebhookUrl,
1218
+ approvalGate: this.approvalGate,
1219
+ pushServiceUrl: this.pushServiceUrl,
1220
+ pushServiceToken: this.pushServiceToken,
1221
+ pushServiceBaseUrl: this.pushServiceBaseUrl,
1222
+ activityLog: this.activityLog,
1223
+ // RecipeRunLog satisfies RecipeRunQuerier structurally
1224
+ // — the cast bridges TS contravariance: RecipeRunQuerier's
1225
+ // narrow query interface (`status?: string`) is deliberately
1226
+ // loose so tests can mock it; RecipeRunLog's stricter
1227
+ // RunStatus union is a strict subset and fails the param
1228
+ // contravariance check despite being safe at runtime.
1229
+ recipeRunLog: this.recipeRunLog,
1230
+ enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
1231
+ });
1232
+ res.writeHead(result.status, {
1233
+ "Content-Type": "application/json",
1234
+ });
1235
+ res.end(JSON.stringify(result.body));
1236
+ }
1237
+ catch (err) {
1238
+ respond500(res, err);
1239
+ }
1233
1240
  return;
1234
1241
  }
1235
1242
  // Quick-task launch endpoint — mirrors /notify pattern. Bearer auth already checked above.
1236
1243
  if (parsedUrl.pathname === "/launch-quick-task" &&
1237
1244
  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
- })();
1245
+ // 4 KB — body is `{ presetId?: string; source?: string }`, two
1246
+ // short strings. 4 KB is plenty.
1247
+ const QUICK_TASK_BODY_CAP = 4 * 1024;
1248
+ const parsed = await readJsonBody(req, QUICK_TASK_BODY_CAP);
1249
+ if (!parsed.ok) {
1250
+ if (parsed.code === "too_large") {
1251
+ respond413(res, QUICK_TASK_BODY_CAP);
1252
+ return;
1253
+ }
1254
+ res.writeHead(400, { "Content-Type": "application/json" });
1255
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1256
+ return;
1257
+ }
1258
+ const presetId = parsed.value?.presetId;
1259
+ const source = parsed.value?.source ?? "cli";
1260
+ if (typeof presetId !== "string" || !presetId) {
1261
+ res.writeHead(400, { "Content-Type": "application/json" });
1262
+ res.end(JSON.stringify({
1263
+ ok: false,
1264
+ error: "presetId required",
1265
+ }));
1266
+ return;
1267
+ }
1268
+ if (!this.launchQuickTaskFn) {
1269
+ res.writeHead(503, { "Content-Type": "application/json" });
1270
+ res.end(JSON.stringify({
1271
+ ok: false,
1272
+ error: "Quick tasks unavailable — requires --claude-driver subprocess",
1273
+ }));
1274
+ return;
1275
+ }
1276
+ const result = await this.launchQuickTaskFn(presetId, source);
1277
+ res.writeHead(result.ok ? 200 : 429, {
1278
+ "Content-Type": "application/json",
1274
1279
  });
1280
+ res.end(JSON.stringify(result));
1275
1281
  return;
1276
1282
  }
1277
1283
  // MCP Streamable HTTP transport — POST/GET/DELETE /mcp.
@@ -1283,11 +1289,9 @@ export class Server extends EventEmitter {
1283
1289
  req.method === "GET" ||
1284
1290
  req.method === "DELETE") {
1285
1291
  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
- }
1292
+ // respond500 logs the underlying error detail server-side; no
1293
+ // need to also funnel it through this.logger.
1294
+ respond500(res, err, "/mcp HTTP handler");
1291
1295
  });
1292
1296
  return;
1293
1297
  }