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