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.
- package/README.md +4 -1
- package/deploy/deploy-dashboard.sh +25 -1
- package/deploy/macos/README.md +153 -0
- package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
- package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
- package/deploy/macos/install-mac-bridge.sh +244 -0
- package/deploy/macos/uninstall-mac-bridge.sh +22 -0
- package/dist/analyticsAggregator.d.ts +5 -1
- package/dist/analyticsAggregator.js +15 -4
- package/dist/analyticsAggregator.js.map +1 -1
- package/dist/analyticsPrefs.d.ts +11 -0
- package/dist/analyticsPrefs.js +33 -0
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/approvalHttp.d.ts +14 -0
- package/dist/approvalHttp.js +172 -1
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +27 -2
- package/dist/approvalQueue.js +44 -7
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.d.ts +34 -3
- package/dist/automation.js +85 -10
- package/dist/automation.js.map +1 -1
- package/dist/bridge.js +39 -27
- package/dist/bridge.js.map +1 -1
- package/dist/claudeDriver.d.ts +0 -16
- package/dist/claudeDriver.js +19 -20
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeMdPatch.d.ts +9 -3
- package/dist/claudeMdPatch.js +79 -13
- package/dist/claudeMdPatch.js.map +1 -1
- package/dist/claudeOrchestrator.d.ts +12 -0
- package/dist/claudeOrchestrator.js +7 -2
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/marketplace.d.ts +15 -10
- package/dist/commands/marketplace.js +27 -115
- package/dist/commands/marketplace.js.map +1 -1
- package/dist/commands/recipe.js +10 -1
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commitIssueLinkLog.d.ts +8 -0
- package/dist/commitIssueLinkLog.js +53 -1
- package/dist/commitIssueLinkLog.js.map +1 -1
- package/dist/config.d.ts +11 -3
- package/dist/config.js +32 -2
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +63 -372
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/baseConnector.js +25 -3
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/jira.js +18 -1
- package/dist/connectors/jira.js.map +1 -1
- package/dist/drivers/claude/subprocess.d.ts +12 -2
- package/dist/drivers/claude/subprocess.js +79 -6
- package/dist/drivers/claude/subprocess.js.map +1 -1
- package/dist/drivers/gemini/api.d.ts +18 -0
- package/dist/drivers/gemini/api.js +29 -0
- package/dist/drivers/gemini/api.js.map +1 -0
- package/dist/drivers/gemini/index.d.ts +22 -0
- package/dist/drivers/gemini/index.js +240 -129
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/drivers/index.d.ts +3 -1
- package/dist/drivers/index.js +9 -1
- package/dist/drivers/index.js.map +1 -1
- package/dist/drivers/local/index.d.ts +43 -0
- package/dist/drivers/local/index.js +140 -0
- package/dist/drivers/local/index.js.map +1 -0
- package/dist/drivers/openai/index.js +30 -2
- package/dist/drivers/openai/index.js.map +1 -1
- package/dist/extensionClient.d.ts +8 -0
- package/dist/extensionClient.js +24 -2
- package/dist/extensionClient.js.map +1 -1
- package/dist/fp/automationInterpreter.d.ts +9 -1
- package/dist/fp/automationInterpreter.js +151 -34
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationProgram.d.ts +30 -0
- package/dist/fp/automationProgram.js.map +1 -1
- package/dist/fp/automationState.d.ts +23 -4
- package/dist/fp/automationState.js +28 -4
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/interpreterContext.d.ts +66 -1
- package/dist/fp/interpreterContext.js +140 -1
- package/dist/fp/interpreterContext.js.map +1 -1
- package/dist/fp/policyParser.js +29 -1
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/httpErrorResponse.d.ts +36 -0
- package/dist/httpErrorResponse.js +46 -0
- package/dist/httpErrorResponse.js.map +1 -0
- package/dist/inboxRoutes.js +90 -11
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +22 -0
- package/dist/oauth.js +46 -0
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.js +3 -8
- package/dist/oauthRoutes.js.map +1 -1
- package/dist/patchworkConfig.d.ts +30 -1
- package/dist/patchworkConfig.js +99 -4
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/preToolUseHook.js +7 -1
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/prompts.js +4 -0
- package/dist/prompts.js.map +1 -1
- package/dist/recipeOrchestration.js +13 -3
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +5 -0
- package/dist/recipeRoutes.js +57 -33
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +10 -1
- package/dist/recipes/agentExecutor.js +5 -4
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/scheduler.d.ts +7 -0
- package/dist/recipes/scheduler.js +30 -13
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +6 -0
- package/dist/recipes/tools/file.js +5 -2
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/gmail.js +18 -1
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +32 -2
- package/dist/recipes/yamlRunner.js +71 -6
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +17 -1
- package/dist/recipesHttp.js +68 -4
- package/dist/recipesHttp.js.map +1 -1
- package/dist/server.d.ts +52 -1
- package/dist/server.js +427 -248
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.d.ts +9 -4
- package/dist/streamableHttp.js +17 -9
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/openInBrowser.js +6 -1
- package/dist/tools/openInBrowser.js.map +1 -1
- package/dist/tools/runCommand.js +5 -0
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/terminal.js +4 -0
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/utils.d.ts +4 -0
- package/dist/tools/utils.js +62 -0
- package/dist/tools/utils.js.map +1 -1
- package/package.json +2 -2
- package/scripts/start-all.sh +4 -2
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
|
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
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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)) {
|