patchwork-os 0.2.0-beta.2 → 0.2.0-beta.4
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 +244 -30
- package/dist/activityLog.d.ts +6 -0
- package/dist/activityLog.js +10 -1
- 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.js +25 -8
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +44 -1
- package/dist/approvalQueue.js +117 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.d.ts +3 -3
- package/dist/automation.js +12 -5
- package/dist/automation.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +140 -8
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +27 -1
- package/dist/bridgeLockDiscovery.js +38 -11
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/claudeOrchestrator.js +27 -10
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/dashboard.js +8 -1
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/install.js +3 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +5 -0
- package/dist/commands/patchworkInit.js +89 -7
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +51 -0
- package/dist/commands/recipe.js +353 -2
- 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/commitIssueLinkLog.d.ts +16 -0
- package/dist/commitIssueLinkLog.js +87 -4
- package/dist/commitIssueLinkLog.js.map +1 -1
- package/dist/config.d.ts +29 -3
- package/dist/config.js +77 -21
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +1 -1
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/asana.js +4 -3
- package/dist/connectors/asana.js.map +1 -1
- package/dist/connectors/confluence.js +35 -0
- package/dist/connectors/confluence.js.map +1 -1
- package/dist/connectors/datadog.js +33 -4
- package/dist/connectors/datadog.js.map +1 -1
- package/dist/connectors/discord.js +5 -4
- package/dist/connectors/discord.js.map +1 -1
- package/dist/connectors/gitlab.js +7 -1
- package/dist/connectors/gitlab.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +71 -6
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/slack.d.ts +1 -1
- package/dist/connectors/slack.js +56 -4
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/tokenStorage.js +56 -14
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/decisionTraceLog.d.ts +28 -0
- package/dist/decisionTraceLog.js +115 -7
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/drivers/claude/subprocess.js +22 -3
- package/dist/drivers/claude/subprocess.js.map +1 -1
- package/dist/drivers/gemini/index.js +19 -3
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/extensionClient.d.ts +29 -4
- package/dist/extensionClient.js +26 -11
- package/dist/extensionClient.js.map +1 -1
- package/dist/featureFlags.d.ts +76 -0
- package/dist/featureFlags.js +153 -3
- package/dist/featureFlags.js.map +1 -1
- package/dist/fileLockSync.d.ts +67 -0
- package/dist/fileLockSync.js +126 -0
- package/dist/fileLockSync.js.map +1 -0
- package/dist/fp/automationInterpreter.d.ts +6 -0
- package/dist/fp/automationInterpreter.js +15 -2
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationState.d.ts +1 -1
- package/dist/fp/automationState.js +10 -0
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/commandDescription.js +7 -1
- package/dist/fp/commandDescription.js.map +1 -1
- package/dist/fsWatchWithFallback.d.ts +36 -0
- package/dist/fsWatchWithFallback.js +127 -0
- package/dist/fsWatchWithFallback.js.map +1 -0
- package/dist/index.js +797 -75
- package/dist/index.js.map +1 -1
- package/dist/installGuard.js +6 -2
- package/dist/installGuard.js.map +1 -1
- package/dist/lockfile.js +31 -4
- package/dist/lockfile.js.map +1 -1
- package/dist/patchworkConfig.js +13 -3
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.js +10 -1
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +6 -13
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.js +3 -2
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/processTree.d.ts +34 -0
- package/dist/processTree.js +105 -0
- package/dist/processTree.js.map +1 -0
- package/dist/prompts.js +3 -3
- package/dist/prompts.js.map +1 -1
- package/dist/recipeOrchestration.js +35 -1
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +37 -0
- package/dist/recipeRoutes.js +236 -33
- 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 +143 -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 +297 -0
- package/dist/recipes/idempotencyKey.js.map +1 -0
- package/dist/recipes/installer.js +48 -2
- package/dist/recipes/installer.js.map +1 -1
- 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/parser.js +82 -4
- package/dist/recipes/parser.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 +17 -0
- package/dist/recipes/scheduler.js +34 -2
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +30 -0
- package/dist/recipes/toolRegistry.js +19 -0
- package/dist/recipes/toolRegistry.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 +75 -8
- package/dist/recipes/yamlRunner.js +174 -28
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/resources.js +21 -13
- package/dist/resources.js.map +1 -1
- package/dist/runLog.d.ts +28 -0
- package/dist/runLog.js +19 -3
- package/dist/runLog.js.map +1 -1
- package/dist/sanitizeParsedJson.d.ts +39 -0
- package/dist/sanitizeParsedJson.js +55 -0
- package/dist/sanitizeParsedJson.js.map +1 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +356 -3
- package/dist/server.js.map +1 -1
- package/dist/sessionCheckpoint.d.ts +8 -0
- package/dist/sessionCheckpoint.js +18 -2
- package/dist/sessionCheckpoint.js.map +1 -1
- package/dist/streamableHttp.js +17 -6
- 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/detectUnusedCode.js +9 -7
- package/dist/tools/detectUnusedCode.js.map +1 -1
- package/dist/tools/editText.js +2 -1
- package/dist/tools/editText.js.map +1 -1
- package/dist/tools/fileOperations.js +2 -1
- package/dist/tools/fileOperations.js.map +1 -1
- package/dist/tools/fileWatcher.js +8 -2
- package/dist/tools/fileWatcher.js.map +1 -1
- package/dist/tools/fixAllLintErrors.js +10 -5
- package/dist/tools/fixAllLintErrors.js.map +1 -1
- package/dist/tools/formatDocument.js +10 -5
- package/dist/tools/formatDocument.js.map +1 -1
- package/dist/tools/getCodeCoverage.js +7 -3
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/handoffNote.js +2 -1
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/headless/lspClient.js +3 -0
- package/dist/tools/headless/lspClient.js.map +1 -1
- package/dist/tools/lsp.js +17 -0
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/openDiff.js +4 -1
- package/dist/tools/openDiff.js.map +1 -1
- package/dist/tools/openFile.js +4 -1
- package/dist/tools/openFile.js.map +1 -1
- package/dist/tools/organizeImports.js +5 -3
- package/dist/tools/organizeImports.js.map +1 -1
- package/dist/tools/previewEdit.js +7 -2
- package/dist/tools/previewEdit.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +56 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/refactorExtractFunction.js +4 -1
- package/dist/tools/refactorExtractFunction.js.map +1 -1
- package/dist/tools/refactorPreview.js +10 -2
- package/dist/tools/refactorPreview.js.map +1 -1
- package/dist/tools/replaceBlock.js +2 -1
- package/dist/tools/replaceBlock.js.map +1 -1
- package/dist/tools/searchAndReplace.js +2 -1
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/spawnWorkspace.js +15 -7
- package/dist/tools/spawnWorkspace.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/transaction.js +4 -1
- package/dist/tools/transaction.js.map +1 -1
- package/dist/tools/utils.js +68 -8
- package/dist/tools/utils.js.map +1 -1
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +18 -4
- package/dist/transport.js.map +1 -1
- package/dist/winShim.d.ts +34 -0
- package/dist/winShim.js +94 -0
- package/dist/winShim.js.map +1 -0
- package/dist/writeFileAtomic.d.ts +23 -0
- package/dist/writeFileAtomic.js +94 -0
- package/dist/writeFileAtomic.js.map +1 -0
- package/package.json +17 -6
- package/scripts/postinstall.mjs +42 -2
- package/scripts/smoke/run-all.mjs +213 -0
- package/scripts/start-all.mjs +572 -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";
|
|
@@ -103,6 +106,10 @@ export class Server extends EventEmitter {
|
|
|
103
106
|
runsFn = null;
|
|
104
107
|
/** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
|
|
105
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;
|
|
106
113
|
/** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
|
|
107
114
|
runPlanFn = null;
|
|
108
115
|
/** Patchwork (VD-4): mocked replay of an existing run. Returns the new
|
|
@@ -110,10 +117,25 @@ export class Server extends EventEmitter {
|
|
|
110
117
|
runReplayFn = null;
|
|
111
118
|
/** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
|
|
112
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;
|
|
113
128
|
/** Patchwork: admin-controlled managed settings path (highest rule precedence). */
|
|
114
129
|
managedSettingsPath = undefined;
|
|
115
130
|
/** Effective bridge config path to update when dashboard saves driver changes. */
|
|
116
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;
|
|
117
139
|
/** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
|
|
118
140
|
approvalGate = "off";
|
|
119
141
|
/** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
|
|
@@ -150,6 +172,33 @@ export class Server extends EventEmitter {
|
|
|
150
172
|
* the user's preference.
|
|
151
173
|
*/
|
|
152
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;
|
|
153
202
|
/** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
|
|
154
203
|
webhookFn = null;
|
|
155
204
|
/**
|
|
@@ -215,6 +264,22 @@ export class Server extends EventEmitter {
|
|
|
215
264
|
/** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
|
|
216
265
|
launchQuickTaskFn = null;
|
|
217
266
|
setRecipeEnabledFn = null;
|
|
267
|
+
/** Set by bridge to check if restart is safe (no in-flight tool calls). */
|
|
268
|
+
restartCheckFn = null;
|
|
269
|
+
/**
|
|
270
|
+
* Called when /restart decides it is safe to shut down. Defaults to
|
|
271
|
+
* `process.kill(process.pid, 'SIGTERM')`. Override in tests to a no-op so
|
|
272
|
+
* the Vitest runner process is not actually killed.
|
|
273
|
+
*/
|
|
274
|
+
restartKillFn = () => process.kill(process.pid, "SIGTERM");
|
|
275
|
+
/**
|
|
276
|
+
* Called when /shutdown decides it is safe to exit. Defaults to
|
|
277
|
+
* `process.kill(process.pid, 'SIGTERM')`. Bridge overrides this to run its
|
|
278
|
+
* internal shutdown sequence directly — necessary on Windows where
|
|
279
|
+
* `process.kill(pid, 'SIGTERM')` is TerminateProcess and cleanup handlers
|
|
280
|
+
* never fire.
|
|
281
|
+
*/
|
|
282
|
+
shutdownFn = () => process.kill(process.pid, "SIGTERM");
|
|
218
283
|
/**
|
|
219
284
|
* Attach an OAuth 2.0 Authorization Server.
|
|
220
285
|
* When set, the bridge exposes:
|
|
@@ -418,6 +483,14 @@ export class Server extends EventEmitter {
|
|
|
418
483
|
const isPhoneApprovalPath = req.method === "POST" &&
|
|
419
484
|
/^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
|
|
420
485
|
!!req.headers["x-approval-token"];
|
|
486
|
+
// GitHub-style webhook bypass: when --webhook-secret is configured,
|
|
487
|
+
// POST /hooks/* requests carrying X-Hub-Signature-256 bypass the
|
|
488
|
+
// bearer-token gate. Signature itself is verified inside the
|
|
489
|
+
// /hooks/* handler after the body has been read.
|
|
490
|
+
const isHmacWebhookCandidate = req.method === "POST" &&
|
|
491
|
+
parsedUrl.pathname.startsWith("/hooks/") &&
|
|
492
|
+
!!req.headers["x-hub-signature-256"] &&
|
|
493
|
+
this.webhookSecret !== null;
|
|
421
494
|
// Rate-limit the phone bypass surface. Only applies when this is
|
|
422
495
|
// actually a phone-path request that's relying on the bypass — a
|
|
423
496
|
// properly-authenticated bearer caller is unaffected. Counted +
|
|
@@ -464,7 +537,10 @@ export class Server extends EventEmitter {
|
|
|
464
537
|
}
|
|
465
538
|
}
|
|
466
539
|
// oauthResolved is the bridge token if the OAuth token is valid; null otherwise
|
|
467
|
-
if (!isStaticToken &&
|
|
540
|
+
if (!isStaticToken &&
|
|
541
|
+
!oauthResolved &&
|
|
542
|
+
!isPhoneApprovalPath &&
|
|
543
|
+
!isHmacWebhookCandidate) {
|
|
468
544
|
// RFC 6750: only include error= when a token was actually presented but invalid
|
|
469
545
|
const tokenPresented = bearer.length > 0;
|
|
470
546
|
const wwwAuth = this.oauthServer && this.oauthIssuerUrl
|
|
@@ -776,6 +852,33 @@ export class Server extends EventEmitter {
|
|
|
776
852
|
respond413(res, HOOKS_BODY_CAP);
|
|
777
853
|
return;
|
|
778
854
|
}
|
|
855
|
+
// HMAC-SHA256 verification for GitHub-style webhooks. Signature is
|
|
856
|
+
// computed over the raw on-the-wire bytes (read.bytes), not the
|
|
857
|
+
// utf-8-decoded string — non-UTF-8 or denormalized payloads must
|
|
858
|
+
// round-trip identically to validate.
|
|
859
|
+
const sigHeader = req.headers["x-hub-signature-256"];
|
|
860
|
+
if (typeof sigHeader === "string" && sigHeader.length > 0) {
|
|
861
|
+
if (!this.webhookSecret) {
|
|
862
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
863
|
+
res.end(JSON.stringify({ error: "webhook_secret_not_configured" }));
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const expected = "sha256=" +
|
|
867
|
+
createHmac("sha256", this.webhookSecret)
|
|
868
|
+
.update(read.bytes)
|
|
869
|
+
.digest("hex");
|
|
870
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
871
|
+
const providedBuf = Buffer.from(sigHeader, "utf-8");
|
|
872
|
+
// timingSafeEqual throws on length mismatch — length-check first
|
|
873
|
+
// so the constant-time path is only taken on equal-length inputs.
|
|
874
|
+
const sigOk = expectedBuf.length === providedBuf.length &&
|
|
875
|
+
timingSafeEqual(expectedBuf, providedBuf);
|
|
876
|
+
if (!sigOk) {
|
|
877
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
878
|
+
res.end(JSON.stringify({ error: "invalid_signature" }));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
779
882
|
let payload;
|
|
780
883
|
if (read.body.trim()) {
|
|
781
884
|
try {
|
|
@@ -789,7 +892,7 @@ export class Server extends EventEmitter {
|
|
|
789
892
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
790
893
|
res.end(JSON.stringify({
|
|
791
894
|
ok: false,
|
|
792
|
-
error: "Webhooks unavailable — start bridge with --
|
|
895
|
+
error: "Webhooks unavailable — start bridge with --driver subprocess",
|
|
793
896
|
}));
|
|
794
897
|
return;
|
|
795
898
|
}
|
|
@@ -992,9 +1095,12 @@ export class Server extends EventEmitter {
|
|
|
992
1095
|
setRecipeEnabledFn: this.setRecipeEnabledFn,
|
|
993
1096
|
runsFn: this.runsFn,
|
|
994
1097
|
runDetailFn: this.runDetailFn,
|
|
1098
|
+
haltSummaryFn: this.haltSummaryFn,
|
|
1099
|
+
judgeSummaryFn: this.judgeSummaryFn,
|
|
995
1100
|
runPlanFn: this.runPlanFn,
|
|
996
1101
|
runReplayFn: this.runReplayFn,
|
|
997
1102
|
runRecipeFn: this.runRecipeFn,
|
|
1103
|
+
onRecipesChangedFn: this.onRecipesChangedFn,
|
|
998
1104
|
})) {
|
|
999
1105
|
return;
|
|
1000
1106
|
}
|
|
@@ -1261,6 +1367,253 @@ export class Server extends EventEmitter {
|
|
|
1261
1367
|
}
|
|
1262
1368
|
return;
|
|
1263
1369
|
}
|
|
1370
|
+
// /kill-switch — dedicated endpoint for the global write-tier kill switch.
|
|
1371
|
+
// See issue #422 v2: not folded into /settings because kill-switch has
|
|
1372
|
+
// audit + idempotency + env-lock semantics nothing else on /settings has.
|
|
1373
|
+
//
|
|
1374
|
+
// POST {engage: boolean, reason?: string} → toggle; idempotent.
|
|
1375
|
+
// 200 {engaged, changed, locked: false} — accepted
|
|
1376
|
+
// 200 {engaged, changed: false, locked: false} — no-op (already in that state)
|
|
1377
|
+
// 409 {error: "env_locked", flag, frozenValue, lockedReason}
|
|
1378
|
+
// — env-locked, cannot toggle
|
|
1379
|
+
// 400 {error: "invalid_request"} — malformed body
|
|
1380
|
+
//
|
|
1381
|
+
// GET → status. 200 {engaged, locked, lockedReason?, lockedValue?}
|
|
1382
|
+
//
|
|
1383
|
+
// Audit emit: state transitions log to this.logger.info (always)
|
|
1384
|
+
// AND record via this.recordKillSwitchTraceFn (when wired by the
|
|
1385
|
+
// bridge — see src/bridge.ts where it threads decisionTraceLog
|
|
1386
|
+
// through to ~/.patchwork/decision_traces.jsonl). Tests / headless
|
|
1387
|
+
// contexts that don't wire the callback still get the log line.
|
|
1388
|
+
if (parsedUrl.pathname === "/kill-switch") {
|
|
1389
|
+
if (req.method === "GET") {
|
|
1390
|
+
const engaged = isWriteKillSwitchActive();
|
|
1391
|
+
const locked = isEnvLockedFor(KILL_SWITCH_WRITES);
|
|
1392
|
+
const body = { engaged, locked };
|
|
1393
|
+
if (locked) {
|
|
1394
|
+
const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
|
|
1395
|
+
body.lockedValue = lockedValue;
|
|
1396
|
+
body.lockedReason = `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`;
|
|
1397
|
+
}
|
|
1398
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1399
|
+
res.end(JSON.stringify(body));
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (req.method === "POST") {
|
|
1403
|
+
// 1 KB — body is `{engage: bool, reason?: string}`; reason is a
|
|
1404
|
+
// short audit note, 1 KB is generous.
|
|
1405
|
+
const KS_BODY_CAP = 1 * 1024;
|
|
1406
|
+
const parsed = await readJsonBody(req, KS_BODY_CAP);
|
|
1407
|
+
if (!parsed.ok) {
|
|
1408
|
+
if (parsed.code === "too_large") {
|
|
1409
|
+
respond413(res, KS_BODY_CAP);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1413
|
+
res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const body = parsed.value ?? {};
|
|
1417
|
+
if (typeof body.engage !== "boolean") {
|
|
1418
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1419
|
+
res.end(JSON.stringify({
|
|
1420
|
+
error: "invalid_request",
|
|
1421
|
+
reason: "engage must be boolean",
|
|
1422
|
+
}));
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const reason = typeof body.reason === "string" && body.reason.trim().length > 0
|
|
1426
|
+
? body.reason.trim().slice(0, 500)
|
|
1427
|
+
: undefined;
|
|
1428
|
+
// v2-B2 + I3: surface env-lock conflict as structured 409 so CLI
|
|
1429
|
+
// + dashboard can distinguish "you sent garbage" from
|
|
1430
|
+
// "policy-locked by sysadmin via PATCHWORK_FLAG_*".
|
|
1431
|
+
if (isEnvLockedFor(KILL_SWITCH_WRITES)) {
|
|
1432
|
+
const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
|
|
1433
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1434
|
+
res.end(JSON.stringify({
|
|
1435
|
+
error: "env_locked",
|
|
1436
|
+
flag: KILL_SWITCH_WRITES,
|
|
1437
|
+
frozenValue: lockedValue,
|
|
1438
|
+
lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`,
|
|
1439
|
+
}));
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
// v2-I12: idempotent. State transitions emit audit; no-ops don't.
|
|
1443
|
+
const prev = isWriteKillSwitchActive();
|
|
1444
|
+
const next = body.engage;
|
|
1445
|
+
const changed = prev !== next;
|
|
1446
|
+
if (changed) {
|
|
1447
|
+
try {
|
|
1448
|
+
setFlag(KILL_SWITCH_WRITES, next, true);
|
|
1449
|
+
}
|
|
1450
|
+
catch (err) {
|
|
1451
|
+
// Belt-and-suspenders: setFlag now throws EnvLockedFlagError if
|
|
1452
|
+
// the flag was env-locked (we already checked isEnvLockedFor above,
|
|
1453
|
+
// but a race with lockKillSwitchEnv() in tests warrants this).
|
|
1454
|
+
if (err instanceof EnvLockedFlagError) {
|
|
1455
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1456
|
+
res.end(JSON.stringify({
|
|
1457
|
+
error: "env_locked",
|
|
1458
|
+
flag: KILL_SWITCH_WRITES,
|
|
1459
|
+
frozenValue: err.frozenValue,
|
|
1460
|
+
lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${err.frozenValue ? "1" : "0"} at bridge startup`,
|
|
1461
|
+
}));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
throw err;
|
|
1465
|
+
}
|
|
1466
|
+
// v2-I6: audit emit on every state transition; no-ops skip.
|
|
1467
|
+
// The bridge wires recordKillSwitchTraceFn to write a
|
|
1468
|
+
// DecisionTrace entry to ~/.patchwork/decision_traces.jsonl
|
|
1469
|
+
// (see src/bridge.ts). The logger.info line stays as a
|
|
1470
|
+
// secondary signal in the bridge log and is the only output
|
|
1471
|
+
// when the trace fn is unset (tests, headless contexts).
|
|
1472
|
+
this.logger.info(`[kill-switch] ${next ? "ENGAGED" : "RELEASED"}${reason ? ` (reason: ${reason})` : ""} — actor=http`);
|
|
1473
|
+
this.recordKillSwitchTraceFn?.({
|
|
1474
|
+
engaged: next,
|
|
1475
|
+
reason,
|
|
1476
|
+
ts: Date.now(),
|
|
1477
|
+
});
|
|
1478
|
+
// v2-I8: broadcast SSE kind:"kill-switch" so dashboard updates
|
|
1479
|
+
// in <1s without changing the poll cadence.
|
|
1480
|
+
this.broadcastKillSwitchEventFn?.(next, reason);
|
|
1481
|
+
}
|
|
1482
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1483
|
+
res.end(JSON.stringify({
|
|
1484
|
+
engaged: next,
|
|
1485
|
+
changed,
|
|
1486
|
+
locked: false,
|
|
1487
|
+
}));
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
// /telemetry-prefs — read/write per-flag telemetry preferences.
|
|
1492
|
+
// GET → {crashReports, usageStats, localDiagnostics}
|
|
1493
|
+
// POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
|
|
1494
|
+
if (parsedUrl.pathname === "/telemetry-prefs") {
|
|
1495
|
+
if (req.method === "GET") {
|
|
1496
|
+
const prefs = getTelemetryPrefs();
|
|
1497
|
+
const all = getAnalyticsPrefsAll();
|
|
1498
|
+
const response = { ...prefs };
|
|
1499
|
+
if (all?.lastSentAt !== undefined) {
|
|
1500
|
+
response.lastSentAt = all.lastSentAt;
|
|
1501
|
+
}
|
|
1502
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1503
|
+
res.end(JSON.stringify(response));
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (req.method === "POST") {
|
|
1507
|
+
const TP_BODY_CAP = 1 * 1024;
|
|
1508
|
+
const parsed = await readJsonBody(req, TP_BODY_CAP);
|
|
1509
|
+
if (!parsed.ok) {
|
|
1510
|
+
if (parsed.code === "too_large") {
|
|
1511
|
+
respond413(res, TP_BODY_CAP);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1515
|
+
res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
const body = parsed.value ?? {};
|
|
1519
|
+
const update = {};
|
|
1520
|
+
if (typeof body.crashReports === "boolean") {
|
|
1521
|
+
update.crashReports = body.crashReports;
|
|
1522
|
+
}
|
|
1523
|
+
if (typeof body.usageStats === "boolean") {
|
|
1524
|
+
update.usageStats = body.usageStats;
|
|
1525
|
+
}
|
|
1526
|
+
if (typeof body.localDiagnostics === "boolean") {
|
|
1527
|
+
update.localDiagnostics = body.localDiagnostics;
|
|
1528
|
+
}
|
|
1529
|
+
setTelemetryPrefs(update);
|
|
1530
|
+
const prefs = getTelemetryPrefs();
|
|
1531
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1532
|
+
res.end(JSON.stringify(prefs));
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// /restart — graceful bridge restart endpoint.
|
|
1537
|
+
// POST → triggers SIGTERM if no active work; returns 409 if busy.
|
|
1538
|
+
// Safety checks: rejects restart if sessions have in-flight tool calls.
|
|
1539
|
+
if (parsedUrl.pathname === "/restart" && req.method === "POST") {
|
|
1540
|
+
if (!this.restartCheckFn) {
|
|
1541
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1542
|
+
res.end(JSON.stringify({
|
|
1543
|
+
ok: false,
|
|
1544
|
+
error: "restart_unavailable",
|
|
1545
|
+
reason: "Restart endpoint not configured",
|
|
1546
|
+
}));
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const check = this.restartCheckFn();
|
|
1550
|
+
// Reject restart if there's active work
|
|
1551
|
+
if (check.inFlightCalls > 0) {
|
|
1552
|
+
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"}`);
|
|
1553
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1554
|
+
res.end(JSON.stringify({
|
|
1555
|
+
ok: false,
|
|
1556
|
+
error: "restart_blocked",
|
|
1557
|
+
reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
|
|
1558
|
+
activeSessions: check.totalSessions,
|
|
1559
|
+
inFlightCalls: check.inFlightCalls,
|
|
1560
|
+
busySessions: check.busySessions,
|
|
1561
|
+
}));
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
// Safe to restart — log and trigger SIGTERM
|
|
1565
|
+
this.logger.info(`[/restart] Initiating graceful restart — ${check.totalSessions} session${check.totalSessions === 1 ? "" : "s"}, 0 in-flight calls`);
|
|
1566
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
1567
|
+
res.end(JSON.stringify({
|
|
1568
|
+
ok: true,
|
|
1569
|
+
message: "Restart initiated. Bridge will shut down gracefully.",
|
|
1570
|
+
activeSessions: check.totalSessions,
|
|
1571
|
+
}));
|
|
1572
|
+
// Trigger shutdown after response is sent (100ms delay to ensure response delivery).
|
|
1573
|
+
// Uses this.restartKillFn so tests can override without killing the runner.
|
|
1574
|
+
const killFn = this.restartKillFn;
|
|
1575
|
+
setTimeout(() => {
|
|
1576
|
+
this.logger.info("[/restart] Sending SIGTERM to self");
|
|
1577
|
+
killFn();
|
|
1578
|
+
}, 100);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
// /shutdown — graceful exit endpoint. POST → triggers the bridge's
|
|
1582
|
+
// internal shutdown sequence (lockfile unlink, HTTP close, telemetry
|
|
1583
|
+
// flush). Same in-flight safety check as /restart; pass `?force=1` to
|
|
1584
|
+
// skip it. Necessary on Windows where `process.kill(pid, 'SIGTERM')`
|
|
1585
|
+
// is TerminateProcess and cleanup handlers never fire — the bridge
|
|
1586
|
+
// wires `shutdownFn` to call the shutdown sequence directly.
|
|
1587
|
+
if (parsedUrl.pathname === "/shutdown" && req.method === "POST") {
|
|
1588
|
+
const force = parsedUrl.searchParams.get("force") === "1";
|
|
1589
|
+
if (!force && this.restartCheckFn) {
|
|
1590
|
+
const check = this.restartCheckFn();
|
|
1591
|
+
if (check.inFlightCalls > 0) {
|
|
1592
|
+
this.logger.warn(`[/shutdown] Rejected — ${check.inFlightCalls} in-flight tool call${check.inFlightCalls === 1 ? "" : "s"}`);
|
|
1593
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1594
|
+
res.end(JSON.stringify({
|
|
1595
|
+
ok: false,
|
|
1596
|
+
error: "shutdown_blocked",
|
|
1597
|
+
reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
|
|
1598
|
+
inFlightCalls: check.inFlightCalls,
|
|
1599
|
+
busySessions: check.busySessions,
|
|
1600
|
+
}));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
this.logger.info("[/shutdown] Initiating graceful shutdown");
|
|
1605
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
1606
|
+
res.end(JSON.stringify({
|
|
1607
|
+
ok: true,
|
|
1608
|
+
message: "Shutdown initiated.",
|
|
1609
|
+
}));
|
|
1610
|
+
const shutdownFn = this.shutdownFn;
|
|
1611
|
+
setTimeout(() => {
|
|
1612
|
+
this.logger.info("[/shutdown] Calling bridge shutdown sequence");
|
|
1613
|
+
shutdownFn();
|
|
1614
|
+
}, 100);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1264
1617
|
// CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
|
|
1265
1618
|
if (parsedUrl.pathname === "/notify" && req.method === "POST") {
|
|
1266
1619
|
// 8 KB — notify payloads carry an event name + small arg map
|
|
@@ -1409,7 +1762,7 @@ export class Server extends EventEmitter {
|
|
|
1409
1762
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1410
1763
|
res.end(JSON.stringify({
|
|
1411
1764
|
ok: false,
|
|
1412
|
-
error: "Quick tasks unavailable — requires --
|
|
1765
|
+
error: "Quick tasks unavailable — requires --driver subprocess",
|
|
1413
1766
|
}));
|
|
1414
1767
|
return;
|
|
1415
1768
|
}
|