patchwork-os 0.2.0-alpha.2 → 0.2.0-alpha.22

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 (281) hide show
  1. package/README.bridge.md +6 -0
  2. package/README.md +40 -15
  3. package/deploy/bootstrap-vps.sh +184 -0
  4. package/dist/approvalHttp.d.ts +11 -2
  5. package/dist/approvalHttp.js +98 -10
  6. package/dist/approvalHttp.js.map +1 -1
  7. package/dist/approvalQueue.d.ts +12 -1
  8. package/dist/approvalQueue.js +25 -3
  9. package/dist/approvalQueue.js.map +1 -1
  10. package/dist/automation.d.ts +20 -0
  11. package/dist/automation.js +35 -0
  12. package/dist/automation.js.map +1 -1
  13. package/dist/bridge.js +145 -23
  14. package/dist/bridge.js.map +1 -1
  15. package/dist/bridgeToken.js +57 -19
  16. package/dist/bridgeToken.js.map +1 -1
  17. package/dist/claudeDriver.d.ts +3 -1
  18. package/dist/claudeDriver.js +48 -0
  19. package/dist/claudeDriver.js.map +1 -1
  20. package/dist/claudeOrchestrator.d.ts +1 -1
  21. package/dist/claudeOrchestrator.js +14 -8
  22. package/dist/claudeOrchestrator.js.map +1 -1
  23. package/dist/commands/launchd.d.ts +2 -0
  24. package/dist/commands/launchd.js +94 -0
  25. package/dist/commands/launchd.js.map +1 -0
  26. package/dist/commands/recipe.d.ts +256 -0
  27. package/dist/commands/recipe.js +1313 -0
  28. package/dist/commands/recipe.js.map +1 -0
  29. package/dist/config.d.ts +15 -2
  30. package/dist/config.js +94 -8
  31. package/dist/config.js.map +1 -1
  32. package/dist/connectors/baseConnector.d.ts +117 -0
  33. package/dist/connectors/baseConnector.js +213 -0
  34. package/dist/connectors/baseConnector.js.map +1 -0
  35. package/dist/connectors/confluence.d.ts +111 -0
  36. package/dist/connectors/confluence.js +406 -0
  37. package/dist/connectors/confluence.js.map +1 -0
  38. package/dist/connectors/fixtureLibrary.d.ts +21 -0
  39. package/dist/connectors/fixtureLibrary.js +70 -0
  40. package/dist/connectors/fixtureLibrary.js.map +1 -0
  41. package/dist/connectors/fixtureRecorder.d.ts +1 -0
  42. package/dist/connectors/fixtureRecorder.js +35 -0
  43. package/dist/connectors/fixtureRecorder.js.map +1 -0
  44. package/dist/connectors/github.d.ts +58 -8
  45. package/dist/connectors/github.js +312 -84
  46. package/dist/connectors/github.js.map +1 -1
  47. package/dist/connectors/gmail.d.ts +4 -1
  48. package/dist/connectors/gmail.js +93 -16
  49. package/dist/connectors/gmail.js.map +1 -1
  50. package/dist/connectors/googleCalendar.d.ts +60 -0
  51. package/dist/connectors/googleCalendar.js +345 -0
  52. package/dist/connectors/googleCalendar.js.map +1 -0
  53. package/dist/connectors/jira.d.ts +98 -0
  54. package/dist/connectors/jira.js +379 -0
  55. package/dist/connectors/jira.js.map +1 -0
  56. package/dist/connectors/linear.d.ts +117 -0
  57. package/dist/connectors/linear.js +239 -0
  58. package/dist/connectors/linear.js.map +1 -0
  59. package/dist/connectors/mcpClient.d.ts +56 -0
  60. package/dist/connectors/mcpClient.js +189 -0
  61. package/dist/connectors/mcpClient.js.map +1 -0
  62. package/dist/connectors/mcpOAuth.d.ts +84 -0
  63. package/dist/connectors/mcpOAuth.js +389 -0
  64. package/dist/connectors/mcpOAuth.js.map +1 -0
  65. package/dist/connectors/mockConnector.d.ts +28 -0
  66. package/dist/connectors/mockConnector.js +81 -0
  67. package/dist/connectors/mockConnector.js.map +1 -0
  68. package/dist/connectors/notion.d.ts +143 -0
  69. package/dist/connectors/notion.js +424 -0
  70. package/dist/connectors/notion.js.map +1 -0
  71. package/dist/connectors/sentry.d.ts +43 -0
  72. package/dist/connectors/sentry.js +188 -0
  73. package/dist/connectors/sentry.js.map +1 -0
  74. package/dist/connectors/slack.d.ts +50 -0
  75. package/dist/connectors/slack.js +324 -0
  76. package/dist/connectors/slack.js.map +1 -0
  77. package/dist/connectors/tokenStorage.d.ts +35 -0
  78. package/dist/connectors/tokenStorage.js +394 -0
  79. package/dist/connectors/tokenStorage.js.map +1 -0
  80. package/dist/connectors/zendesk.d.ts +104 -0
  81. package/dist/connectors/zendesk.js +424 -0
  82. package/dist/connectors/zendesk.js.map +1 -0
  83. package/dist/drivers/claude/api.d.ts +11 -0
  84. package/dist/drivers/claude/api.js +54 -0
  85. package/dist/drivers/claude/api.js.map +1 -0
  86. package/dist/drivers/claude/envSanitizer.d.ts +7 -0
  87. package/dist/drivers/claude/envSanitizer.js +18 -0
  88. package/dist/drivers/claude/envSanitizer.js.map +1 -0
  89. package/dist/drivers/claude/streamParser.d.ts +38 -0
  90. package/dist/drivers/claude/streamParser.js +34 -0
  91. package/dist/drivers/claude/streamParser.js.map +1 -0
  92. package/dist/drivers/claude/subprocess.d.ts +19 -0
  93. package/dist/drivers/claude/subprocess.js +216 -0
  94. package/dist/drivers/claude/subprocess.js.map +1 -0
  95. package/dist/drivers/claude/subprocessSettings.d.ts +9 -0
  96. package/dist/drivers/claude/subprocessSettings.js +55 -0
  97. package/dist/drivers/claude/subprocessSettings.js.map +1 -0
  98. package/dist/drivers/gemini/index.d.ts +18 -0
  99. package/dist/drivers/gemini/index.js +210 -0
  100. package/dist/drivers/gemini/index.js.map +1 -0
  101. package/dist/drivers/grok/index.d.ts +11 -0
  102. package/dist/drivers/grok/index.js +22 -0
  103. package/dist/drivers/grok/index.js.map +1 -0
  104. package/dist/drivers/index.d.ts +23 -0
  105. package/dist/drivers/index.js +31 -0
  106. package/dist/drivers/index.js.map +1 -0
  107. package/dist/drivers/openai/index.d.ts +24 -0
  108. package/dist/drivers/openai/index.js +110 -0
  109. package/dist/drivers/openai/index.js.map +1 -0
  110. package/dist/drivers/types.d.ts +72 -0
  111. package/dist/drivers/types.js +30 -0
  112. package/dist/drivers/types.js.map +1 -0
  113. package/dist/featureFlags.d.ts +73 -0
  114. package/dist/featureFlags.js +203 -0
  115. package/dist/featureFlags.js.map +1 -0
  116. package/dist/fp/automationInterpreter.js +1 -0
  117. package/dist/fp/automationInterpreter.js.map +1 -1
  118. package/dist/fp/automationProgram.d.ts +1 -1
  119. package/dist/fp/automationProgram.js.map +1 -1
  120. package/dist/fp/policyParser.js +17 -0
  121. package/dist/fp/policyParser.js.map +1 -1
  122. package/dist/index.js +543 -37
  123. package/dist/index.js.map +1 -1
  124. package/dist/installGuard.d.ts +25 -0
  125. package/dist/installGuard.js +48 -0
  126. package/dist/installGuard.js.map +1 -0
  127. package/dist/oauth.d.ts +4 -1
  128. package/dist/oauth.js +50 -14
  129. package/dist/oauth.js.map +1 -1
  130. package/dist/patchworkConfig.d.ts +9 -0
  131. package/dist/patchworkConfig.js.map +1 -1
  132. package/dist/recipes/chainedRunner.d.ts +104 -0
  133. package/dist/recipes/chainedRunner.js +359 -0
  134. package/dist/recipes/chainedRunner.js.map +1 -0
  135. package/dist/recipes/dependencyGraph.d.ts +39 -0
  136. package/dist/recipes/dependencyGraph.js +199 -0
  137. package/dist/recipes/dependencyGraph.js.map +1 -0
  138. package/dist/recipes/legacyRecipeCompat.d.ts +1 -0
  139. package/dist/recipes/legacyRecipeCompat.js +97 -0
  140. package/dist/recipes/legacyRecipeCompat.js.map +1 -0
  141. package/dist/recipes/nestedRecipeStep.d.ts +58 -0
  142. package/dist/recipes/nestedRecipeStep.js +95 -0
  143. package/dist/recipes/nestedRecipeStep.js.map +1 -0
  144. package/dist/recipes/outputRegistry.d.ts +28 -0
  145. package/dist/recipes/outputRegistry.js +52 -0
  146. package/dist/recipes/outputRegistry.js.map +1 -0
  147. package/dist/recipes/scheduler.d.ts +23 -7
  148. package/dist/recipes/scheduler.js +135 -41
  149. package/dist/recipes/scheduler.js.map +1 -1
  150. package/dist/recipes/schemaGenerator.d.ts +28 -0
  151. package/dist/recipes/schemaGenerator.js +484 -0
  152. package/dist/recipes/schemaGenerator.js.map +1 -0
  153. package/dist/recipes/templateEngine.d.ts +62 -0
  154. package/dist/recipes/templateEngine.js +182 -0
  155. package/dist/recipes/templateEngine.js.map +1 -0
  156. package/dist/recipes/toolRegistry.d.ts +181 -0
  157. package/dist/recipes/toolRegistry.js +300 -0
  158. package/dist/recipes/toolRegistry.js.map +1 -0
  159. package/dist/recipes/tools/calendar.d.ts +6 -0
  160. package/dist/recipes/tools/calendar.js +61 -0
  161. package/dist/recipes/tools/calendar.js.map +1 -0
  162. package/dist/recipes/tools/confluence.d.ts +6 -0
  163. package/dist/recipes/tools/confluence.js +254 -0
  164. package/dist/recipes/tools/confluence.js.map +1 -0
  165. package/dist/recipes/tools/diagnostics.d.ts +6 -0
  166. package/dist/recipes/tools/diagnostics.js +36 -0
  167. package/dist/recipes/tools/diagnostics.js.map +1 -0
  168. package/dist/recipes/tools/file.d.ts +6 -0
  169. package/dist/recipes/tools/file.js +170 -0
  170. package/dist/recipes/tools/file.js.map +1 -0
  171. package/dist/recipes/tools/git.d.ts +6 -0
  172. package/dist/recipes/tools/git.js +63 -0
  173. package/dist/recipes/tools/git.js.map +1 -0
  174. package/dist/recipes/tools/github.d.ts +6 -0
  175. package/dist/recipes/tools/github.js +91 -0
  176. package/dist/recipes/tools/github.js.map +1 -0
  177. package/dist/recipes/tools/gmail.d.ts +6 -0
  178. package/dist/recipes/tools/gmail.js +210 -0
  179. package/dist/recipes/tools/gmail.js.map +1 -0
  180. package/dist/recipes/tools/index.d.ts +18 -0
  181. package/dist/recipes/tools/index.js +21 -0
  182. package/dist/recipes/tools/index.js.map +1 -0
  183. package/dist/recipes/tools/linear.d.ts +6 -0
  184. package/dist/recipes/tools/linear.js +83 -0
  185. package/dist/recipes/tools/linear.js.map +1 -0
  186. package/dist/recipes/tools/notion.d.ts +6 -0
  187. package/dist/recipes/tools/notion.js +278 -0
  188. package/dist/recipes/tools/notion.js.map +1 -0
  189. package/dist/recipes/tools/slack.d.ts +6 -0
  190. package/dist/recipes/tools/slack.js +72 -0
  191. package/dist/recipes/tools/slack.js.map +1 -0
  192. package/dist/recipes/tools/zendesk.d.ts +6 -0
  193. package/dist/recipes/tools/zendesk.js +245 -0
  194. package/dist/recipes/tools/zendesk.js.map +1 -0
  195. package/dist/recipes/yamlRunner.d.ts +79 -0
  196. package/dist/recipes/yamlRunner.js +612 -346
  197. package/dist/recipes/yamlRunner.js.map +1 -1
  198. package/dist/recipesHttp.d.ts +14 -1
  199. package/dist/recipesHttp.js +21 -4
  200. package/dist/recipesHttp.js.map +1 -1
  201. package/dist/riskTier.js +1 -0
  202. package/dist/riskTier.js.map +1 -1
  203. package/dist/runLog.d.ts +23 -0
  204. package/dist/runLog.js +56 -1
  205. package/dist/runLog.js.map +1 -1
  206. package/dist/server.d.ts +19 -1
  207. package/dist/server.js +682 -31
  208. package/dist/server.js.map +1 -1
  209. package/dist/streamableHttp.js +2 -0
  210. package/dist/streamableHttp.js.map +1 -1
  211. package/dist/tools/addLinearComment.d.ts +55 -0
  212. package/dist/tools/addLinearComment.js +72 -0
  213. package/dist/tools/addLinearComment.js.map +1 -0
  214. package/dist/tools/bridgeDoctor.js +2 -2
  215. package/dist/tools/bridgeDoctor.js.map +1 -1
  216. package/dist/tools/createLinearIssue.d.ts +84 -0
  217. package/dist/tools/createLinearIssue.js +146 -0
  218. package/dist/tools/createLinearIssue.js.map +1 -0
  219. package/dist/tools/ctxGetTaskContext.d.ts +4 -1
  220. package/dist/tools/ctxGetTaskContext.js +45 -2
  221. package/dist/tools/ctxGetTaskContext.js.map +1 -1
  222. package/dist/tools/fetchCalendarEvents.d.ts +94 -0
  223. package/dist/tools/fetchCalendarEvents.js +97 -0
  224. package/dist/tools/fetchCalendarEvents.js.map +1 -0
  225. package/dist/tools/fetchGithubIssue.d.ts +80 -0
  226. package/dist/tools/fetchGithubIssue.js +84 -0
  227. package/dist/tools/fetchGithubIssue.js.map +1 -0
  228. package/dist/tools/fetchGithubPR.d.ts +89 -0
  229. package/dist/tools/fetchGithubPR.js +96 -0
  230. package/dist/tools/fetchGithubPR.js.map +1 -0
  231. package/dist/tools/fetchLinearIssue.d.ts +112 -0
  232. package/dist/tools/fetchLinearIssue.js +129 -0
  233. package/dist/tools/fetchLinearIssue.js.map +1 -0
  234. package/dist/tools/fetchSentryIssue.d.ts +143 -0
  235. package/dist/tools/fetchSentryIssue.js +150 -0
  236. package/dist/tools/fetchSentryIssue.js.map +1 -0
  237. package/dist/tools/fetchSlackProfile.d.ts +43 -0
  238. package/dist/tools/fetchSlackProfile.js +46 -0
  239. package/dist/tools/fetchSlackProfile.js.map +1 -0
  240. package/dist/tools/getConnectorStatus.d.ts +58 -0
  241. package/dist/tools/getConnectorStatus.js +56 -0
  242. package/dist/tools/getConnectorStatus.js.map +1 -0
  243. package/dist/tools/github/actions.js +4 -2
  244. package/dist/tools/github/actions.js.map +1 -1
  245. package/dist/tools/github/composite.d.ts +339 -0
  246. package/dist/tools/github/composite.js +343 -0
  247. package/dist/tools/github/composite.js.map +1 -0
  248. package/dist/tools/github/index.d.ts +2 -1
  249. package/dist/tools/github/index.js +2 -1
  250. package/dist/tools/github/index.js.map +1 -1
  251. package/dist/tools/github/issues.js +8 -4
  252. package/dist/tools/github/issues.js.map +1 -1
  253. package/dist/tools/github/pr.d.ts +122 -0
  254. package/dist/tools/github/pr.js +195 -5
  255. package/dist/tools/github/pr.js.map +1 -1
  256. package/dist/tools/index.js +36 -1
  257. package/dist/tools/index.js.map +1 -1
  258. package/dist/tools/searchTools.js +1 -1
  259. package/dist/tools/searchTools.js.map +1 -1
  260. package/dist/tools/slackListChannels.d.ts +65 -0
  261. package/dist/tools/slackListChannels.js +70 -0
  262. package/dist/tools/slackListChannels.js.map +1 -0
  263. package/dist/tools/slackPostMessage.d.ts +57 -0
  264. package/dist/tools/slackPostMessage.js +77 -0
  265. package/dist/tools/slackPostMessage.js.map +1 -0
  266. package/dist/tools/updateLinearIssue.d.ts +89 -0
  267. package/dist/tools/updateLinearIssue.js +117 -0
  268. package/dist/tools/updateLinearIssue.js.map +1 -0
  269. package/dist/transport.d.ts +7 -1
  270. package/dist/transport.js +85 -11
  271. package/dist/transport.js.map +1 -1
  272. package/package.json +4 -2
  273. package/scripts/start-all.sh +56 -19
  274. package/templates/automation-policies/recipe-authoring.json +25 -0
  275. package/templates/automation-policy.example.json +6 -0
  276. package/templates/co.patchwork-os.bridge.plist +34 -0
  277. package/templates/recipes/ctx-loop-test.yaml +75 -0
  278. package/templates/recipes/lint-on-save.yaml +1 -2
  279. package/templates/recipes/morning-brief-slack.yaml +57 -0
  280. package/templates/recipes/morning-brief.yaml +21 -5
  281. package/templates/recipes/sentry-to-linear.yaml +77 -0
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { WebSocket, WebSocketServer as WsServer } from "ws";
6
6
  import { routeApprovalRequest } from "./approvalHttp.js";
7
7
  import { getApprovalQueue } from "./approvalQueue.js";
8
+ import { saveBridgeConfigDriver } from "./config.js";
8
9
  import { timingSafeStringEqual } from "./crypto.js";
9
10
  import { renderDashboardHtml } from "./dashboard.js";
10
11
  import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
@@ -93,14 +94,26 @@ export class Server extends EventEmitter {
93
94
  saveRecipeFn = null;
94
95
  /** Patchwork: set by bridge to query the recipe run audit log. */
95
96
  runsFn = null;
97
+ /** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
98
+ runDetailFn = null;
99
+ /** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
100
+ runPlanFn = null;
96
101
  /** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
97
102
  runRecipeFn = null;
98
103
  /** Patchwork: admin-controlled managed settings path (highest rule precedence). */
99
104
  managedSettingsPath = undefined;
105
+ /** Effective bridge config path to update when dashboard saves driver changes. */
106
+ bridgeConfigPath = undefined;
100
107
  /** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
101
108
  approvalGate = "off";
102
109
  /** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
103
110
  approvalWebhookUrl = undefined;
111
+ /** Patchwork: push relay service URL — when set, per-callId approval tokens are generated. */
112
+ pushServiceUrl = undefined;
113
+ /** Patchwork: bearer token for the push relay service. */
114
+ pushServiceToken = undefined;
115
+ /** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
116
+ pushServiceBaseUrl = undefined;
104
117
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
105
118
  onApprovalDecision = undefined;
106
119
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
@@ -127,6 +140,7 @@ export class Server extends EventEmitter {
127
140
  sessionDetailFn = null;
128
141
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
129
142
  launchQuickTaskFn = null;
143
+ setRecipeEnabledFn = null;
130
144
  /**
131
145
  * Attach an OAuth 2.0 Authorization Server.
132
146
  * When set, the bridge exposes:
@@ -355,6 +369,144 @@ export class Server extends EventEmitter {
355
369
  res.end(JSON.stringify({ ok: true, v: PACKAGE_VERSION }));
356
370
  return;
357
371
  }
372
+ // ── Connector OAuth callbacks (unauthenticated — browser redirect from vendor) ──
373
+ if (parsedUrl.pathname === "/connections/github/callback" &&
374
+ req.method === "GET") {
375
+ void (async () => {
376
+ const { handleGithubCallback } = await import("./connectors/github.js");
377
+ const code = parsedUrl.searchParams.get("code");
378
+ const state = parsedUrl.searchParams.get("state");
379
+ const error = parsedUrl.searchParams.get("error");
380
+ const result = await handleGithubCallback(code, state, error);
381
+ res.writeHead(result.status, {
382
+ "Content-Type": result.contentType ?? "application/json",
383
+ });
384
+ res.end(result.body);
385
+ })();
386
+ return;
387
+ }
388
+ if (parsedUrl.pathname === "/connections/linear/callback" &&
389
+ req.method === "GET") {
390
+ void (async () => {
391
+ const { handleLinearCallback } = await import("./connectors/linear.js");
392
+ const code = parsedUrl.searchParams.get("code");
393
+ const state = parsedUrl.searchParams.get("state");
394
+ const error = parsedUrl.searchParams.get("error");
395
+ const result = await handleLinearCallback(code, state, error);
396
+ res.writeHead(result.status, {
397
+ "Content-Type": result.contentType ?? "application/json",
398
+ });
399
+ res.end(result.body);
400
+ })();
401
+ return;
402
+ }
403
+ if (parsedUrl.pathname === "/connections/sentry/callback" &&
404
+ req.method === "GET") {
405
+ void (async () => {
406
+ const { handleSentryCallback } = await import("./connectors/sentry.js");
407
+ const code = parsedUrl.searchParams.get("code");
408
+ const state = parsedUrl.searchParams.get("state");
409
+ const error = parsedUrl.searchParams.get("error");
410
+ const result = await handleSentryCallback(code, state, error);
411
+ res.writeHead(result.status, {
412
+ "Content-Type": result.contentType ?? "application/json",
413
+ });
414
+ res.end(result.body);
415
+ })();
416
+ return;
417
+ }
418
+ if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
419
+ req.method === "GET") {
420
+ void (async () => {
421
+ const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
422
+ const code = parsedUrl.searchParams.get("code");
423
+ const state = parsedUrl.searchParams.get("state");
424
+ const error = parsedUrl.searchParams.get("error");
425
+ const result = await handleCalendarCallback(code, state, error);
426
+ res.writeHead(result.status, {
427
+ "Content-Type": result.contentType ?? "application/json",
428
+ });
429
+ res.end(result.body);
430
+ })();
431
+ return;
432
+ }
433
+ if (parsedUrl.pathname === "/connections/slack/callback" &&
434
+ req.method === "GET") {
435
+ void (async () => {
436
+ const { handleSlackCallback } = await import("./connectors/slack.js");
437
+ const code = parsedUrl.searchParams.get("code");
438
+ const state = parsedUrl.searchParams.get("state");
439
+ const error = parsedUrl.searchParams.get("error");
440
+ const result = await handleSlackCallback(code, state, error);
441
+ res.writeHead(result.status, {
442
+ "Content-Type": result.contentType ?? "application/json",
443
+ });
444
+ res.end(result.body);
445
+ })();
446
+ return;
447
+ }
448
+ if (parsedUrl.pathname === "/connections/gmail/callback" &&
449
+ req.method === "GET") {
450
+ void (async () => {
451
+ const { handleGmailCallback } = await import("./connectors/gmail.js");
452
+ const code = parsedUrl.searchParams.get("code");
453
+ const state = parsedUrl.searchParams.get("state");
454
+ const error = parsedUrl.searchParams.get("error");
455
+ const result = await handleGmailCallback(code, state, error);
456
+ res.writeHead(result.status, {
457
+ "Content-Type": result.contentType ?? "text/html",
458
+ });
459
+ res.end(result.body);
460
+ })();
461
+ return;
462
+ }
463
+ // ── /schemas/* — unauthenticated registry-derived JSON Schemas ────────
464
+ // Serves recipe.v1.json, dry-run-plan.v1.json, tools/<ns>.json so YAML-LSP
465
+ // editors can resolve `$schema:` headers against a running bridge. No
466
+ // secrets — schemas are generated from the tool registry.
467
+ if (parsedUrl.pathname?.startsWith("/schemas/") && req.method === "GET") {
468
+ try {
469
+ await import("./recipes/tools/index.js");
470
+ const { generateSchemaSet } = await import("./recipes/schemaGenerator.js");
471
+ const schemas = generateSchemaSet();
472
+ const rest = parsedUrl.pathname.slice("/schemas/".length);
473
+ let body;
474
+ if (rest === "recipe.v1.json") {
475
+ body = schemas.recipe;
476
+ }
477
+ else if (rest === "dry-run-plan.v1.json") {
478
+ body = schemas.dryRunPlan;
479
+ }
480
+ else if (rest.startsWith("tools/") && rest.endsWith(".json")) {
481
+ const ns = rest.slice("tools/".length, -".json".length);
482
+ body = schemas.namespaces[ns];
483
+ }
484
+ else if (rest === "" || rest === "index.json") {
485
+ body = {
486
+ recipe: "/schemas/recipe.v1.json",
487
+ dryRunPlan: "/schemas/dry-run-plan.v1.json",
488
+ tools: Object.keys(schemas.namespaces).map((ns) => `/schemas/tools/${ns}.json`),
489
+ };
490
+ }
491
+ if (body === undefined) {
492
+ res.writeHead(404, { "Content-Type": "application/json" });
493
+ res.end(JSON.stringify({ error: `schema not found: ${rest}` }));
494
+ return;
495
+ }
496
+ res.writeHead(200, {
497
+ "Content-Type": "application/schema+json",
498
+ "Cache-Control": "public, max-age=60",
499
+ });
500
+ res.end(JSON.stringify(body, null, 2));
501
+ }
502
+ catch (err) {
503
+ res.writeHead(500, { "Content-Type": "application/json" });
504
+ res.end(JSON.stringify({
505
+ error: err instanceof Error ? err.message : String(err),
506
+ }));
507
+ }
508
+ return;
509
+ }
358
510
  // ── Bearer token authentication ───────────────────────────────────────
359
511
  // All other HTTP endpoints require a valid Bearer token.
360
512
  // Accepts either:
@@ -372,8 +524,13 @@ export class Server extends EventEmitter {
372
524
  const oauthResolved = !isStaticToken && this.oauthServer
373
525
  ? this.oauthServer.resolveBearerToken(bearer)
374
526
  : null;
527
+ // Phone-path: approve/reject with x-approval-token bypass bearer check.
528
+ // The token itself is validated inside routeApprovalRequest via queue.validateToken.
529
+ const isPhoneApprovalPath = req.method === "POST" &&
530
+ /^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
531
+ !!req.headers["x-approval-token"];
375
532
  // oauthResolved is the bridge token if the OAuth token is valid; null otherwise
376
- if (!isStaticToken && !oauthResolved) {
533
+ if (!isStaticToken && !oauthResolved && !isPhoneApprovalPath) {
377
534
  // RFC 6750: only include error= when a token was actually presented but invalid
378
535
  const tokenPresented = bearer.length > 0;
379
536
  const wwwAuth = this.oauthServer && this.oauthIssuerUrl
@@ -675,26 +832,124 @@ export class Server extends EventEmitter {
675
832
  })();
676
833
  return;
677
834
  }
678
- if (parsedUrl.pathname === "/connections/gmail/callback" &&
835
+ if (parsedUrl.pathname === "/connections/gmail" &&
836
+ req.method === "DELETE") {
837
+ void (async () => {
838
+ const { handleGmailDisconnect } = await import("./connectors/gmail.js");
839
+ const result = await handleGmailDisconnect();
840
+ res.writeHead(result.status, {
841
+ "Content-Type": result.contentType ?? "application/json",
842
+ });
843
+ res.end(result.body);
844
+ })();
845
+ return;
846
+ }
847
+ if (parsedUrl.pathname === "/connections/gmail/test" &&
848
+ req.method === "POST") {
849
+ void (async () => {
850
+ const { handleGmailTest } = await import("./connectors/gmail.js");
851
+ const result = await handleGmailTest();
852
+ res.writeHead(result.status, {
853
+ "Content-Type": result.contentType ?? "application/json",
854
+ });
855
+ res.end(result.body);
856
+ })();
857
+ return;
858
+ }
859
+ // ── GitHub MCP connector routes ─────────────────────────────────────
860
+ if (parsedUrl.pathname === "/connections/github/auth" &&
679
861
  req.method === "GET") {
680
862
  void (async () => {
681
- const { handleGmailCallback } = await import("./connectors/gmail.js");
863
+ const { handleGithubAuthorize } = await import("./connectors/github.js");
864
+ const result = await handleGithubAuthorize();
865
+ if (result.redirect) {
866
+ res.writeHead(302, { Location: result.redirect });
867
+ res.end();
868
+ }
869
+ else {
870
+ res.writeHead(result.status, {
871
+ "Content-Type": result.contentType ?? "application/json",
872
+ });
873
+ res.end(result.body);
874
+ }
875
+ })();
876
+ return;
877
+ }
878
+ if (parsedUrl.pathname === "/connections/github/test" &&
879
+ req.method === "POST") {
880
+ void (async () => {
881
+ const { handleGithubTest } = await import("./connectors/github.js");
882
+ const result = await handleGithubTest();
883
+ res.writeHead(result.status, {
884
+ "Content-Type": result.contentType ?? "application/json",
885
+ });
886
+ res.end(result.body);
887
+ })();
888
+ return;
889
+ }
890
+ if (parsedUrl.pathname === "/connections/github" &&
891
+ req.method === "DELETE") {
892
+ void (async () => {
893
+ const { handleGithubDisconnect } = await import("./connectors/github.js");
894
+ const result = await handleGithubDisconnect();
895
+ res.writeHead(result.status, {
896
+ "Content-Type": result.contentType ?? "application/json",
897
+ });
898
+ res.end(result.body);
899
+ })();
900
+ return;
901
+ }
902
+ // ── Sentry MCP connector routes ─────────────────────────────────────
903
+ if (parsedUrl.pathname === "/connections/sentry/auth" &&
904
+ req.method === "GET") {
905
+ void (async () => {
906
+ const { handleSentryAuthorize } = await import("./connectors/sentry.js");
907
+ const result = await handleSentryAuthorize();
908
+ if (result.redirect) {
909
+ res.writeHead(302, { Location: result.redirect });
910
+ res.end();
911
+ }
912
+ else {
913
+ res.writeHead(result.status, {
914
+ "Content-Type": result.contentType ?? "application/json",
915
+ });
916
+ res.end(result.body);
917
+ }
918
+ })();
919
+ return;
920
+ }
921
+ if (parsedUrl.pathname === "/connections/sentry/callback" &&
922
+ req.method === "GET") {
923
+ void (async () => {
924
+ const { handleSentryCallback } = await import("./connectors/sentry.js");
682
925
  const code = parsedUrl.searchParams.get("code");
683
926
  const state = parsedUrl.searchParams.get("state");
684
927
  const error = parsedUrl.searchParams.get("error");
685
- const result = await handleGmailCallback(code, state, error);
928
+ const result = await handleSentryCallback(code, state, error);
686
929
  res.writeHead(result.status, {
687
- "Content-Type": result.contentType ?? "text/html",
930
+ "Content-Type": result.contentType ?? "application/json",
688
931
  });
689
932
  res.end(result.body);
690
933
  })();
691
934
  return;
692
935
  }
693
- if (parsedUrl.pathname === "/connections/gmail" &&
936
+ if (parsedUrl.pathname === "/connections/sentry/test" &&
937
+ req.method === "POST") {
938
+ void (async () => {
939
+ const { handleSentryTest } = await import("./connectors/sentry.js");
940
+ const result = await handleSentryTest();
941
+ res.writeHead(result.status, {
942
+ "Content-Type": result.contentType ?? "application/json",
943
+ });
944
+ res.end(result.body);
945
+ })();
946
+ return;
947
+ }
948
+ if (parsedUrl.pathname === "/connections/sentry" &&
694
949
  req.method === "DELETE") {
695
950
  void (async () => {
696
- const { handleGmailDisconnect } = await import("./connectors/gmail.js");
697
- const result = await handleGmailDisconnect();
951
+ const { handleSentryDisconnect } = await import("./connectors/sentry.js");
952
+ const result = await handleSentryDisconnect();
698
953
  res.writeHead(result.status, {
699
954
  "Content-Type": result.contentType ?? "application/json",
700
955
  });
@@ -702,11 +957,45 @@ export class Server extends EventEmitter {
702
957
  })();
703
958
  return;
704
959
  }
705
- if (parsedUrl.pathname === "/connections/gmail/test" &&
960
+ // ── Linear MCP connector routes ─────────────────────────────────────
961
+ if (parsedUrl.pathname === "/connections/linear/auth" &&
962
+ req.method === "GET") {
963
+ void (async () => {
964
+ const { handleLinearAuthorize } = await import("./connectors/linear.js");
965
+ const result = await handleLinearAuthorize();
966
+ if (result.redirect) {
967
+ res.writeHead(302, { Location: result.redirect });
968
+ res.end();
969
+ }
970
+ else {
971
+ res.writeHead(result.status, {
972
+ "Content-Type": result.contentType ?? "application/json",
973
+ });
974
+ res.end(result.body);
975
+ }
976
+ })();
977
+ return;
978
+ }
979
+ if (parsedUrl.pathname === "/connections/linear/callback" &&
980
+ req.method === "GET") {
981
+ void (async () => {
982
+ const { handleLinearCallback } = await import("./connectors/linear.js");
983
+ const code = parsedUrl.searchParams.get("code");
984
+ const state = parsedUrl.searchParams.get("state");
985
+ const error = parsedUrl.searchParams.get("error");
986
+ const result = await handleLinearCallback(code, state, error);
987
+ res.writeHead(result.status, {
988
+ "Content-Type": result.contentType ?? "application/json",
989
+ });
990
+ res.end(result.body);
991
+ })();
992
+ return;
993
+ }
994
+ if (parsedUrl.pathname === "/connections/linear/test" &&
706
995
  req.method === "POST") {
707
996
  void (async () => {
708
- const { handleGmailTest } = await import("./connectors/gmail.js");
709
- const result = await handleGmailTest();
997
+ const { handleLinearTest } = await import("./connectors/linear.js");
998
+ const result = await handleLinearTest();
710
999
  res.writeHead(result.status, {
711
1000
  "Content-Type": result.contentType ?? "application/json",
712
1001
  });
@@ -714,28 +1003,233 @@ export class Server extends EventEmitter {
714
1003
  })();
715
1004
  return;
716
1005
  }
717
- if (parsedUrl.pathname === "/connections/github/test" &&
1006
+ if (parsedUrl.pathname === "/connections/linear" &&
1007
+ req.method === "DELETE") {
1008
+ void (async () => {
1009
+ const { handleLinearDisconnect } = await import("./connectors/linear.js");
1010
+ const result = await handleLinearDisconnect();
1011
+ res.writeHead(result.status, {
1012
+ "Content-Type": result.contentType ?? "application/json",
1013
+ });
1014
+ res.end(result.body);
1015
+ })();
1016
+ return;
1017
+ }
1018
+ // ── Slack connector routes ──────────────────────────────────────
1019
+ if ((parsedUrl.pathname === "/connections/slack/auth" ||
1020
+ parsedUrl.pathname === "/connections/slack/authorize") &&
1021
+ req.method === "GET") {
1022
+ const { handleSlackAuthorize } = await import("./connectors/slack.js");
1023
+ const result = handleSlackAuthorize();
1024
+ if (result.redirect) {
1025
+ res.writeHead(302, { Location: result.redirect });
1026
+ res.end();
1027
+ }
1028
+ else {
1029
+ res.writeHead(result.status, {
1030
+ "Content-Type": result.contentType ?? "application/json",
1031
+ });
1032
+ res.end(result.body);
1033
+ }
1034
+ return;
1035
+ }
1036
+ if (parsedUrl.pathname === "/connections/slack/test" &&
718
1037
  req.method === "POST") {
719
1038
  void (async () => {
720
- const { getStatus } = await import("./connectors/github.js");
721
- const s = getStatus();
722
- if (s.connected) {
723
- res.writeHead(200, { "Content-Type": "application/json" });
724
- res.end(JSON.stringify({
725
- ok: true,
726
- message: `Connected as ${s.user ?? "unknown"}`,
727
- }));
1039
+ const { handleSlackTest } = await import("./connectors/slack.js");
1040
+ const result = await handleSlackTest();
1041
+ res.writeHead(result.status, {
1042
+ "Content-Type": result.contentType ?? "application/json",
1043
+ });
1044
+ res.end(result.body);
1045
+ })();
1046
+ return;
1047
+ }
1048
+ if (parsedUrl.pathname === "/connections/slack" &&
1049
+ req.method === "DELETE") {
1050
+ const { handleSlackDisconnect } = await import("./connectors/slack.js");
1051
+ const result = handleSlackDisconnect();
1052
+ res.writeHead(result.status, {
1053
+ "Content-Type": result.contentType ?? "application/json",
1054
+ });
1055
+ res.end(result.body);
1056
+ return;
1057
+ }
1058
+ // ── Notion routes ──────────────────────────────────────────────
1059
+ if (parsedUrl.pathname === "/connections/notion/connect" &&
1060
+ req.method === "POST") {
1061
+ const chunks = [];
1062
+ req.on("data", (c) => chunks.push(c));
1063
+ req.on("end", () => {
1064
+ void (async () => {
1065
+ const { handleNotionConnect } = await import("./connectors/notion.js");
1066
+ const result = await handleNotionConnect(Buffer.concat(chunks).toString("utf-8"));
1067
+ res.writeHead(result.status, {
1068
+ "Content-Type": result.contentType ?? "application/json",
1069
+ });
1070
+ res.end(result.body);
1071
+ })();
1072
+ });
1073
+ return;
1074
+ }
1075
+ if (parsedUrl.pathname === "/connections/notion/test" &&
1076
+ req.method === "POST") {
1077
+ void (async () => {
1078
+ const { handleNotionTest } = await import("./connectors/notion.js");
1079
+ const result = await handleNotionTest();
1080
+ res.writeHead(result.status, {
1081
+ "Content-Type": result.contentType ?? "application/json",
1082
+ });
1083
+ res.end(result.body);
1084
+ })();
1085
+ return;
1086
+ }
1087
+ if (parsedUrl.pathname === "/connections/notion" &&
1088
+ req.method === "DELETE") {
1089
+ const { handleNotionDisconnect } = await import("./connectors/notion.js");
1090
+ const result = handleNotionDisconnect();
1091
+ res.writeHead(result.status, {
1092
+ "Content-Type": result.contentType ?? "application/json",
1093
+ });
1094
+ res.end(result.body);
1095
+ return;
1096
+ }
1097
+ // ── Confluence routes ───────────────────────────────────────────
1098
+ if (parsedUrl.pathname === "/connections/confluence/connect" &&
1099
+ req.method === "POST") {
1100
+ const chunks = [];
1101
+ req.on("data", (c) => chunks.push(c));
1102
+ req.on("end", () => {
1103
+ void (async () => {
1104
+ const { handleConfluenceConnect } = await import("./connectors/confluence.js");
1105
+ const result = await handleConfluenceConnect(Buffer.concat(chunks).toString("utf-8"));
1106
+ res.writeHead(result.status, {
1107
+ "Content-Type": result.contentType ?? "application/json",
1108
+ });
1109
+ res.end(result.body);
1110
+ })();
1111
+ });
1112
+ return;
1113
+ }
1114
+ if (parsedUrl.pathname === "/connections/confluence/test" &&
1115
+ req.method === "POST") {
1116
+ void (async () => {
1117
+ const { handleConfluenceTest } = await import("./connectors/confluence.js");
1118
+ const result = await handleConfluenceTest();
1119
+ res.writeHead(result.status, {
1120
+ "Content-Type": result.contentType ?? "application/json",
1121
+ });
1122
+ res.end(result.body);
1123
+ })();
1124
+ return;
1125
+ }
1126
+ if (parsedUrl.pathname === "/connections/confluence" &&
1127
+ req.method === "DELETE") {
1128
+ const { handleConfluenceDisconnect } = await import("./connectors/confluence.js");
1129
+ const result = handleConfluenceDisconnect();
1130
+ res.writeHead(result.status, {
1131
+ "Content-Type": result.contentType ?? "application/json",
1132
+ });
1133
+ res.end(result.body);
1134
+ return;
1135
+ }
1136
+ // ── Zendesk routes ──────────────────────────────────────────────
1137
+ if (parsedUrl.pathname === "/connections/zendesk/connect" &&
1138
+ req.method === "POST") {
1139
+ const chunks = [];
1140
+ req.on("data", (c) => chunks.push(c));
1141
+ req.on("end", () => {
1142
+ void (async () => {
1143
+ const { handleZendeskConnect } = await import("./connectors/zendesk.js");
1144
+ const result = await handleZendeskConnect(Buffer.concat(chunks).toString("utf-8"));
1145
+ res.writeHead(result.status, {
1146
+ "Content-Type": result.contentType ?? "application/json",
1147
+ });
1148
+ res.end(result.body);
1149
+ })();
1150
+ });
1151
+ return;
1152
+ }
1153
+ if (parsedUrl.pathname === "/connections/zendesk/test" &&
1154
+ req.method === "POST") {
1155
+ void (async () => {
1156
+ const { handleZendeskTest } = await import("./connectors/zendesk.js");
1157
+ const result = await handleZendeskTest();
1158
+ res.writeHead(result.status, {
1159
+ "Content-Type": result.contentType ?? "application/json",
1160
+ });
1161
+ res.end(result.body);
1162
+ })();
1163
+ return;
1164
+ }
1165
+ if (parsedUrl.pathname === "/connections/zendesk" &&
1166
+ req.method === "DELETE") {
1167
+ const { handleZendeskDisconnect } = await import("./connectors/zendesk.js");
1168
+ const result = handleZendeskDisconnect();
1169
+ res.writeHead(result.status, {
1170
+ "Content-Type": result.contentType ?? "application/json",
1171
+ });
1172
+ res.end(result.body);
1173
+ return;
1174
+ }
1175
+ // ── Google Calendar routes ──────────────────────────────────────
1176
+ if (parsedUrl.pathname === "/connections/google-calendar/auth" &&
1177
+ req.method === "GET") {
1178
+ void (async () => {
1179
+ const { handleCalendarAuthRedirect } = await import("./connectors/googleCalendar.js");
1180
+ const result = handleCalendarAuthRedirect();
1181
+ if (result.redirect) {
1182
+ res.writeHead(302, { Location: result.redirect });
1183
+ res.end();
728
1184
  }
729
1185
  else {
730
- res.writeHead(200, { "Content-Type": "application/json" });
731
- res.end(JSON.stringify({
732
- ok: false,
733
- message: "Not connected — run: gh auth login",
734
- }));
1186
+ res.writeHead(result.status, {
1187
+ "Content-Type": result.contentType ?? "application/json",
1188
+ });
1189
+ res.end(result.body);
735
1190
  }
736
1191
  })();
737
1192
  return;
738
1193
  }
1194
+ if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
1195
+ req.method === "GET") {
1196
+ void (async () => {
1197
+ const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
1198
+ const code = parsedUrl.searchParams.get("code");
1199
+ const state = parsedUrl.searchParams.get("state");
1200
+ const error = parsedUrl.searchParams.get("error");
1201
+ const result = await handleCalendarCallback(code, state, error);
1202
+ res.writeHead(result.status, {
1203
+ "Content-Type": result.contentType ?? "application/json",
1204
+ });
1205
+ res.end(result.body);
1206
+ })();
1207
+ return;
1208
+ }
1209
+ if (parsedUrl.pathname === "/connections/google-calendar/test" &&
1210
+ req.method === "POST") {
1211
+ void (async () => {
1212
+ const { handleCalendarTest } = await import("./connectors/googleCalendar.js");
1213
+ const result = await handleCalendarTest();
1214
+ res.writeHead(result.status, {
1215
+ "Content-Type": result.contentType ?? "application/json",
1216
+ });
1217
+ res.end(result.body);
1218
+ })();
1219
+ return;
1220
+ }
1221
+ if (parsedUrl.pathname === "/connections/google-calendar" &&
1222
+ req.method === "DELETE") {
1223
+ void (async () => {
1224
+ const { handleCalendarDisconnect } = await import("./connectors/googleCalendar.js");
1225
+ const result = await handleCalendarDisconnect();
1226
+ res.writeHead(result.status, {
1227
+ "Content-Type": result.contentType ?? "application/json",
1228
+ });
1229
+ res.end(result.body);
1230
+ })();
1231
+ return;
1232
+ }
739
1233
  // ── Inbox routes ────────────────────────────────────────────────────
740
1234
  if (parsedUrl.pathname === "/inbox" && req.method === "GET") {
741
1235
  void (async () => {
@@ -831,6 +1325,11 @@ export class Server extends EventEmitter {
831
1325
  const body = Buffer.concat(chunks).toString("utf-8");
832
1326
  const parsed = JSON.parse(body || "{}");
833
1327
  const name = parsed.name;
1328
+ const vars = parsed.vars &&
1329
+ typeof parsed.vars === "object" &&
1330
+ !Array.isArray(parsed.vars)
1331
+ ? parsed.vars
1332
+ : undefined;
834
1333
  if (typeof name !== "string" || !name) {
835
1334
  res.writeHead(400, { "Content-Type": "application/json" });
836
1335
  res.end(JSON.stringify({ ok: false, error: "name required" }));
@@ -844,7 +1343,7 @@ export class Server extends EventEmitter {
844
1343
  }));
845
1344
  return;
846
1345
  }
847
- const result = await this.runRecipeFn(name);
1346
+ const result = await this.runRecipeFn(name, vars);
848
1347
  res.writeHead(result.ok ? 200 : 400, {
849
1348
  "Content-Type": "application/json",
850
1349
  });
@@ -886,6 +1385,62 @@ export class Server extends EventEmitter {
886
1385
  }
887
1386
  return;
888
1387
  }
1388
+ // GET /runs/:seq — single run detail (includes stepResults if present)
1389
+ const runDetailMatch = req.method === "GET"
1390
+ ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname)
1391
+ : null;
1392
+ if (runDetailMatch?.[1]) {
1393
+ const seq = Number.parseInt(runDetailMatch[1], 10);
1394
+ try {
1395
+ const run = this.runDetailFn?.(seq) ?? null;
1396
+ if (!run) {
1397
+ res.writeHead(404, { "Content-Type": "application/json" });
1398
+ res.end(JSON.stringify({ error: "not_found" }));
1399
+ }
1400
+ else {
1401
+ res.writeHead(200, { "Content-Type": "application/json" });
1402
+ res.end(JSON.stringify({ run }));
1403
+ }
1404
+ }
1405
+ catch (err) {
1406
+ res.writeHead(500, { "Content-Type": "application/json" });
1407
+ res.end(JSON.stringify({
1408
+ error: err instanceof Error ? err.message : String(err),
1409
+ }));
1410
+ }
1411
+ return;
1412
+ }
1413
+ // GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
1414
+ const runPlanMatch = req.method === "GET"
1415
+ ? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
1416
+ : null;
1417
+ if (runPlanMatch?.[1]) {
1418
+ const seq = Number.parseInt(runPlanMatch[1], 10);
1419
+ try {
1420
+ const run = this.runDetailFn?.(seq) ?? null;
1421
+ if (!run) {
1422
+ res.writeHead(404, { "Content-Type": "application/json" });
1423
+ res.end(JSON.stringify({ error: "run_not_found" }));
1424
+ return;
1425
+ }
1426
+ if (!this.runPlanFn) {
1427
+ res.writeHead(503, { "Content-Type": "application/json" });
1428
+ res.end(JSON.stringify({ error: "plan_unavailable" }));
1429
+ return;
1430
+ }
1431
+ const recipeName = run["recipeName"];
1432
+ const plan = await this.runPlanFn(recipeName);
1433
+ res.writeHead(200, { "Content-Type": "application/json" });
1434
+ res.end(JSON.stringify({ plan }));
1435
+ }
1436
+ catch (err) {
1437
+ const msg = err instanceof Error ? err.message : String(err);
1438
+ const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
1439
+ res.writeHead(status, { "Content-Type": "application/json" });
1440
+ res.end(JSON.stringify({ error: msg }));
1441
+ }
1442
+ return;
1443
+ }
889
1444
  if (req.url === "/recipes" && req.method === "POST") {
890
1445
  const chunks = [];
891
1446
  req.on("data", (c) => chunks.push(c));
@@ -919,6 +1474,40 @@ export class Server extends EventEmitter {
919
1474
  });
920
1475
  return;
921
1476
  }
1477
+ const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
1478
+ if (recipePatchMatch && req.method === "PATCH") {
1479
+ const name = decodeURIComponent(recipePatchMatch[1]);
1480
+ const chunks = [];
1481
+ req.on("data", (c) => chunks.push(c));
1482
+ req.on("end", () => {
1483
+ try {
1484
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
1485
+ if (typeof body.enabled !== "boolean") {
1486
+ res.writeHead(400, { "Content-Type": "application/json" });
1487
+ res.end(JSON.stringify({
1488
+ ok: false,
1489
+ error: "enabled (boolean) required",
1490
+ }));
1491
+ return;
1492
+ }
1493
+ if (!this.setRecipeEnabledFn) {
1494
+ res.writeHead(503, { "Content-Type": "application/json" });
1495
+ res.end(JSON.stringify({ ok: false, error: "Not available" }));
1496
+ return;
1497
+ }
1498
+ const result = this.setRecipeEnabledFn(name, body.enabled);
1499
+ res.writeHead(result.ok ? 200 : 400, {
1500
+ "Content-Type": "application/json",
1501
+ });
1502
+ res.end(JSON.stringify(result));
1503
+ }
1504
+ catch {
1505
+ res.writeHead(400, { "Content-Type": "application/json" });
1506
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
1507
+ }
1508
+ });
1509
+ return;
1510
+ }
922
1511
  if (req.url === "/recipes" && req.method === "GET") {
923
1512
  try {
924
1513
  const data = this.recipesFn?.() ?? { recipesDir: null, recipes: [] };
@@ -979,8 +1568,11 @@ export class Server extends EventEmitter {
979
1568
  req.on("end", () => {
980
1569
  try {
981
1570
  const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
982
- const raw = body.webhookUrl?.trim() ?? "";
983
- if (raw !== "" && !/^https:\/\/.+/.test(raw)) {
1571
+ const hasWebhookUpdate = body.webhookUrl !== undefined;
1572
+ const raw = hasWebhookUpdate
1573
+ ? (body.webhookUrl?.trim() ?? "")
1574
+ : undefined;
1575
+ if (raw !== undefined && raw !== "" && !/^https:\/\/.+/.test(raw)) {
984
1576
  res.writeHead(400, { "Content-Type": "application/json" });
985
1577
  res.end(JSON.stringify({ error: "webhookUrl must be HTTPS" }));
986
1578
  return;
@@ -1002,16 +1594,69 @@ export class Server extends EventEmitter {
1002
1594
  port: cfg.dashboard?.port ?? 3000,
1003
1595
  requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1004
1596
  pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1005
- webhookUrl: raw || undefined,
1597
+ webhookUrl: hasWebhookUpdate
1598
+ ? raw || undefined
1599
+ : cfg.dashboard?.webhookUrl,
1006
1600
  };
1007
1601
  if (gateRaw !== undefined) {
1008
1602
  cfg.approvalGate = gateRaw;
1009
1603
  this.approvalGate = gateRaw;
1010
1604
  }
1605
+ const driverRaw = body.driver;
1606
+ if (driverRaw !== undefined) {
1607
+ const validDrivers = [
1608
+ "subprocess",
1609
+ "api",
1610
+ "openai",
1611
+ "grok",
1612
+ "gemini",
1613
+ "none",
1614
+ ];
1615
+ if (!validDrivers.includes(driverRaw)) {
1616
+ res.writeHead(400, { "Content-Type": "application/json" });
1617
+ res.end(JSON.stringify({
1618
+ error: `driver must be one of: ${validDrivers.join(", ")}`,
1619
+ }));
1620
+ return;
1621
+ }
1622
+ const driver = driverRaw;
1623
+ cfg.driver = driver;
1624
+ saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1625
+ }
1626
+ if (body.apiKey) {
1627
+ const { provider, key } = body.apiKey;
1628
+ const validProviders = ["anthropic", "openai", "google", "xai"];
1629
+ if (!validProviders.includes(provider) ||
1630
+ typeof key !== "string") {
1631
+ res.writeHead(400, { "Content-Type": "application/json" });
1632
+ res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1633
+ return;
1634
+ }
1635
+ cfg.apiKeys = { ...cfg.apiKeys, [provider]: key || undefined };
1636
+ }
1011
1637
  savePatchworkConfig(cfg, configPath);
1012
- this.approvalWebhookUrl = raw || undefined;
1638
+ if (hasWebhookUpdate) {
1639
+ this.approvalWebhookUrl = raw || undefined;
1640
+ }
1641
+ if (body.pushServiceUrl !== undefined) {
1642
+ const pushUrl = body.pushServiceUrl.trim();
1643
+ if (pushUrl && !pushUrl.startsWith("https://")) {
1644
+ res.writeHead(400, { "Content-Type": "application/json" });
1645
+ res.end(JSON.stringify({ error: "pushServiceUrl must be HTTPS" }));
1646
+ return;
1647
+ }
1648
+ this.pushServiceUrl = pushUrl || undefined;
1649
+ }
1650
+ if (body.pushServiceToken !== undefined) {
1651
+ this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1652
+ }
1653
+ if (body.pushServiceBaseUrl !== undefined) {
1654
+ this.pushServiceBaseUrl =
1655
+ body.pushServiceBaseUrl.trim() || undefined;
1656
+ }
1657
+ const restartRequired = driverRaw !== undefined || body.apiKey !== undefined;
1013
1658
  res.writeHead(200, { "Content-Type": "application/json" });
1014
- res.end(JSON.stringify({ ok: true }));
1659
+ res.end(JSON.stringify({ ok: true, restartRequired }));
1015
1660
  }
1016
1661
  catch (err) {
1017
1662
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -1100,6 +1745,7 @@ export class Server extends EventEmitter {
1100
1745
  path: parsedUrl.pathname,
1101
1746
  body: parsedBody,
1102
1747
  query: parsedUrl.searchParams,
1748
+ approvalToken: req.headers["x-approval-token"],
1103
1749
  }, {
1104
1750
  queue: getApprovalQueue(),
1105
1751
  workspace: process.cwd(),
@@ -1107,6 +1753,9 @@ export class Server extends EventEmitter {
1107
1753
  onDecision: this.onApprovalDecision,
1108
1754
  webhookUrl: this.approvalWebhookUrl,
1109
1755
  approvalGate: this.approvalGate,
1756
+ pushServiceUrl: this.pushServiceUrl,
1757
+ pushServiceToken: this.pushServiceToken,
1758
+ pushServiceBaseUrl: this.pushServiceBaseUrl,
1110
1759
  });
1111
1760
  res.writeHead(result.status, {
1112
1761
  "Content-Type": "application/json",
@@ -1305,6 +1954,8 @@ export class Server extends EventEmitter {
1305
1954
  ws.on("error", (err) => {
1306
1955
  this.logger.error(`WebSocket client error: ${err.message}`);
1307
1956
  });
1957
+ ws.remoteAddr =
1958
+ req.socket.remoteAddress;
1308
1959
  this.emit("connection", ws);
1309
1960
  });
1310
1961
  }