patchwork-os 0.2.0-beta.0 → 0.2.0-beta.10
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 +14 -14
- package/README.md +204 -34
- package/deploy/README.md +1 -1
- package/deploy/bootstrap-vps.sh +14 -9
- 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 +63 -26
- package/dist/activityLog.js.map +1 -1
- package/dist/adapters/claude.js +22 -16
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/gemini.js +15 -11
- package/dist/adapters/gemini.js.map +1 -1
- package/dist/adapters/openai.js +9 -9
- package/dist/adapters/openai.js.map +1 -1
- package/dist/ajv2020.d.ts +25 -0
- package/dist/ajv2020.js +33 -0
- package/dist/ajv2020.js.map +1 -0
- package/dist/analyticsAggregator.d.ts +5 -1
- package/dist/analyticsAggregator.js +16 -5
- package/dist/analyticsAggregator.js.map +1 -1
- package/dist/analyticsConfig.d.ts +29 -0
- package/dist/analyticsConfig.js +102 -0
- package/dist/analyticsConfig.js.map +1 -0
- package/dist/analyticsPrefs.d.ts +49 -2
- package/dist/analyticsPrefs.js +177 -19
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/analyticsSend.d.ts +17 -1
- package/dist/analyticsSend.js +67 -5
- package/dist/analyticsSend.js.map +1 -1
- package/dist/approvalHttp.d.ts +14 -0
- package/dist/approvalHttp.js +212 -9
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalInsights.js +13 -5
- package/dist/approvalInsights.js.map +1 -1
- package/dist/approvalQueue.d.ts +97 -3
- package/dist/approvalQueue.js +193 -19
- package/dist/approvalQueue.js.map +1 -1
- package/dist/approvalSignals.js +19 -11
- package/dist/approvalSignals.js.map +1 -1
- package/dist/automation.d.ts +35 -10
- package/dist/automation.js +133 -37
- package/dist/automation.js.map +1 -1
- package/dist/automationSuggestions.js +10 -8
- package/dist/automationSuggestions.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +238 -51
- 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/bridgeToken.js +3 -2
- package/dist/bridgeToken.js.map +1 -1
- package/dist/claudeDriver.d.ts +0 -16
- package/dist/claudeDriver.js +42 -28
- 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 +13 -1
- package/dist/claudeOrchestrator.js +89 -44
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/analytics.d.ts +8 -0
- package/dist/commands/analytics.js +134 -0
- package/dist/commands/analytics.js.map +1 -0
- package/dist/commands/auditEnv.d.ts +36 -0
- package/dist/commands/auditEnv.js +202 -0
- package/dist/commands/auditEnv.js.map +1 -0
- package/dist/commands/dashboard.js +8 -1
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/doctor.d.ts +35 -0
- package/dist/commands/doctor.js +51 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/install.js +3 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/launchd.js +15 -16
- package/dist/commands/launchd.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 +110 -0
- package/dist/commands/recipe.js +512 -9
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +11 -4
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/shadowScan.d.ts +34 -0
- package/dist/commands/shadowScan.js +142 -0
- package/dist/commands/shadowScan.js.map +1 -0
- package/dist/commands/task.js +2 -2
- package/dist/commands/task.js.map +1 -1
- package/dist/commands/tools.d.ts +20 -1
- package/dist/commands/tools.js +112 -3
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/tracesImport.js +21 -4
- package/dist/commands/tracesImport.js.map +1 -1
- package/dist/commitIssueLinkLog.d.ts +24 -0
- package/dist/commitIssueLinkLog.js +160 -22
- package/dist/commitIssueLinkLog.js.map +1 -1
- package/dist/companions/registry.js +15 -7
- package/dist/companions/registry.js.map +1 -1
- package/dist/config.d.ts +49 -5
- package/dist/config.js +123 -22
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +913 -413
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/airtable.d.ts +230 -0
- package/dist/connectors/airtable.js +700 -0
- package/dist/connectors/airtable.js.map +1 -0
- package/dist/connectors/asana.js +13 -9
- package/dist/connectors/asana.js.map +1 -1
- package/dist/connectors/baseConnector.js +25 -3
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/caldiy.d.ts +137 -0
- package/dist/connectors/caldiy.js +424 -0
- package/dist/connectors/caldiy.js.map +1 -0
- package/dist/connectors/circleci.d.ts +162 -0
- package/dist/connectors/circleci.js +576 -0
- package/dist/connectors/circleci.js.map +1 -0
- package/dist/connectors/cloudflare.d.ts +132 -0
- package/dist/connectors/cloudflare.js +622 -0
- package/dist/connectors/cloudflare.js.map +1 -0
- package/dist/connectors/confluence.js +35 -0
- package/dist/connectors/confluence.js.map +1 -1
- package/dist/connectors/connectorRedirectUri.d.ts +30 -0
- package/dist/connectors/connectorRedirectUri.js +38 -0
- package/dist/connectors/connectorRedirectUri.js.map +1 -0
- package/dist/connectors/connectorRegistry.d.ts +63 -0
- package/dist/connectors/connectorRegistry.js +354 -0
- package/dist/connectors/connectorRegistry.js.map +1 -0
- package/dist/connectors/datadog.js +33 -4
- package/dist/connectors/datadog.js.map +1 -1
- package/dist/connectors/discord.js +14 -10
- package/dist/connectors/discord.js.map +1 -1
- package/dist/connectors/elasticsearch.d.ts +141 -0
- package/dist/connectors/elasticsearch.js +601 -0
- package/dist/connectors/elasticsearch.js.map +1 -0
- package/dist/connectors/figma.d.ts +130 -0
- package/dist/connectors/figma.js +387 -0
- package/dist/connectors/figma.js.map +1 -0
- package/dist/connectors/github.d.ts +17 -0
- package/dist/connectors/github.js +53 -2
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.js +12 -6
- package/dist/connectors/gitlab.js.map +1 -1
- package/dist/connectors/gmail.js +72 -21
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.js +8 -8
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/connectors/googleDocs.d.ts +103 -0
- package/dist/connectors/googleDocs.js +409 -0
- package/dist/connectors/googleDocs.js.map +1 -0
- package/dist/connectors/googleDrive.d.ts +12 -0
- package/dist/connectors/googleDrive.js +35 -8
- package/dist/connectors/googleDrive.js.map +1 -1
- package/dist/connectors/grafana.d.ts +133 -0
- package/dist/connectors/grafana.js +478 -0
- package/dist/connectors/grafana.js.map +1 -0
- package/dist/connectors/jira.d.ts +25 -0
- package/dist/connectors/jira.js +245 -2
- package/dist/connectors/jira.js.map +1 -1
- package/dist/connectors/linear.js +1 -1
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +78 -16
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/monday.d.ts +217 -0
- package/dist/connectors/monday.js +655 -0
- package/dist/connectors/monday.js.map +1 -0
- package/dist/connectors/mongodb.d.ts +139 -0
- package/dist/connectors/mongodb.js +455 -0
- package/dist/connectors/mongodb.js.map +1 -0
- package/dist/connectors/oauthStateStore.d.ts +20 -0
- package/dist/connectors/oauthStateStore.js +94 -4
- package/dist/connectors/oauthStateStore.js.map +1 -1
- package/dist/connectors/obsidian.d.ts +83 -0
- package/dist/connectors/obsidian.js +441 -0
- package/dist/connectors/obsidian.js.map +1 -0
- package/dist/connectors/paystack.d.ts +159 -0
- package/dist/connectors/paystack.js +607 -0
- package/dist/connectors/paystack.js.map +1 -0
- package/dist/connectors/pipedrive.d.ts +189 -0
- package/dist/connectors/pipedrive.js +559 -0
- package/dist/connectors/pipedrive.js.map +1 -0
- package/dist/connectors/postgres.d.ts +127 -0
- package/dist/connectors/postgres.js +512 -0
- package/dist/connectors/postgres.js.map +1 -0
- package/dist/connectors/posthog.d.ts +119 -0
- package/dist/connectors/posthog.js +479 -0
- package/dist/connectors/posthog.js.map +1 -0
- package/dist/connectors/redis.d.ts +140 -0
- package/dist/connectors/redis.js +571 -0
- package/dist/connectors/redis.js.map +1 -0
- package/dist/connectors/resend.d.ts +131 -0
- package/dist/connectors/resend.js +529 -0
- package/dist/connectors/resend.js.map +1 -0
- package/dist/connectors/salesforce.d.ts +136 -0
- package/dist/connectors/salesforce.js +603 -0
- package/dist/connectors/salesforce.js.map +1 -0
- package/dist/connectors/secrets.d.ts +51 -0
- package/dist/connectors/secrets.js +102 -0
- package/dist/connectors/secrets.js.map +1 -0
- package/dist/connectors/sendgrid.d.ts +102 -0
- package/dist/connectors/sendgrid.js +423 -0
- package/dist/connectors/sendgrid.js.map +1 -0
- package/dist/connectors/shopify.d.ts +145 -0
- package/dist/connectors/shopify.js +502 -0
- package/dist/connectors/shopify.js.map +1 -0
- package/dist/connectors/slack.d.ts +1 -1
- package/dist/connectors/slack.js +61 -9
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/snowflake.d.ts +119 -0
- package/dist/connectors/snowflake.js +615 -0
- package/dist/connectors/snowflake.js.map +1 -0
- package/dist/connectors/supabase.d.ts +92 -0
- package/dist/connectors/supabase.js +630 -0
- package/dist/connectors/supabase.js.map +1 -0
- package/dist/connectors/todoist.d.ts +117 -0
- package/dist/connectors/todoist.js +485 -0
- package/dist/connectors/todoist.js.map +1 -0
- package/dist/connectors/tokenStorage.js +56 -14
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/connectors/twilio.d.ts +118 -0
- package/dist/connectors/twilio.js +475 -0
- package/dist/connectors/twilio.js.map +1 -0
- package/dist/connectors/vercel.d.ts +110 -0
- package/dist/connectors/vercel.js +479 -0
- package/dist/connectors/vercel.js.map +1 -0
- package/dist/connectors/webflow.d.ts +118 -0
- package/dist/connectors/webflow.js +393 -0
- package/dist/connectors/webflow.js.map +1 -0
- package/dist/connectors/woocommerce.d.ts +220 -0
- package/dist/connectors/woocommerce.js +464 -0
- package/dist/connectors/woocommerce.js.map +1 -0
- package/dist/crypto.js +7 -2
- package/dist/crypto.js.map +1 -1
- package/dist/decisionReplay.js +15 -6
- package/dist/decisionReplay.js.map +1 -1
- package/dist/decisionTraceLog.d.ts +28 -0
- package/dist/decisionTraceLog.js +156 -29
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/drivers/claude/api.js +5 -4
- package/dist/drivers/claude/api.js.map +1 -1
- package/dist/drivers/claude/envSanitizer.d.ts +0 -6
- package/dist/drivers/claude/envSanitizer.js +17 -2
- package/dist/drivers/claude/envSanitizer.js.map +1 -1
- package/dist/drivers/claude/streamParser.js +5 -4
- package/dist/drivers/claude/streamParser.js.map +1 -1
- package/dist/drivers/claude/subprocess.d.ts +12 -2
- package/dist/drivers/claude/subprocess.js +100 -8
- 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 +256 -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 +37 -4
- package/dist/extensionClient.js +58 -16
- package/dist/extensionClient.js.map +1 -1
- package/dist/featureFlags.d.ts +91 -0
- package/dist/featureFlags.js +174 -3
- package/dist/featureFlags.js.map +1 -1
- package/dist/fileLock.js +21 -12
- package/dist/fileLock.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/activityAnalytics.js +26 -12
- package/dist/fp/activityAnalytics.js.map +1 -1
- package/dist/fp/automationInterpreter.d.ts +15 -1
- package/dist/fp/automationInterpreter.js +217 -82
- 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 +24 -5
- package/dist/fp/automationState.js +56 -9
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/automationUtils.js +1 -1
- package/dist/fp/automationUtils.js.map +1 -1
- package/dist/fp/commandDescription.js +7 -1
- package/dist/fp/commandDescription.js.map +1 -1
- package/dist/fp/extensionSnapshot.js +8 -2
- package/dist/fp/extensionSnapshot.js.map +1 -1
- package/dist/fp/interpreterContext.d.ts +66 -1
- package/dist/fp/interpreterContext.js +91 -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/fsWatchWithFallback.d.ts +36 -0
- package/dist/fsWatchWithFallback.js +128 -0
- package/dist/fsWatchWithFallback.js.map +1 -0
- package/dist/haltPushDispatch.d.ts +33 -0
- package/dist/haltPushDispatch.js +103 -0
- package/dist/haltPushDispatch.js.map +1 -0
- package/dist/httpBodyValidation.d.ts +41 -0
- package/dist/httpBodyValidation.js +45 -0
- package/dist/httpBodyValidation.js.map +1 -0
- package/dist/httpErrorResponse.d.ts +36 -0
- package/dist/httpErrorResponse.js +46 -0
- package/dist/httpErrorResponse.js.map +1 -0
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +151 -12
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1054 -76
- 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/oauth.d.ts +22 -0
- package/dist/oauth.js +46 -0
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.d.ts +1 -1
- package/dist/oauthRoutes.js +8 -11
- package/dist/oauthRoutes.js.map +1 -1
- package/dist/orchestrator/childBridgeRegistry.js +29 -11
- package/dist/orchestrator/childBridgeRegistry.js.map +1 -1
- package/dist/patchworkConfig.d.ts +57 -1
- package/dist/patchworkConfig.js +125 -5
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.js +10 -1
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +12 -14
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.js +10 -3
- 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 +7 -3
- package/dist/prompts.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +9 -0
- package/dist/recipeOrchestration.js +354 -27
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +63 -2
- package/dist/recipeRoutes.js +790 -137
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/RecipeOrchestrator.d.ts +2 -0
- package/dist/recipes/RecipeOrchestrator.js +6 -1
- package/dist/recipes/RecipeOrchestrator.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +34 -5
- package/dist/recipes/agentExecutor.js +5 -4
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +2 -0
- package/dist/recipes/chainedRunner.js +142 -5
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/connectorPreflight.d.ts +66 -0
- package/dist/recipes/connectorPreflight.js +169 -0
- package/dist/recipes/connectorPreflight.js.map +1 -0
- package/dist/recipes/dependencyGraph.js +13 -5
- package/dist/recipes/dependencyGraph.js.map +1 -1
- package/dist/recipes/githubInstallSource.d.ts +128 -0
- package/dist/recipes/githubInstallSource.js +206 -0
- package/dist/recipes/githubInstallSource.js.map +1 -0
- package/dist/recipes/haltCategory.d.ts +119 -0
- package/dist/recipes/haltCategory.js +188 -0
- package/dist/recipes/haltCategory.js.map +1 -0
- package/dist/recipes/idempotencyKey.d.ts +134 -0
- package/dist/recipes/idempotencyKey.js +322 -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/names.d.ts +20 -0
- package/dist/recipes/names.js +25 -0
- package/dist/recipes/names.js.map +1 -1
- package/dist/recipes/parser.js +88 -5
- package/dist/recipes/parser.js.map +1 -1
- package/dist/recipes/replayRun.js +1 -1
- package/dist/recipes/replayRun.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 +30 -0
- package/dist/recipes/scheduler.js +69 -19
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +36 -0
- package/dist/recipes/schemaGenerator.js +103 -5
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/stepObservation.js +9 -0
- package/dist/recipes/stepObservation.js.map +1 -1
- package/dist/recipes/toolRegistry.js +20 -3
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/fanOut.d.ts +20 -0
- package/dist/recipes/tools/fanOut.js +199 -0
- package/dist/recipes/tools/fanOut.js.map +1 -0
- package/dist/recipes/tools/file.js +5 -2
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/github.d.ts +1 -1
- package/dist/recipes/tools/github.js +75 -1
- package/dist/recipes/tools/github.js.map +1 -1
- package/dist/recipes/tools/gmail.js +45 -6
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/googleDrive.js +64 -0
- package/dist/recipes/tools/googleDrive.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 +2 -0
- package/dist/recipes/tools/index.js +2 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/slack.js +1 -1
- package/dist/recipes/validation.d.ts +17 -0
- package/dist/recipes/validation.js +29 -11
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/workspaceRoot.d.ts +37 -0
- package/dist/recipes/workspaceRoot.js +73 -0
- package/dist/recipes/workspaceRoot.js.map +1 -0
- package/dist/recipes/yamlPositions.d.ts +56 -0
- package/dist/recipes/yamlPositions.js +183 -0
- package/dist/recipes/yamlPositions.js.map +1 -0
- package/dist/recipes/yamlRunner.d.ts +195 -8
- package/dist/recipes/yamlRunner.js +877 -209
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +33 -3
- package/dist/recipesHttp.js +126 -12
- package/dist/recipesHttp.js.map +1 -1
- package/dist/resources.js +21 -13
- package/dist/resources.js.map +1 -1
- package/dist/runLog.d.ts +58 -5
- package/dist/runLog.js +98 -21
- 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/schemas/recipe.v1.json +885 -196
- package/dist/server.d.ts +166 -3
- package/dist/server.js +1313 -332
- package/dist/server.js.map +1 -1
- package/dist/sessionCheckpoint.d.ts +8 -0
- package/dist/sessionCheckpoint.js +33 -3
- package/dist/sessionCheckpoint.js.map +1 -1
- package/dist/ssrfGuard.d.ts +16 -0
- package/dist/ssrfGuard.js +87 -4
- package/dist/ssrfGuard.js.map +1 -1
- package/dist/streamableHttp.d.ts +9 -4
- package/dist/streamableHttp.js +76 -20
- package/dist/streamableHttp.js.map +1 -1
- package/dist/telemetry.js +19 -12
- package/dist/telemetry.js.map +1 -1
- package/dist/testing/shadowRun.d.ts +52 -0
- package/dist/testing/shadowRun.js +71 -0
- package/dist/testing/shadowRun.js.map +1 -0
- package/dist/tools/batchLsp.d.ts +3 -0
- package/dist/tools/bridgeDoctor.d.ts +11 -0
- package/dist/tools/bridgeDoctor.js +48 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/bridgeStatus.d.ts +0 -12
- package/dist/tools/bridgeStatus.js +2 -28
- package/dist/tools/bridgeStatus.js.map +1 -1
- package/dist/tools/cancelClaudeTask.d.ts +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/closeTabs.d.ts +1 -0
- package/dist/tools/codeLens.d.ts +1 -0
- package/dist/tools/createIssueFromAIComment.d.ts +1 -0
- package/dist/tools/ctxGetTaskContext.d.ts +9 -0
- package/dist/tools/ctxGetTaskContext.js +50 -2
- package/dist/tools/ctxGetTaskContext.js.map +1 -1
- package/dist/tools/ctxQueryTraces.js +32 -24
- package/dist/tools/ctxQueryTraces.js.map +1 -1
- package/dist/tools/ctxSaveTrace.d.ts +1 -0
- package/dist/tools/debug.d.ts +4 -0
- package/dist/tools/decorations.d.ts +2 -0
- package/dist/tools/detectUnusedCode.js +9 -7
- package/dist/tools/detectUnusedCode.js.map +1 -1
- package/dist/tools/documentLinks.d.ts +1 -0
- package/dist/tools/editText.d.ts +1 -0
- package/dist/tools/editText.js +2 -1
- package/dist/tools/editText.js.map +1 -1
- package/dist/tools/enrichCommit.d.ts +1 -0
- package/dist/tools/enrichStackTrace.js +11 -5
- package/dist/tools/enrichStackTrace.js.map +1 -1
- package/dist/tools/explainDiagnostic.d.ts +1 -0
- package/dist/tools/explainSymbol.d.ts +1 -0
- package/dist/tools/fileOperations.d.ts +3 -0
- package/dist/tools/fileOperations.js +2 -1
- package/dist/tools/fileOperations.js.map +1 -1
- package/dist/tools/fileWatcher.d.ts +2 -0
- package/dist/tools/fileWatcher.js +8 -2
- package/dist/tools/fileWatcher.js.map +1 -1
- package/dist/tools/findFiles.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.js +10 -5
- package/dist/tools/fixAllLintErrors.js.map +1 -1
- package/dist/tools/foldingRanges.d.ts +1 -0
- package/dist/tools/formatDocument.d.ts +1 -0
- package/dist/tools/formatDocument.js +10 -5
- package/dist/tools/formatDocument.js.map +1 -1
- package/dist/tools/generateTests.d.ts +1 -0
- package/dist/tools/getAIComments.d.ts +1 -0
- package/dist/tools/getAnalyticsReport.js +5 -1
- package/dist/tools/getAnalyticsReport.js.map +1 -1
- package/dist/tools/getBufferContent.d.ts +1 -0
- package/dist/tools/getChangeImpact.d.ts +1 -0
- package/dist/tools/getChangeImpact.js +23 -14
- package/dist/tools/getChangeImpact.js.map +1 -1
- package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
- package/dist/tools/getCodeCoverage.d.ts +1 -0
- package/dist/tools/getCodeCoverage.js +32 -14
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/getCommitsForIssue.d.ts +1 -0
- package/dist/tools/getDebugState.d.ts +1 -0
- package/dist/tools/getDiagnostics.js +32 -29
- package/dist/tools/getDiagnostics.js.map +1 -1
- package/dist/tools/getDiffFromHandoff.js +8 -2
- package/dist/tools/getDiffFromHandoff.js.map +1 -1
- package/dist/tools/getDocumentSymbols.d.ts +1 -0
- package/dist/tools/getGitHotspots.d.ts +1 -0
- package/dist/tools/getImportedSignatures.d.ts +1 -0
- package/dist/tools/getImportedSignatures.js +18 -14
- package/dist/tools/getImportedSignatures.js.map +1 -1
- package/dist/tools/getPRTemplate.d.ts +1 -0
- package/dist/tools/getProjectContext.js +23 -10
- package/dist/tools/getProjectContext.js.map +1 -1
- package/dist/tools/getSymbolHistory.d.ts +1 -0
- package/dist/tools/getToolCapabilities.d.ts +10 -5
- package/dist/tools/getToolCapabilities.js +79 -202
- package/dist/tools/getToolCapabilities.js.map +1 -1
- package/dist/tools/getTypeSignature.d.ts +1 -0
- package/dist/tools/getWorkspaceSettings.d.ts +1 -0
- package/dist/tools/gitWrite.d.ts +11 -0
- package/dist/tools/github/actions.d.ts +2 -0
- package/dist/tools/github/composite.d.ts +3 -0
- package/dist/tools/github/issues.d.ts +4 -0
- package/dist/tools/github/pr.d.ts +7 -0
- package/dist/tools/handoffNote.d.ts +1 -0
- 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/hoverAtCursor.d.ts +1 -0
- package/dist/tools/httpClient.d.ts +2 -0
- package/dist/tools/httpClient.js +38 -71
- package/dist/tools/httpClient.js.map +1 -1
- package/dist/tools/inlayHints.d.ts +1 -0
- package/dist/tools/launchQuickTask.d.ts +1 -0
- package/dist/tools/listClaudeTasks.d.ts +1 -0
- package/dist/tools/listClaudeTasks.js +10 -8
- package/dist/tools/listClaudeTasks.js.map +1 -1
- package/dist/tools/listTerminals.d.ts +1 -0
- package/dist/tools/lsp.d.ts +15 -0
- package/dist/tools/lsp.js +17 -0
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/navigateToSymbolByName.d.ts +1 -0
- package/dist/tools/openDiff.d.ts +1 -0
- package/dist/tools/openDiff.js +4 -1
- package/dist/tools/openDiff.js.map +1 -1
- package/dist/tools/openFile.d.ts +1 -0
- package/dist/tools/openFile.js +4 -1
- package/dist/tools/openFile.js.map +1 -1
- package/dist/tools/openInBrowser.js +6 -1
- package/dist/tools/openInBrowser.js.map +1 -1
- package/dist/tools/organizeImports.d.ts +1 -0
- package/dist/tools/organizeImports.js +5 -3
- package/dist/tools/organizeImports.js.map +1 -1
- package/dist/tools/performanceReport.js +5 -3
- package/dist/tools/performanceReport.js.map +1 -1
- package/dist/tools/planPersistence.d.ts +3 -0
- package/dist/tools/planPersistence.js +4 -1
- package/dist/tools/planPersistence.js.map +1 -1
- package/dist/tools/previewEdit.d.ts +1 -0
- package/dist/tools/previewEdit.js +15 -4
- package/dist/tools/previewEdit.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +54 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/refactorAnalyze.d.ts +1 -0
- package/dist/tools/refactorExtractFunction.js +4 -1
- package/dist/tools/refactorExtractFunction.js.map +1 -1
- package/dist/tools/refactorPreview.d.ts +1 -0
- package/dist/tools/refactorPreview.js +10 -2
- package/dist/tools/refactorPreview.js.map +1 -1
- package/dist/tools/replaceBlock.d.ts +1 -0
- package/dist/tools/replaceBlock.js +2 -1
- package/dist/tools/replaceBlock.js.map +1 -1
- package/dist/tools/resumeClaudeTask.d.ts +1 -0
- package/dist/tools/runClaudeTask.d.ts +1 -0
- package/dist/tools/runCommand.js +5 -0
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/runTests.js +15 -4
- package/dist/tools/runTests.js.map +1 -1
- package/dist/tools/screenshot.d.ts +1 -0
- package/dist/tools/screenshotAndAnnotate.js +6 -2
- package/dist/tools/screenshotAndAnnotate.js.map +1 -1
- package/dist/tools/searchAndReplace.d.ts +1 -0
- package/dist/tools/searchAndReplace.js +17 -7
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/searchTools.js +20 -19
- package/dist/tools/searchTools.js.map +1 -1
- package/dist/tools/searchWorkspace.d.ts +1 -0
- package/dist/tools/selectionRanges.d.ts +1 -0
- package/dist/tools/semanticTokens.d.ts +1 -0
- package/dist/tools/signatureHelp.d.ts +1 -0
- package/dist/tools/spawnWorkspace.js +15 -7
- package/dist/tools/spawnWorkspace.js.map +1 -1
- package/dist/tools/terminal.d.ts +6 -0
- package/dist/tools/terminal.js +4 -0
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/testRunners/pytest.js +6 -2
- package/dist/tools/testRunners/pytest.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/testTraceToSource.d.ts +1 -0
- package/dist/tools/transaction.d.ts +4 -0
- package/dist/tools/transaction.js +4 -1
- package/dist/tools/transaction.js.map +1 -1
- package/dist/tools/typeHierarchy.d.ts +1 -0
- package/dist/tools/utils.d.ts +22 -0
- package/dist/tools/utils.js +158 -14
- package/dist/tools/utils.js.map +1 -1
- package/dist/tools/vscodeCommands.d.ts +2 -0
- package/dist/tools/vscodeTasks.d.ts +2 -0
- package/dist/tools/workspaceSettings.d.ts +1 -0
- package/dist/transport.d.ts +3 -1
- package/dist/transport.js +103 -52
- 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/wireHaltPushDispatch.d.ts +38 -0
- package/dist/wireHaltPushDispatch.js +71 -0
- package/dist/wireHaltPushDispatch.js.map +1 -0
- package/dist/writeFileAtomic.d.ts +23 -0
- package/dist/writeFileAtomic.js +121 -0
- package/dist/writeFileAtomic.js.map +1 -0
- package/package.json +23 -7
- 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 +77 -19
- 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 +2 -2
- package/templates/recipes/approval-queue-ui-test.yaml +205 -0
- package/templates/recipes/ctx-loop-test.yaml +1 -1
- package/templates/recipes/fix-errors-on-save.yaml +71 -0
- package/templates/recipes/morning-brief.yaml +5 -2
- package/templates/recipes/project-health-check.yaml +4 -1
- package/templates/recipes/sentry-to-linear.yaml +72 -38
- package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
- package/templates/recipes/webhook/customer-escalation.yaml +8 -9
- package/templates/recipes/webhook/meeting-prep.yaml +11 -5
- package/dist/commands/marketplace.d.ts +0 -11
- package/dist/commands/marketplace.js +0 -120
- 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,17 +1,25 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import { unlinkSync } from "node:fs";
|
|
2
4
|
import http from "node:http";
|
|
3
5
|
import { WebSocket, WebSocketServer as WsServer } from "ws";
|
|
6
|
+
import { clearAnalyticsConfig, getAnalyticsConfig } from "./analyticsConfig.js";
|
|
7
|
+
import { getAnalyticsPrefsAll, getAnalyticsPrefsPath, getAnalyticsSaltPath, getTelemetryPrefs, setTelemetryPrefs, } from "./analyticsPrefs.js";
|
|
4
8
|
import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
|
|
5
9
|
import { getApprovalQueue } from "./approvalQueue.js";
|
|
6
10
|
import { saveBridgeConfigDriver } from "./config.js";
|
|
7
11
|
import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
|
|
8
12
|
import { timingSafeStringEqual } from "./crypto.js";
|
|
9
13
|
import { renderDashboardHtml } from "./dashboard.js";
|
|
14
|
+
import { isLoopbackOrPrivateEndpoint } from "./drivers/local/index.js";
|
|
15
|
+
import { EnvLockedFlagError, getEnvLockedValue, isEnabled, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, listFlags, setFlag, } from "./featureFlags.js";
|
|
16
|
+
import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
|
|
17
|
+
import { respond500 } from "./httpErrorResponse.js";
|
|
10
18
|
import { tryHandleInboxRoute } from "./inboxRoutes.js";
|
|
11
19
|
import { tryHandleMcpRoute } from "./mcpRoutes.js";
|
|
12
20
|
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";
|
|
21
|
+
import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveApiKeyToSecureStore, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
|
|
22
|
+
import { readBodyWithCap, readJsonBody, respond413, tryHandleRecipeRoute, } from "./recipeRoutes.js";
|
|
15
23
|
import { PACKAGE_VERSION } from "./version.js";
|
|
16
24
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
17
25
|
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 +56,7 @@ export class Server extends EventEmitter {
|
|
|
48
56
|
logger;
|
|
49
57
|
extraCorsOrigins;
|
|
50
58
|
pingIntervalMs;
|
|
59
|
+
trustedProxies;
|
|
51
60
|
httpServer;
|
|
52
61
|
wss;
|
|
53
62
|
pingInterval = null;
|
|
@@ -58,9 +67,25 @@ export class Server extends EventEmitter {
|
|
|
58
67
|
oauthServer = null;
|
|
59
68
|
oauthIssuerUrl = null;
|
|
60
69
|
sseSubscriberCount = 0;
|
|
70
|
+
/**
|
|
71
|
+
* Registered SSE subscribers on `/stream`, oldest-first by insertion
|
|
72
|
+
* order. Used as a defense-in-depth backstop: if a subscriber's peer is
|
|
73
|
+
* a dead-but-not-erroring socket (e.g. an orphaned proxy connection),
|
|
74
|
+
* the ping-write cleanup can fail to fire and the subscriber leaks. To
|
|
75
|
+
* guarantee a leak can never *permanently* brick `/stream`, a new
|
|
76
|
+
* request arriving at the cap evicts the oldest subscriber instead of
|
|
77
|
+
* returning 503 — mirroring the HTTP-session eviction in ADR-0005.
|
|
78
|
+
* The real cure for the known leak is the dashboard proxy aborting its
|
|
79
|
+
* upstream fetch on client disconnect; this is the backstop.
|
|
80
|
+
*/
|
|
81
|
+
sseSubscribers = new Set();
|
|
61
82
|
/** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
|
|
62
83
|
_explainRulesCache = null;
|
|
63
84
|
static MAX_SSE_SUBSCRIBERS = 20;
|
|
85
|
+
/** Number of currently-registered `/stream` SSE subscribers. Read-only. */
|
|
86
|
+
get sseSubscribers_count() {
|
|
87
|
+
return this.sseSubscriberCount;
|
|
88
|
+
}
|
|
64
89
|
/** Set by bridge to provide health data */
|
|
65
90
|
healthDataFn = null;
|
|
66
91
|
/** Set by bridge to provide Prometheus metrics */
|
|
@@ -79,6 +104,15 @@ export class Server extends EventEmitter {
|
|
|
79
104
|
setRecipeTrustFn = null;
|
|
80
105
|
/** Patchwork: set by bridge to generate a recipe YAML draft from a natural-language prompt. */
|
|
81
106
|
generateRecipeFn = null;
|
|
107
|
+
/**
|
|
108
|
+
* Patchwork Phase 2A: repair a broken recipe YAML given the current
|
|
109
|
+
* content + structured LintIssue[] context. Driven by the same
|
|
110
|
+
* Claude-orchestrator infrastructure as `generateRecipeFn` (system
|
|
111
|
+
* prompt + sanitized user-tag wrapper + post-lint), but the user
|
|
112
|
+
* input is the user's CURRENT recipe rather than free-text — gated
|
|
113
|
+
* behind the `recipe.repair-ai` feature flag.
|
|
114
|
+
*/
|
|
115
|
+
repairRecipeFn = null;
|
|
82
116
|
/** Patchwork: set by bridge to list installed recipes for the dashboard. */
|
|
83
117
|
recipesFn = null;
|
|
84
118
|
/** Patchwork: set by bridge to load raw recipe source content by name. */
|
|
@@ -87,6 +121,8 @@ export class Server extends EventEmitter {
|
|
|
87
121
|
saveRecipeContentFn = null;
|
|
88
122
|
/** Patchwork: set by bridge to delete a recipe by name. */
|
|
89
123
|
deleteRecipeContentFn = null;
|
|
124
|
+
/** Patchwork: set by bridge to archive a recipe (move to .archive/). */
|
|
125
|
+
archiveRecipeFn = null;
|
|
90
126
|
/** Patchwork: set by bridge to promote a variant recipe to the canonical name. */
|
|
91
127
|
promoteRecipeVariantFn = null;
|
|
92
128
|
/** Patchwork: set by bridge to duplicate a recipe as a variant. */
|
|
@@ -99,6 +135,10 @@ export class Server extends EventEmitter {
|
|
|
99
135
|
runsFn = null;
|
|
100
136
|
/** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
|
|
101
137
|
runDetailFn = null;
|
|
138
|
+
/** Patchwork (PR1c): aggregate halt-reason categories across recent runs. */
|
|
139
|
+
haltSummaryFn = null;
|
|
140
|
+
/** Patchwork (PR3b): aggregate judge-step verdicts across recent runs. */
|
|
141
|
+
judgeSummaryFn = null;
|
|
102
142
|
/** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
|
|
103
143
|
runPlanFn = null;
|
|
104
144
|
/** Patchwork (VD-4): mocked replay of an existing run. Returns the new
|
|
@@ -106,10 +146,25 @@ export class Server extends EventEmitter {
|
|
|
106
146
|
runReplayFn = null;
|
|
107
147
|
/** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
|
|
108
148
|
runRecipeFn = null;
|
|
149
|
+
/**
|
|
150
|
+
* Patchwork: set by bridge to re-prime the recipe scheduler when the
|
|
151
|
+
* on-disk recipe set changes (install / save / delete). Lets cron-
|
|
152
|
+
* triggered recipes start firing without a bridge restart. Optional —
|
|
153
|
+
* tests + headless tooling leave it null; the install handler treats
|
|
154
|
+
* the callback as best-effort fire-and-forget.
|
|
155
|
+
*/
|
|
156
|
+
onRecipesChangedFn = null;
|
|
109
157
|
/** Patchwork: admin-controlled managed settings path (highest rule precedence). */
|
|
110
158
|
managedSettingsPath = undefined;
|
|
111
159
|
/** Effective bridge config path to update when dashboard saves driver changes. */
|
|
112
160
|
bridgeConfigPath = undefined;
|
|
161
|
+
/**
|
|
162
|
+
* Shared secret for HMAC-SHA256 verification of POST /hooks/* requests
|
|
163
|
+
* carrying `X-Hub-Signature-256`. When null (default), HMAC auth is
|
|
164
|
+
* disabled and /hooks/* requires the bridge bearer token like every
|
|
165
|
+
* other route. Set by Bridge constructor from `config.webhookSecret`.
|
|
166
|
+
*/
|
|
167
|
+
webhookSecret = null;
|
|
113
168
|
/** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
|
|
114
169
|
approvalGate = "off";
|
|
115
170
|
/** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
|
|
@@ -120,6 +175,10 @@ export class Server extends EventEmitter {
|
|
|
120
175
|
pushServiceToken = undefined;
|
|
121
176
|
/** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
|
|
122
177
|
pushServiceBaseUrl = undefined;
|
|
178
|
+
/** Patchwork: ntfy.sh topic for direct phone-path approvals via action buttons. */
|
|
179
|
+
ntfyTopic = undefined;
|
|
180
|
+
/** Patchwork: ntfy server (default https://ntfy.sh; override for self-hosted). */
|
|
181
|
+
ntfyServer = undefined;
|
|
123
182
|
/** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
|
|
124
183
|
onApprovalDecision = undefined;
|
|
125
184
|
/**
|
|
@@ -142,6 +201,33 @@ export class Server extends EventEmitter {
|
|
|
142
201
|
* the user's preference.
|
|
143
202
|
*/
|
|
144
203
|
enableTimeOfDayAnomaly = false;
|
|
204
|
+
/**
|
|
205
|
+
* Patchwork: set by bridge to record a kill-switch audit trace.
|
|
206
|
+
* `/kill-switch` POST emits one entry on every state transition (no-op
|
|
207
|
+
* toggles do not emit). When unset, the handler logs via this.logger
|
|
208
|
+
* instead — see step 5 of issue #422.
|
|
209
|
+
*
|
|
210
|
+
* Trace encoding (v2-I6 from #422): we encode kill-switch events via
|
|
211
|
+
* the existing `DecisionTrace` schema rather than extending its
|
|
212
|
+
* `traceType` union, to keep schema migration off the kill-switch
|
|
213
|
+
* critical path. Fields used:
|
|
214
|
+
* ref = "kill-switch.writes"
|
|
215
|
+
* problem = "<short reason>" or "engage" / "release" if no reason
|
|
216
|
+
* solution = "ENGAGED at <ts>" or "RELEASED at <ts>"
|
|
217
|
+
* tags = ["kill-switch", "engage" | "release", "actor:http"]
|
|
218
|
+
*
|
|
219
|
+
* `ctxQueryTraces({tag: "kill-switch"})` returns the full audit
|
|
220
|
+
* history; pair with `tag: "engage"` / `tag: "release"` to filter
|
|
221
|
+
* direction.
|
|
222
|
+
*/
|
|
223
|
+
recordKillSwitchTraceFn = null;
|
|
224
|
+
/**
|
|
225
|
+
* Set by bridge to broadcast a `kind: "kill-switch"` SSE event from
|
|
226
|
+
* `/stream` when the kill-switch state changes (issue #422 v2, pitfall I8).
|
|
227
|
+
* Bridge wires this to `activityLog.broadcastKillSwitch()` or an equivalent
|
|
228
|
+
* that notifies all active SSE listeners so the dashboard updates in <1s.
|
|
229
|
+
*/
|
|
230
|
+
broadcastKillSwitchEventFn = null;
|
|
145
231
|
/** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
|
|
146
232
|
webhookFn = null;
|
|
147
233
|
/**
|
|
@@ -154,6 +240,36 @@ export class Server extends EventEmitter {
|
|
|
154
240
|
*/
|
|
155
241
|
webhookPayloads = new Map();
|
|
156
242
|
static MAX_WEBHOOK_PAYLOADS = 5;
|
|
243
|
+
/**
|
|
244
|
+
* Per-list FIFO bounds the per-recipe payload count, but the Map itself
|
|
245
|
+
* needs a key cap so a recipe-rename loop or a scanner hitting many
|
|
246
|
+
* distinct legitimate hookPaths can't grow `webhookPayloads.size`
|
|
247
|
+
* without bound. 1000 is generous for any realistic operator deployment
|
|
248
|
+
* (5 payloads × 1000 recipes = ~5000 entries × ~10 KB each = 50 MB).
|
|
249
|
+
* On overflow, evict the oldest *recipe* (Map iteration order is
|
|
250
|
+
* insertion order in JS), not the largest list.
|
|
251
|
+
*/
|
|
252
|
+
static MAX_WEBHOOK_RECIPES = 1000;
|
|
253
|
+
/**
|
|
254
|
+
* Per-IP rate limit on the unauthenticated phone-path approval endpoints
|
|
255
|
+
* (`POST /approve/:callId` and `POST /reject/:callId` when
|
|
256
|
+
* `x-approval-token` is present). The auth gate intentionally bypasses
|
|
257
|
+
* bearer auth for those paths so a phone can dispatch without a bridge
|
|
258
|
+
* token; without rate limiting, an attacker who learns a callId (via
|
|
259
|
+
* webhook target leak, bearer-authed `/approvals` reader, etc.) can
|
|
260
|
+
* spray garbage tokens to DoS the legitimate approver. PR #380 bumped
|
|
261
|
+
* the per-callId failure cap to 1000 (memory bound, not security
|
|
262
|
+
* bound); this is the HTTP-layer spray defense flagged as the proper
|
|
263
|
+
* fix in that commit.
|
|
264
|
+
*
|
|
265
|
+
* 60 attempts per IP per minute is generous for legitimate retries
|
|
266
|
+
* (phone re-tap, network flake) and tight enough to bound brute-force
|
|
267
|
+
* attempts during the 5-minute approval TTL to 300 — well within the
|
|
268
|
+
* per-callId cap, so no legit retry budget is consumed by sprayers.
|
|
269
|
+
*/
|
|
270
|
+
approvalIpCounts = new Map();
|
|
271
|
+
static APPROVAL_IP_MAX = 60;
|
|
272
|
+
static APPROVAL_IP_WINDOW_MS = 60_000;
|
|
157
273
|
/** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
|
|
158
274
|
httpMcpHandler = null;
|
|
159
275
|
/** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
|
|
@@ -177,6 +293,22 @@ export class Server extends EventEmitter {
|
|
|
177
293
|
/** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
|
|
178
294
|
launchQuickTaskFn = null;
|
|
179
295
|
setRecipeEnabledFn = null;
|
|
296
|
+
/** Set by bridge to check if restart is safe (no in-flight tool calls). */
|
|
297
|
+
restartCheckFn = null;
|
|
298
|
+
/**
|
|
299
|
+
* Called when /restart decides it is safe to shut down. Defaults to
|
|
300
|
+
* `process.kill(process.pid, 'SIGTERM')`. Override in tests to a no-op so
|
|
301
|
+
* the Vitest runner process is not actually killed.
|
|
302
|
+
*/
|
|
303
|
+
restartKillFn = () => process.kill(process.pid, "SIGTERM");
|
|
304
|
+
/**
|
|
305
|
+
* Called when /shutdown decides it is safe to exit. Defaults to
|
|
306
|
+
* `process.kill(process.pid, 'SIGTERM')`. Bridge overrides this to run its
|
|
307
|
+
* internal shutdown sequence directly — necessary on Windows where
|
|
308
|
+
* `process.kill(pid, 'SIGTERM')` is TerminateProcess and cleanup handlers
|
|
309
|
+
* never fire.
|
|
310
|
+
*/
|
|
311
|
+
shutdownFn = () => process.kill(process.pid, "SIGTERM");
|
|
180
312
|
/**
|
|
181
313
|
* Attach an OAuth 2.0 Authorization Server.
|
|
182
314
|
* When set, the bridge exposes:
|
|
@@ -193,12 +325,19 @@ export class Server extends EventEmitter {
|
|
|
193
325
|
}
|
|
194
326
|
/** Hosts accepted in the WebSocket upgrade Host header (DNS-rebinding guard). */
|
|
195
327
|
allowedHosts;
|
|
196
|
-
constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000
|
|
328
|
+
constructor(authToken, logger, extraCorsOrigins = [], pingIntervalMs = 30_000,
|
|
329
|
+
// Reverse-proxy hops whose X-Forwarded-For values we trust. Empty by
|
|
330
|
+
// default — the per-IP rate limiter buckets on the direct socket peer.
|
|
331
|
+
// Behind nginx/Caddy/Cloudflare set this to the proxy's IP so distinct
|
|
332
|
+
// real clients get distinct buckets; otherwise every request looks
|
|
333
|
+
// like 127.0.0.1 and a single sprayer DoSes the legit approver.
|
|
334
|
+
trustedProxies = []) {
|
|
197
335
|
super();
|
|
198
336
|
this.authToken = authToken;
|
|
199
337
|
this.logger = logger;
|
|
200
338
|
this.extraCorsOrigins = extraCorsOrigins;
|
|
201
339
|
this.pingIntervalMs = pingIntervalMs;
|
|
340
|
+
this.trustedProxies = trustedProxies;
|
|
202
341
|
// Defense-in-depth: ensure token is non-empty so timingSafeTokenCompare
|
|
203
342
|
// cannot accept a blank Authorization header against an empty token.
|
|
204
343
|
if (authToken.length === 0) {
|
|
@@ -347,10 +486,7 @@ export class Server extends EventEmitter {
|
|
|
347
486
|
res.end(JSON.stringify(body, null, 2));
|
|
348
487
|
}
|
|
349
488
|
catch (err) {
|
|
350
|
-
res
|
|
351
|
-
res.end(JSON.stringify({
|
|
352
|
-
error: err instanceof Error ? err.message : String(err),
|
|
353
|
-
}));
|
|
489
|
+
respond500(res, err);
|
|
354
490
|
}
|
|
355
491
|
return;
|
|
356
492
|
}
|
|
@@ -371,13 +507,69 @@ export class Server extends EventEmitter {
|
|
|
371
507
|
const oauthResolved = !isStaticToken && this.oauthServer
|
|
372
508
|
? this.oauthServer.resolveBearerToken(bearer)
|
|
373
509
|
: null;
|
|
374
|
-
// Phone-path: approve/reject with x-approval-token bypass bearer check.
|
|
510
|
+
// Phone-path: approve/reject with x-approval-token header or ?token= query param bypass bearer check.
|
|
375
511
|
// The token itself is validated inside routeApprovalRequest via queue.validateToken.
|
|
376
512
|
const isPhoneApprovalPath = req.method === "POST" &&
|
|
377
513
|
/^\/(approve|reject)\/[A-Za-z0-9-]+$/.test(parsedUrl.pathname) &&
|
|
378
|
-
!!req.headers["x-approval-token"];
|
|
514
|
+
!!(req.headers["x-approval-token"] || parsedUrl.searchParams.get("token"));
|
|
515
|
+
// GitHub-style webhook bypass: when --webhook-secret is configured,
|
|
516
|
+
// POST /hooks/* requests carrying X-Hub-Signature-256 bypass the
|
|
517
|
+
// bearer-token gate. Signature itself is verified inside the
|
|
518
|
+
// /hooks/* handler after the body has been read.
|
|
519
|
+
const isHmacWebhookCandidate = req.method === "POST" &&
|
|
520
|
+
parsedUrl.pathname.startsWith("/hooks/") &&
|
|
521
|
+
!!req.headers["x-hub-signature-256"] &&
|
|
522
|
+
this.webhookSecret !== null;
|
|
523
|
+
// Rate-limit the phone bypass surface. Only applies when this is
|
|
524
|
+
// actually a phone-path request that's relying on the bypass — a
|
|
525
|
+
// properly-authenticated bearer caller is unaffected. Counted +
|
|
526
|
+
// checked *before* dispatch so a sprayer can't burn the per-callId
|
|
527
|
+
// failure budget at line-rate.
|
|
528
|
+
if (isPhoneApprovalPath && !isStaticToken && !oauthResolved) {
|
|
529
|
+
const remoteIp = this.getClientIp(req);
|
|
530
|
+
if (!remoteIp) {
|
|
531
|
+
// Fail closed — without an attributable IP we cannot bucket
|
|
532
|
+
// safely, and the original "unknown" string was a single shared
|
|
533
|
+
// counter every IP-less request rolled up into.
|
|
534
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
535
|
+
res.end(JSON.stringify({
|
|
536
|
+
error: "bad_request",
|
|
537
|
+
error_description: "could not determine client IP",
|
|
538
|
+
}));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
const entry = this.approvalIpCounts.get(remoteIp);
|
|
543
|
+
if (entry && now - entry.windowStart < Server.APPROVAL_IP_WINDOW_MS) {
|
|
544
|
+
entry.count++;
|
|
545
|
+
if (entry.count > Server.APPROVAL_IP_MAX) {
|
|
546
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
547
|
+
res.end(JSON.stringify({
|
|
548
|
+
error: "too_many_requests",
|
|
549
|
+
error_description: "per-IP approval endpoint rate limit reached",
|
|
550
|
+
}));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this.approvalIpCounts.set(remoteIp, { count: 1, windowStart: now });
|
|
556
|
+
}
|
|
557
|
+
// GC stale entries opportunistically — bounded growth alongside
|
|
558
|
+
// the same Map. 200 is well above any legitimate concurrent IP
|
|
559
|
+
// count; well below memory pressure.
|
|
560
|
+
if (this.approvalIpCounts.size > 200) {
|
|
561
|
+
for (const [k, v] of this.approvalIpCounts) {
|
|
562
|
+
if (now - v.windowStart > Server.APPROVAL_IP_WINDOW_MS) {
|
|
563
|
+
this.approvalIpCounts.delete(k);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
379
568
|
// oauthResolved is the bridge token if the OAuth token is valid; null otherwise
|
|
380
|
-
if (!isStaticToken &&
|
|
569
|
+
if (!isStaticToken &&
|
|
570
|
+
!oauthResolved &&
|
|
571
|
+
!isPhoneApprovalPath &&
|
|
572
|
+
!isHmacWebhookCandidate) {
|
|
381
573
|
// RFC 6750: only include error= when a token was actually presented but invalid
|
|
382
574
|
const tokenPresented = bearer.length > 0;
|
|
383
575
|
const wwwAuth = this.oauthServer && this.oauthIssuerUrl
|
|
@@ -399,10 +591,7 @@ export class Server extends EventEmitter {
|
|
|
399
591
|
res.end(body);
|
|
400
592
|
}
|
|
401
593
|
catch (err) {
|
|
402
|
-
res
|
|
403
|
-
res.end(JSON.stringify({
|
|
404
|
-
error: err instanceof Error ? err.message : String(err),
|
|
405
|
-
}));
|
|
594
|
+
respond500(res, err);
|
|
406
595
|
}
|
|
407
596
|
return;
|
|
408
597
|
}
|
|
@@ -418,10 +607,7 @@ export class Server extends EventEmitter {
|
|
|
418
607
|
res.end(JSON.stringify(data));
|
|
419
608
|
}
|
|
420
609
|
catch (err) {
|
|
421
|
-
res
|
|
422
|
-
res.end(JSON.stringify({
|
|
423
|
-
error: err instanceof Error ? err.message : String(err),
|
|
424
|
-
}));
|
|
610
|
+
respond500(res, err);
|
|
425
611
|
}
|
|
426
612
|
return;
|
|
427
613
|
}
|
|
@@ -436,10 +622,7 @@ export class Server extends EventEmitter {
|
|
|
436
622
|
res.end(JSON.stringify({ events: data, count: data.length }));
|
|
437
623
|
}
|
|
438
624
|
catch (err) {
|
|
439
|
-
res
|
|
440
|
-
res.end(JSON.stringify({
|
|
441
|
-
error: err instanceof Error ? err.message : String(err),
|
|
442
|
-
}));
|
|
625
|
+
respond500(res, err);
|
|
443
626
|
}
|
|
444
627
|
return;
|
|
445
628
|
}
|
|
@@ -475,10 +658,7 @@ export class Server extends EventEmitter {
|
|
|
475
658
|
res.end(JSON.stringify(data));
|
|
476
659
|
}
|
|
477
660
|
catch (err) {
|
|
478
|
-
res
|
|
479
|
-
res.end(JSON.stringify({
|
|
480
|
-
error: err instanceof Error ? err.message : String(err),
|
|
481
|
-
}));
|
|
661
|
+
respond500(res, err);
|
|
482
662
|
}
|
|
483
663
|
return;
|
|
484
664
|
}
|
|
@@ -549,12 +729,7 @@ export class Server extends EventEmitter {
|
|
|
549
729
|
}
|
|
550
730
|
}
|
|
551
731
|
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
|
-
}
|
|
732
|
+
respond500(res, err);
|
|
558
733
|
}
|
|
559
734
|
})();
|
|
560
735
|
return;
|
|
@@ -577,10 +752,7 @@ export class Server extends EventEmitter {
|
|
|
577
752
|
res.end(JSON.stringify(data));
|
|
578
753
|
}
|
|
579
754
|
catch (err) {
|
|
580
|
-
res
|
|
581
|
-
res.end(JSON.stringify({
|
|
582
|
-
error: err instanceof Error ? err.message : String(err),
|
|
583
|
-
}));
|
|
755
|
+
respond500(res, err);
|
|
584
756
|
}
|
|
585
757
|
return;
|
|
586
758
|
}
|
|
@@ -594,10 +766,7 @@ export class Server extends EventEmitter {
|
|
|
594
766
|
res.end(JSON.stringify(data));
|
|
595
767
|
}
|
|
596
768
|
catch (err) {
|
|
597
|
-
res
|
|
598
|
-
res.end(JSON.stringify({
|
|
599
|
-
error: err instanceof Error ? err.message : String(err),
|
|
600
|
-
}));
|
|
769
|
+
respond500(res, err);
|
|
601
770
|
}
|
|
602
771
|
return;
|
|
603
772
|
}
|
|
@@ -625,18 +794,23 @@ export class Server extends EventEmitter {
|
|
|
625
794
|
}
|
|
626
795
|
}
|
|
627
796
|
catch (err) {
|
|
628
|
-
res
|
|
629
|
-
res.end(JSON.stringify({
|
|
630
|
-
error: err instanceof Error ? err.message : String(err),
|
|
631
|
-
}));
|
|
797
|
+
respond500(res, err);
|
|
632
798
|
}
|
|
633
799
|
return;
|
|
634
800
|
}
|
|
635
801
|
if (req.url === "/stream" && req.method === "GET") {
|
|
802
|
+
// Backstop (ADR-0005 pattern): at the cap, evict the oldest
|
|
803
|
+
// subscriber rather than returning 503 forever. A leaked
|
|
804
|
+
// subscriber whose socket is dead-but-not-erroring can otherwise
|
|
805
|
+
// permanently brick /stream. The dashboard proxy abort-on-disconnect
|
|
806
|
+
// fix is the real cure; this guarantees recovery either way.
|
|
807
|
+
// sseSubscriberCount and sseSubscribers.size stay in lockstep
|
|
808
|
+
// (both mutated together below + in cleanup()), so when the
|
|
809
|
+
// counter is at the cap there is always an evictable subscriber —
|
|
810
|
+
// eviction always frees the slot.
|
|
636
811
|
if (this.sseSubscriberCount >= Server.MAX_SSE_SUBSCRIBERS) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return;
|
|
812
|
+
const oldest = this.sseSubscribers.values().next().value;
|
|
813
|
+
oldest?.();
|
|
640
814
|
}
|
|
641
815
|
this.sseSubscriberCount++;
|
|
642
816
|
// Disable socket timeout — SSE connections are long-lived by design
|
|
@@ -647,31 +821,58 @@ export class Server extends EventEmitter {
|
|
|
647
821
|
Connection: "keep-alive",
|
|
648
822
|
});
|
|
649
823
|
res.flushHeaders();
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
824
|
+
// Idempotent teardown — every disconnect path (write error,
|
|
825
|
+
// ping error, req.close) funnels through here. Without this the
|
|
826
|
+
// counter could be decremented twice for a single subscriber
|
|
827
|
+
// (req.close fires AFTER write error in some Node versions) or
|
|
828
|
+
// not at all (write error never escalates to req.close on
|
|
829
|
+
// certain proxy intermediaries). Audit 2026-05-17 (#605
|
|
830
|
+
// BLOCKER): observed in production — subscribers hit cap of 20
|
|
831
|
+
// after enough page reloads, every new SSE returns 503 and
|
|
832
|
+
// kill-switch / activity ticker silently die.
|
|
833
|
+
let cleanedUp = false;
|
|
834
|
+
let pingHandle = null;
|
|
835
|
+
let unsub = () => { };
|
|
836
|
+
// Single teardown closure, also stored directly in the registry
|
|
837
|
+
// as this subscriber's eviction handler — it tears down state AND
|
|
838
|
+
// destroys the socket so the peer (and any orphaned proxy in
|
|
839
|
+
// between) observes the close. It removes this exact function
|
|
840
|
+
// from the Set, so no placeholder/no-op function is ever created.
|
|
841
|
+
const cleanup = () => {
|
|
842
|
+
if (cleanedUp)
|
|
843
|
+
return;
|
|
844
|
+
cleanedUp = true;
|
|
845
|
+
this.sseSubscriberCount--;
|
|
846
|
+
this.sseSubscribers.delete(cleanup);
|
|
847
|
+
if (pingHandle)
|
|
848
|
+
clearInterval(pingHandle);
|
|
849
|
+
unsub();
|
|
850
|
+
res.end();
|
|
851
|
+
res.socket?.destroy();
|
|
852
|
+
};
|
|
853
|
+
this.sseSubscribers.add(cleanup);
|
|
854
|
+
unsub =
|
|
855
|
+
this.streamFn?.((kind, entry) => {
|
|
856
|
+
try {
|
|
857
|
+
res.write(`data: ${JSON.stringify({ kind, ...entry })}\n\n`);
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
cleanup();
|
|
861
|
+
}
|
|
862
|
+
}) ?? (() => { });
|
|
659
863
|
// Keep-alive comment ping every 15s so proxies don't close idle connections
|
|
660
|
-
|
|
864
|
+
pingHandle = setInterval(() => {
|
|
661
865
|
try {
|
|
662
866
|
res.write(": ping\n\n");
|
|
663
867
|
}
|
|
664
868
|
catch {
|
|
665
|
-
|
|
666
|
-
unsub();
|
|
869
|
+
cleanup();
|
|
667
870
|
}
|
|
668
871
|
}, 15_000);
|
|
669
|
-
|
|
670
|
-
req.on("close",
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
unsub();
|
|
674
|
-
});
|
|
872
|
+
pingHandle.unref();
|
|
873
|
+
req.on("close", cleanup);
|
|
874
|
+
req.on("error", cleanup);
|
|
875
|
+
res.on("error", cleanup);
|
|
675
876
|
return;
|
|
676
877
|
}
|
|
677
878
|
if (req.url === "/tasks" && req.method === "GET") {
|
|
@@ -681,10 +882,7 @@ export class Server extends EventEmitter {
|
|
|
681
882
|
res.end(JSON.stringify(data));
|
|
682
883
|
}
|
|
683
884
|
catch (err) {
|
|
684
|
-
res
|
|
685
|
-
res.end(JSON.stringify({
|
|
686
|
-
error: err instanceof Error ? err.message : String(err),
|
|
687
|
-
}));
|
|
885
|
+
respond500(res, err);
|
|
688
886
|
}
|
|
689
887
|
return;
|
|
690
888
|
}
|
|
@@ -703,67 +901,104 @@ export class Server extends EventEmitter {
|
|
|
703
901
|
}
|
|
704
902
|
}
|
|
705
903
|
catch (err) {
|
|
706
|
-
res
|
|
707
|
-
res.end(JSON.stringify({
|
|
708
|
-
error: err instanceof Error ? err.message : String(err),
|
|
709
|
-
}));
|
|
904
|
+
respond500(res, err);
|
|
710
905
|
}
|
|
711
906
|
return;
|
|
712
907
|
}
|
|
713
908
|
if (parsedUrl.pathname?.startsWith("/hooks/") && req.method === "POST") {
|
|
714
909
|
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
|
-
|
|
910
|
+
// 256 KB — webhook payloads from GitHub/Linear/etc. are typically
|
|
911
|
+
// 1–25 KB; 256 KB matches RECIPE_ROUTE_BODY_CAPS.content as a
|
|
912
|
+
// generous ceiling that still bounds an authenticated DoS vector.
|
|
913
|
+
const HOOKS_BODY_CAP = 256 * 1024;
|
|
914
|
+
const read = await readBodyWithCap(req, HOOKS_BODY_CAP);
|
|
915
|
+
if (!read.ok) {
|
|
916
|
+
respond413(res, HOOKS_BODY_CAP);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// HMAC-SHA256 verification for GitHub-style webhooks. Signature is
|
|
920
|
+
// computed over the raw on-the-wire bytes (read.bytes), not the
|
|
921
|
+
// utf-8-decoded string — non-UTF-8 or denormalized payloads must
|
|
922
|
+
// round-trip identically to validate.
|
|
923
|
+
const sigHeader = req.headers["x-hub-signature-256"];
|
|
924
|
+
if (typeof sigHeader === "string" && sigHeader.length > 0) {
|
|
925
|
+
if (!this.webhookSecret) {
|
|
926
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
927
|
+
res.end(JSON.stringify({ error: "webhook_secret_not_configured" }));
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const expected = "sha256=" +
|
|
931
|
+
createHmac("sha256", this.webhookSecret)
|
|
932
|
+
.update(read.bytes)
|
|
933
|
+
.digest("hex");
|
|
934
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
935
|
+
const providedBuf = Buffer.from(sigHeader, "utf-8");
|
|
936
|
+
// timingSafeEqual throws on length mismatch — length-check first
|
|
937
|
+
// so the constant-time path is only taken on equal-length inputs.
|
|
938
|
+
const sigOk = expectedBuf.length === providedBuf.length &&
|
|
939
|
+
timingSafeEqual(expectedBuf, providedBuf);
|
|
940
|
+
if (!sigOk) {
|
|
941
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
942
|
+
res.end(JSON.stringify({ error: "invalid_signature" }));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
let payload;
|
|
947
|
+
if (read.body.trim()) {
|
|
948
|
+
try {
|
|
949
|
+
payload = JSON.parse(read.body);
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
payload = read.body;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (!this.webhookFn) {
|
|
956
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
957
|
+
res.end(JSON.stringify({
|
|
958
|
+
ok: false,
|
|
959
|
+
error: "Webhooks unavailable — start bridge with --driver subprocess",
|
|
960
|
+
}));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const result = await this.webhookFn(hookPath, payload);
|
|
964
|
+
const status = result.ok
|
|
965
|
+
? 200
|
|
966
|
+
: result.error === "not_found"
|
|
967
|
+
? 404
|
|
968
|
+
: 400;
|
|
969
|
+
// Record in ring buffer so the dashboard can show "last
|
|
970
|
+
// payload" per recipe. Skip not_found so unknown paths don't
|
|
971
|
+
// pollute the buffer with garbage / scanner traffic.
|
|
972
|
+
if (result.error !== "not_found") {
|
|
973
|
+
const existing = this.webhookPayloads.get(hookPath) ?? [];
|
|
974
|
+
existing.unshift({
|
|
975
|
+
receivedAt: Date.now(),
|
|
976
|
+
payload,
|
|
977
|
+
ok: result.ok,
|
|
978
|
+
error: result.error,
|
|
979
|
+
taskId: result.taskId,
|
|
980
|
+
recipeName: result.name,
|
|
981
|
+
});
|
|
982
|
+
if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
|
|
983
|
+
existing.length = Server.MAX_WEBHOOK_PAYLOADS;
|
|
984
|
+
}
|
|
985
|
+
// LRU eviction: Map.set() on an existing key keeps original
|
|
986
|
+
// insertion-order position (per spec), so a hot recipe registered
|
|
987
|
+
// at startup would otherwise be evicted in favor of a cold
|
|
988
|
+
// scanner-spam recipe just because it was registered earlier.
|
|
989
|
+
// Delete + re-set re-anchors the key to the END of insertion
|
|
990
|
+
// order, making `.keys().next()` correctly point at the
|
|
991
|
+
// least-recently-fired recipe.
|
|
992
|
+
this.webhookPayloads.delete(hookPath);
|
|
993
|
+
if (this.webhookPayloads.size >= Server.MAX_WEBHOOK_RECIPES) {
|
|
994
|
+
const oldest = this.webhookPayloads.keys().next().value;
|
|
995
|
+
if (oldest !== undefined)
|
|
996
|
+
this.webhookPayloads.delete(oldest);
|
|
997
|
+
}
|
|
998
|
+
this.webhookPayloads.set(hookPath, existing);
|
|
999
|
+
}
|
|
1000
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1001
|
+
res.end(JSON.stringify(result));
|
|
767
1002
|
return;
|
|
768
1003
|
}
|
|
769
1004
|
if (parsedUrl.pathname?.startsWith("/webhook-payloads/") &&
|
|
@@ -912,10 +1147,12 @@ export class Server extends EventEmitter {
|
|
|
912
1147
|
if (tryHandleRecipeRoute(req, res, parsedUrl, {
|
|
913
1148
|
setRecipeTrustFn: this.setRecipeTrustFn,
|
|
914
1149
|
generateRecipeFn: this.generateRecipeFn,
|
|
1150
|
+
repairRecipeFn: this.repairRecipeFn,
|
|
915
1151
|
recipesFn: this.recipesFn,
|
|
916
1152
|
loadRecipeContentFn: this.loadRecipeContentFn,
|
|
917
1153
|
saveRecipeContentFn: this.saveRecipeContentFn,
|
|
918
1154
|
deleteRecipeContentFn: this.deleteRecipeContentFn,
|
|
1155
|
+
archiveRecipeFn: this.archiveRecipeFn,
|
|
919
1156
|
duplicateRecipeFn: this.duplicateRecipeFn,
|
|
920
1157
|
promoteRecipeVariantFn: this.promoteRecipeVariantFn,
|
|
921
1158
|
lintRecipeContentFn: this.lintRecipeContentFn,
|
|
@@ -923,9 +1160,12 @@ export class Server extends EventEmitter {
|
|
|
923
1160
|
setRecipeEnabledFn: this.setRecipeEnabledFn,
|
|
924
1161
|
runsFn: this.runsFn,
|
|
925
1162
|
runDetailFn: this.runDetailFn,
|
|
1163
|
+
haltSummaryFn: this.haltSummaryFn,
|
|
1164
|
+
judgeSummaryFn: this.judgeSummaryFn,
|
|
926
1165
|
runPlanFn: this.runPlanFn,
|
|
927
1166
|
runReplayFn: this.runReplayFn,
|
|
928
1167
|
runRecipeFn: this.runRecipeFn,
|
|
1168
|
+
onRecipesChangedFn: this.onRecipesChangedFn,
|
|
929
1169
|
})) {
|
|
930
1170
|
return;
|
|
931
1171
|
}
|
|
@@ -943,10 +1183,7 @@ export class Server extends EventEmitter {
|
|
|
943
1183
|
res.end(JSON.stringify(data));
|
|
944
1184
|
}
|
|
945
1185
|
catch (err) {
|
|
946
|
-
res
|
|
947
|
-
res.end(JSON.stringify({
|
|
948
|
-
error: err instanceof Error ? err.message : String(err),
|
|
949
|
-
}));
|
|
1186
|
+
respond500(res, err);
|
|
950
1187
|
}
|
|
951
1188
|
return;
|
|
952
1189
|
}
|
|
@@ -962,68 +1199,96 @@ export class Server extends EventEmitter {
|
|
|
962
1199
|
res.end(JSON.stringify(data));
|
|
963
1200
|
}
|
|
964
1201
|
catch (err) {
|
|
965
|
-
res
|
|
966
|
-
res.end(JSON.stringify({
|
|
967
|
-
error: err instanceof Error ? err.message : String(err),
|
|
968
|
-
}));
|
|
1202
|
+
respond500(res, err);
|
|
969
1203
|
}
|
|
970
1204
|
return;
|
|
971
1205
|
}
|
|
972
1206
|
if (parsedUrl.pathname === "/settings" && req.method === "POST") {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1207
|
+
// Kill-switch gate: during an incident a settings mutation can
|
|
1208
|
+
// defeat the panic posture (e.g. switching driver to one with a
|
|
1209
|
+
// leaked API key, flipping approvalGate to "off"). The /settings
|
|
1210
|
+
// card on the dashboard sits directly above the kill-switch
|
|
1211
|
+
// toggle — users will reasonably read "writes blocked" as
|
|
1212
|
+
// covering both. Refuse with 423 Locked; the /kill-switch
|
|
1213
|
+
// endpoint itself is the only way out and is not gated.
|
|
1214
|
+
if (isWriteKillSwitchActive()) {
|
|
1215
|
+
res.writeHead(423, { "Content-Type": "application/json" });
|
|
1216
|
+
res.end(JSON.stringify({
|
|
1217
|
+
error: "kill_switch_blocked",
|
|
1218
|
+
reason: "Settings writes are disabled while the write kill-switch is engaged. Release it via POST /kill-switch to mutate config.",
|
|
1219
|
+
}));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
// 16 KB — settings POSTs are short-string fields (URLs, API keys,
|
|
1223
|
+
// gate level). 16 KB is generous; an authenticated attacker can't
|
|
1224
|
+
// stream gigabytes here.
|
|
1225
|
+
const SETTINGS_BODY_CAP = 16 * 1024;
|
|
1226
|
+
const parsed = await readJsonBody(req, SETTINGS_BODY_CAP);
|
|
1227
|
+
if (!parsed.ok) {
|
|
1228
|
+
if (parsed.code === "too_large") {
|
|
1229
|
+
respond413(res, SETTINGS_BODY_CAP);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1233
|
+
res.end(JSON.stringify({ error: "Invalid request body" }));
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
{
|
|
1238
|
+
const body = parsed.value ?? {};
|
|
1239
|
+
if (respondIfUnknownBodyKeys(res, body, [
|
|
1240
|
+
"webhookUrl",
|
|
1241
|
+
"approvalGate",
|
|
1242
|
+
"enableTimeOfDayAnomaly",
|
|
1243
|
+
"driver",
|
|
1244
|
+
"model",
|
|
1245
|
+
"localEndpoint",
|
|
1246
|
+
"localModel",
|
|
1247
|
+
"apiKey",
|
|
1248
|
+
"pushServiceUrl",
|
|
1249
|
+
"pushServiceToken",
|
|
1250
|
+
"pushServiceBaseUrl",
|
|
1251
|
+
"ntfyTopic",
|
|
1252
|
+
"ntfyServer",
|
|
1253
|
+
])) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
// PHASE 1 — validate ALL fields up front. No disk writes, no
|
|
1257
|
+
// secure-store writes, no live-state mutations until every input
|
|
1258
|
+
// has passed. Prevents the "valid driver + invalid ntfyTopic"
|
|
1259
|
+
// class of bug where a 400 still leaves a partial side-effect on
|
|
1260
|
+
// disk.
|
|
1261
|
+
const respond400 = (error) => {
|
|
1262
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1263
|
+
res.end(JSON.stringify({ error }));
|
|
1264
|
+
};
|
|
1265
|
+
// webhookUrl
|
|
978
1266
|
const hasWebhookUpdate = body.webhookUrl !== undefined;
|
|
979
|
-
const
|
|
1267
|
+
const webhookRaw = hasWebhookUpdate
|
|
980
1268
|
? (body.webhookUrl?.trim() ?? "")
|
|
981
1269
|
: undefined;
|
|
982
|
-
if (
|
|
983
|
-
|
|
984
|
-
|
|
1270
|
+
if (webhookRaw !== undefined &&
|
|
1271
|
+
webhookRaw !== "" &&
|
|
1272
|
+
!/^https:\/\/.+/.test(webhookRaw)) {
|
|
1273
|
+
respond400("webhookUrl must be HTTPS");
|
|
985
1274
|
return;
|
|
986
1275
|
}
|
|
1276
|
+
// approvalGate
|
|
987
1277
|
const gateRaw = body.approvalGate;
|
|
988
1278
|
if (gateRaw !== undefined &&
|
|
989
1279
|
gateRaw !== "off" &&
|
|
990
1280
|
gateRaw !== "high" &&
|
|
991
1281
|
gateRaw !== "all") {
|
|
992
|
-
|
|
993
|
-
res.end(JSON.stringify({
|
|
994
|
-
error: 'approvalGate must be "off", "high", or "all"',
|
|
995
|
-
}));
|
|
1282
|
+
respond400('approvalGate must be "off", "high", or "all"');
|
|
996
1283
|
return;
|
|
997
1284
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
pushNotifications: cfg.dashboard?.pushNotifications ?? false,
|
|
1004
|
-
webhookUrl: hasWebhookUpdate
|
|
1005
|
-
? raw || undefined
|
|
1006
|
-
: cfg.dashboard?.webhookUrl,
|
|
1007
|
-
};
|
|
1008
|
-
if (gateRaw !== undefined) {
|
|
1009
|
-
cfg.approvalGate = gateRaw;
|
|
1010
|
-
this.approvalGate = gateRaw;
|
|
1011
|
-
}
|
|
1012
|
-
// h10 toggle: must be boolean if present. Persists to
|
|
1013
|
-
// ~/.patchwork/config.json AND live-mutates the Server
|
|
1014
|
-
// field so the next /approvals POST honors it without
|
|
1015
|
-
// needing a bridge restart.
|
|
1016
|
-
if (body.enableTimeOfDayAnomaly !== undefined) {
|
|
1017
|
-
if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
|
|
1018
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1019
|
-
res.end(JSON.stringify({
|
|
1020
|
-
error: "enableTimeOfDayAnomaly must be a boolean",
|
|
1021
|
-
}));
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1025
|
-
this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1285
|
+
// enableTimeOfDayAnomaly
|
|
1286
|
+
if (body.enableTimeOfDayAnomaly !== undefined &&
|
|
1287
|
+
typeof body.enableTimeOfDayAnomaly !== "boolean") {
|
|
1288
|
+
respond400("enableTimeOfDayAnomaly must be a boolean");
|
|
1289
|
+
return;
|
|
1026
1290
|
}
|
|
1291
|
+
// driver
|
|
1027
1292
|
const driverRaw = body.driver;
|
|
1028
1293
|
if (driverRaw !== undefined) {
|
|
1029
1294
|
const validDrivers = [
|
|
@@ -1032,19 +1297,16 @@ export class Server extends EventEmitter {
|
|
|
1032
1297
|
"openai",
|
|
1033
1298
|
"grok",
|
|
1034
1299
|
"gemini",
|
|
1300
|
+
"gemini-api",
|
|
1301
|
+
"local",
|
|
1035
1302
|
"none",
|
|
1036
1303
|
];
|
|
1037
1304
|
if (!validDrivers.includes(driverRaw)) {
|
|
1038
|
-
|
|
1039
|
-
res.end(JSON.stringify({
|
|
1040
|
-
error: `driver must be one of: ${validDrivers.join(", ")}`,
|
|
1041
|
-
}));
|
|
1305
|
+
respond400(`driver must be one of: ${validDrivers.join(", ")}`);
|
|
1042
1306
|
return;
|
|
1043
1307
|
}
|
|
1044
|
-
const driver = driverRaw;
|
|
1045
|
-
cfg.driver = driver;
|
|
1046
|
-
saveBridgeConfigDriver(driver, this.bridgeConfigPath);
|
|
1047
1308
|
}
|
|
1309
|
+
// model
|
|
1048
1310
|
if (body.model !== undefined) {
|
|
1049
1311
|
const validModels = [
|
|
1050
1312
|
"claude",
|
|
@@ -1054,95 +1316,782 @@ export class Server extends EventEmitter {
|
|
|
1054
1316
|
"local",
|
|
1055
1317
|
];
|
|
1056
1318
|
if (!validModels.includes(body.model)) {
|
|
1057
|
-
|
|
1058
|
-
res.end(JSON.stringify({
|
|
1059
|
-
error: `model must be one of: ${validModels.join(", ")}`,
|
|
1060
|
-
}));
|
|
1319
|
+
respond400(`model must be one of: ${validModels.join(", ")}`);
|
|
1061
1320
|
return;
|
|
1062
1321
|
}
|
|
1063
|
-
cfg.model = body.model;
|
|
1064
|
-
if (body.model === "local") {
|
|
1065
|
-
if (body.localEndpoint !== undefined)
|
|
1066
|
-
cfg.localEndpoint = body.localEndpoint.trim() || undefined;
|
|
1067
|
-
if (body.localModel !== undefined)
|
|
1068
|
-
cfg.localModel = body.localModel.trim() || undefined;
|
|
1069
|
-
}
|
|
1070
1322
|
}
|
|
1323
|
+
// apiKey
|
|
1071
1324
|
if (body.apiKey) {
|
|
1072
1325
|
const { provider, key } = body.apiKey;
|
|
1073
1326
|
const validProviders = ["anthropic", "openai", "google", "xai"];
|
|
1074
1327
|
if (!validProviders.includes(provider) ||
|
|
1075
1328
|
typeof key !== "string") {
|
|
1076
|
-
|
|
1077
|
-
res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
|
|
1329
|
+
respond400("Invalid apiKey provider or key");
|
|
1078
1330
|
return;
|
|
1079
1331
|
}
|
|
1080
|
-
cfg.apiKeys = { ...cfg.apiKeys, [provider]: key || undefined };
|
|
1081
1332
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1333
|
+
// pushServiceUrl
|
|
1334
|
+
const pushUrlTrimmed = body.pushServiceUrl !== undefined
|
|
1335
|
+
? body.pushServiceUrl.trim()
|
|
1336
|
+
: undefined;
|
|
1337
|
+
if (pushUrlTrimmed !== undefined &&
|
|
1338
|
+
pushUrlTrimmed !== "" &&
|
|
1339
|
+
!pushUrlTrimmed.startsWith("https://")) {
|
|
1340
|
+
respond400("pushServiceUrl must be HTTPS");
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
// pushServiceToken — bearer for the push relay. Charset cap to
|
|
1344
|
+
// printable ASCII (no newlines / control chars) so it can't
|
|
1345
|
+
// header-inject when later interpolated into an outgoing
|
|
1346
|
+
// `Authorization` header. 512 chars is an order of magnitude
|
|
1347
|
+
// above any realistic relay token.
|
|
1348
|
+
const pushTokenTrimmed = body.pushServiceToken !== undefined
|
|
1349
|
+
? body.pushServiceToken.trim()
|
|
1350
|
+
: undefined;
|
|
1351
|
+
if (pushTokenTrimmed !== undefined &&
|
|
1352
|
+
pushTokenTrimmed !== "" &&
|
|
1353
|
+
!/^[\x21-\x7E]{1,512}$/.test(pushTokenTrimmed)) {
|
|
1354
|
+
respond400("pushServiceToken must be 1-512 printable ASCII characters");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
// pushServiceBaseUrl — bridge callback origin embedded in SW
|
|
1358
|
+
// approveUrl/rejectUrl. http:// or attacker host = approval token
|
|
1359
|
+
// exfiltration. Validate before persisting.
|
|
1360
|
+
const pushBaseTrimmed = body.pushServiceBaseUrl !== undefined
|
|
1361
|
+
? body.pushServiceBaseUrl.trim()
|
|
1362
|
+
: undefined;
|
|
1363
|
+
if (pushBaseTrimmed !== undefined &&
|
|
1364
|
+
pushBaseTrimmed !== "" &&
|
|
1365
|
+
!pushBaseTrimmed.startsWith("https://")) {
|
|
1366
|
+
respond400("pushServiceBaseUrl must be HTTPS");
|
|
1367
|
+
return;
|
|
1085
1368
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1369
|
+
// ntfyTopic — bearer-token on public ntfy.sh; charset-restricted.
|
|
1370
|
+
const ntfyTopicTrimmed = body.ntfyTopic !== undefined ? body.ntfyTopic.trim() : undefined;
|
|
1371
|
+
if (ntfyTopicTrimmed !== undefined &&
|
|
1372
|
+
ntfyTopicTrimmed !== "" &&
|
|
1373
|
+
!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopicTrimmed)) {
|
|
1374
|
+
respond400("ntfyTopic must match [A-Za-z0-9_-]{1,64}");
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
// ntfyServer — same threat model as pushServiceBaseUrl.
|
|
1378
|
+
const ntfyServerTrimmed = body.ntfyServer !== undefined
|
|
1379
|
+
? body.ntfyServer.trim()
|
|
1380
|
+
: undefined;
|
|
1381
|
+
if (ntfyServerTrimmed !== undefined &&
|
|
1382
|
+
ntfyServerTrimmed !== "" &&
|
|
1383
|
+
!ntfyServerTrimmed.startsWith("https://")) {
|
|
1384
|
+
respond400("ntfyServer must be HTTPS");
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
// localEndpoint — used by LocalApiDriver as the inference base
|
|
1388
|
+
// URL. Must parse as http(s)://, be ≤2048 chars, and resolve to
|
|
1389
|
+
// a loopback or private address. Otherwise prompts + context
|
|
1390
|
+
// would stream to a public / metadata / file:// host. Same gate
|
|
1391
|
+
// the driver enforces at construction, raised to the HTTP
|
|
1392
|
+
// boundary so the bad value never reaches disk. Operator can
|
|
1393
|
+
// opt out via LOCAL_ENDPOINT_ALLOW_REMOTE=1 for audited
|
|
1394
|
+
// internal inference clusters.
|
|
1395
|
+
const localEndpointTrimmed = body.localEndpoint !== undefined
|
|
1396
|
+
? body.localEndpoint.trim()
|
|
1397
|
+
: undefined;
|
|
1398
|
+
if (localEndpointTrimmed !== undefined &&
|
|
1399
|
+
localEndpointTrimmed !== "") {
|
|
1400
|
+
if (localEndpointTrimmed.length > 2048) {
|
|
1401
|
+
respond400("localEndpoint must be ≤2048 characters");
|
|
1091
1402
|
return;
|
|
1092
1403
|
}
|
|
1093
|
-
|
|
1404
|
+
let parsed;
|
|
1405
|
+
try {
|
|
1406
|
+
parsed = new URL(localEndpointTrimmed);
|
|
1407
|
+
}
|
|
1408
|
+
catch {
|
|
1409
|
+
respond400("localEndpoint must be a valid http(s):// URL");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1413
|
+
respond400("localEndpoint must use http:// or https:// scheme");
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (process.env.LOCAL_ENDPOINT_ALLOW_REMOTE !== "1" &&
|
|
1417
|
+
!isLoopbackOrPrivateEndpoint(localEndpointTrimmed)) {
|
|
1418
|
+
respond400("localEndpoint must be loopback or private (set LOCAL_ENDPOINT_ALLOW_REMOTE=1 to override)");
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// PHASE 2 — load config and apply all in-memory edits to `cfg`.
|
|
1423
|
+
// Still no disk writes; if anything throws here the request
|
|
1424
|
+
// returns 500 with no side effects.
|
|
1425
|
+
const configPath = patchworkConfigPath();
|
|
1426
|
+
const cfg = loadPatchworkConfig(configPath);
|
|
1427
|
+
cfg.dashboard = {
|
|
1428
|
+
port: cfg.dashboard?.port ?? 3200,
|
|
1429
|
+
requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
|
|
1430
|
+
pushNotifications: cfg.dashboard?.pushNotifications ?? false,
|
|
1431
|
+
webhookUrl: hasWebhookUpdate
|
|
1432
|
+
? webhookRaw || undefined
|
|
1433
|
+
: cfg.dashboard?.webhookUrl,
|
|
1434
|
+
};
|
|
1435
|
+
if (gateRaw !== undefined) {
|
|
1436
|
+
cfg.approvalGate = gateRaw;
|
|
1437
|
+
}
|
|
1438
|
+
if (body.enableTimeOfDayAnomaly !== undefined) {
|
|
1439
|
+
cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1440
|
+
}
|
|
1441
|
+
if (driverRaw !== undefined) {
|
|
1442
|
+
cfg.driver = driverRaw;
|
|
1443
|
+
}
|
|
1444
|
+
if (body.model !== undefined) {
|
|
1445
|
+
cfg.model = body.model;
|
|
1446
|
+
}
|
|
1447
|
+
// localEndpoint / localModel persist regardless of whether
|
|
1448
|
+
// `model` is sent. Previously they were silently dropped when
|
|
1449
|
+
// the dashboard updated only the endpoint of an already-local
|
|
1450
|
+
// install, which left the running driver pointed at the old
|
|
1451
|
+
// host until the user happened to re-pick "Local LLM" in the
|
|
1452
|
+
// UI.
|
|
1453
|
+
if (body.localEndpoint !== undefined) {
|
|
1454
|
+
cfg.localEndpoint = body.localEndpoint.trim() || undefined;
|
|
1455
|
+
}
|
|
1456
|
+
if (body.localModel !== undefined) {
|
|
1457
|
+
cfg.localModel = body.localModel.trim() || undefined;
|
|
1458
|
+
}
|
|
1459
|
+
// Push / ntfy fields used to be set only on `this.*` and were
|
|
1460
|
+
// lost on bridge restart. Persist alongside the rest.
|
|
1461
|
+
if (pushUrlTrimmed !== undefined) {
|
|
1462
|
+
cfg.pushServiceUrl = pushUrlTrimmed || undefined;
|
|
1094
1463
|
}
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
1464
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1465
|
+
cfg.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1097
1466
|
}
|
|
1098
|
-
if (
|
|
1099
|
-
|
|
1100
|
-
|
|
1467
|
+
if (pushBaseTrimmed !== undefined) {
|
|
1468
|
+
cfg.pushServiceBaseUrl = pushBaseTrimmed || undefined;
|
|
1469
|
+
}
|
|
1470
|
+
if (ntfyTopicTrimmed !== undefined) {
|
|
1471
|
+
cfg.ntfyTopic = ntfyTopicTrimmed || undefined;
|
|
1472
|
+
}
|
|
1473
|
+
if (ntfyServerTrimmed !== undefined) {
|
|
1474
|
+
cfg.ntfyServer = ntfyServerTrimmed || undefined;
|
|
1475
|
+
}
|
|
1476
|
+
// PHASE 3 — disk + secure-store writes. Order: secure store →
|
|
1477
|
+
// bridge driver config → patchwork config. Each rolls back what
|
|
1478
|
+
// it can on later failure (left to PR #02; for now log + 500).
|
|
1479
|
+
if (body.apiKey) {
|
|
1480
|
+
try {
|
|
1481
|
+
saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
|
|
1482
|
+
}
|
|
1483
|
+
catch (writeErr) {
|
|
1484
|
+
// Some Keychain / Secret-Service backends echo the
|
|
1485
|
+
// payload into the error message on certain failure
|
|
1486
|
+
// modes. Cap the logged message at 200 chars AND
|
|
1487
|
+
// strip anything that looks like the leading hex/
|
|
1488
|
+
// base64 of the just-attempted key so a misbehaving
|
|
1489
|
+
// backend can't leak it into bridge logs.
|
|
1490
|
+
const raw = writeErr instanceof Error
|
|
1491
|
+
? writeErr.message
|
|
1492
|
+
: String(writeErr);
|
|
1493
|
+
const safe = raw
|
|
1494
|
+
.replaceAll(body.apiKey.key, "[redacted]")
|
|
1495
|
+
.slice(0, 200);
|
|
1496
|
+
this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${safe}`);
|
|
1497
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1498
|
+
res.end(JSON.stringify({ error: "Failed to write provider API key" }));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (driverRaw !== undefined) {
|
|
1503
|
+
try {
|
|
1504
|
+
saveBridgeConfigDriver(driverRaw, this.bridgeConfigPath);
|
|
1505
|
+
}
|
|
1506
|
+
catch (writeErr) {
|
|
1507
|
+
this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
|
|
1508
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1509
|
+
res.end(JSON.stringify({
|
|
1510
|
+
error: "Failed to write bridge driver config",
|
|
1511
|
+
}));
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1101
1514
|
}
|
|
1515
|
+
try {
|
|
1516
|
+
savePatchworkConfig(cfg, configPath);
|
|
1517
|
+
}
|
|
1518
|
+
catch (writeErr) {
|
|
1519
|
+
this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
|
|
1520
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1521
|
+
res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
// PHASE 4 — live state. Only after every persistence step
|
|
1525
|
+
// succeeded; ensures live state never diverges from disk.
|
|
1526
|
+
if (gateRaw !== undefined) {
|
|
1527
|
+
const prevGate = this.approvalGate;
|
|
1528
|
+
this.approvalGate = gateRaw;
|
|
1529
|
+
// When the operator downgrades to "off", every pending
|
|
1530
|
+
// queue entry becomes moot — the originating tool dispatch
|
|
1531
|
+
// now bypasses, and a phone approver who hits "Approve"
|
|
1532
|
+
// five seconds later would get a 404. Cancel them
|
|
1533
|
+
// server-side so the dashboard /approvals list clears
|
|
1534
|
+
// and any pending phone notification reflects reality.
|
|
1535
|
+
if (gateRaw === "off" && prevGate !== "off") {
|
|
1536
|
+
const cancelled = getApprovalQueue().cancelAll();
|
|
1537
|
+
if (cancelled > 0) {
|
|
1538
|
+
this.logger.info(`[/settings] approvalGate → off; cancelled ${cancelled} pending entr${cancelled === 1 ? "y" : "ies"}`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (body.enableTimeOfDayAnomaly !== undefined) {
|
|
1543
|
+
this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1544
|
+
}
|
|
1545
|
+
if (hasWebhookUpdate) {
|
|
1546
|
+
this.approvalWebhookUrl = webhookRaw || undefined;
|
|
1547
|
+
}
|
|
1548
|
+
if (pushUrlTrimmed !== undefined) {
|
|
1549
|
+
this.pushServiceUrl = pushUrlTrimmed || undefined;
|
|
1550
|
+
}
|
|
1551
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1552
|
+
this.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1553
|
+
}
|
|
1554
|
+
if (pushBaseTrimmed !== undefined) {
|
|
1555
|
+
this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
|
|
1556
|
+
}
|
|
1557
|
+
if (ntfyTopicTrimmed !== undefined) {
|
|
1558
|
+
this.ntfyTopic = ntfyTopicTrimmed || undefined;
|
|
1559
|
+
}
|
|
1560
|
+
if (ntfyServerTrimmed !== undefined) {
|
|
1561
|
+
this.ntfyServer = ntfyServerTrimmed || undefined;
|
|
1562
|
+
}
|
|
1563
|
+
// restartRequired covers fields the bridge only reads at boot:
|
|
1564
|
+
// driver/model/apiKey (env injection + driver factory), plus
|
|
1565
|
+
// localEndpoint/localModel which LocalApiDriver reads at
|
|
1566
|
+
// construction time. Push/ntfy fields are live-mutated on
|
|
1567
|
+
// `this.*` in PHASE 4 below, so they do not require restart.
|
|
1102
1568
|
const restartRequired = driverRaw !== undefined ||
|
|
1103
1569
|
body.apiKey !== undefined ||
|
|
1104
|
-
body.model !== undefined
|
|
1570
|
+
body.model !== undefined ||
|
|
1571
|
+
body.localEndpoint !== undefined ||
|
|
1572
|
+
body.localModel !== undefined;
|
|
1573
|
+
// Audit-log emission — forensic record of every config mutation
|
|
1574
|
+
// so an operator can answer "who changed what, when?" after an
|
|
1575
|
+
// incident. Bearer-token auth doesn't carry an actor identity
|
|
1576
|
+
// (no user JWT) so we attribute to "http" with the request IP.
|
|
1577
|
+
// Secrets are redacted to the shape `"***"` — value presence
|
|
1578
|
+
// is recorded, value content is never logged.
|
|
1579
|
+
if (this.activityLog) {
|
|
1580
|
+
const changes = {};
|
|
1581
|
+
if (hasWebhookUpdate)
|
|
1582
|
+
changes.webhookUrl = webhookRaw || "";
|
|
1583
|
+
if (gateRaw !== undefined)
|
|
1584
|
+
changes.approvalGate = gateRaw;
|
|
1585
|
+
if (body.enableTimeOfDayAnomaly !== undefined)
|
|
1586
|
+
changes.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1587
|
+
if (driverRaw !== undefined)
|
|
1588
|
+
changes.driver = driverRaw;
|
|
1589
|
+
if (body.model !== undefined)
|
|
1590
|
+
changes.model = body.model;
|
|
1591
|
+
if (body.localEndpoint !== undefined)
|
|
1592
|
+
changes.localEndpoint = body.localEndpoint;
|
|
1593
|
+
if (body.localModel !== undefined)
|
|
1594
|
+
changes.localModel = body.localModel;
|
|
1595
|
+
if (body.apiKey)
|
|
1596
|
+
changes.apiKey = { provider: body.apiKey.provider, key: "***" };
|
|
1597
|
+
if (pushUrlTrimmed !== undefined)
|
|
1598
|
+
changes.pushServiceUrl = pushUrlTrimmed || "";
|
|
1599
|
+
if (pushTokenTrimmed !== undefined)
|
|
1600
|
+
changes.pushServiceToken = pushTokenTrimmed ? "***" : "";
|
|
1601
|
+
if (pushBaseTrimmed !== undefined)
|
|
1602
|
+
changes.pushServiceBaseUrl = pushBaseTrimmed || "";
|
|
1603
|
+
if (ntfyTopicTrimmed !== undefined)
|
|
1604
|
+
changes.ntfyTopic = ntfyTopicTrimmed ? "***" : "";
|
|
1605
|
+
if (ntfyServerTrimmed !== undefined)
|
|
1606
|
+
changes.ntfyServer = ntfyServerTrimmed || "";
|
|
1607
|
+
const remoteAddr = req.headers["x-forwarded-for"]
|
|
1608
|
+
?.split(",")[0]
|
|
1609
|
+
?.trim() ||
|
|
1610
|
+
req.socket.remoteAddress ||
|
|
1611
|
+
"unknown";
|
|
1612
|
+
this.activityLog.recordEvent("settings.change", {
|
|
1613
|
+
actor: "http",
|
|
1614
|
+
ip: remoteAddr,
|
|
1615
|
+
fields: Object.keys(changes),
|
|
1616
|
+
changes,
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1105
1619
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1106
1620
|
res.end(JSON.stringify({ ok: true, restartRequired }));
|
|
1107
1621
|
}
|
|
1108
|
-
|
|
1622
|
+
}
|
|
1623
|
+
catch (err) {
|
|
1624
|
+
// Any error reaching this outer catch is unexpected — all caller
|
|
1625
|
+
// validation errors already returned their own 400 inline, and all
|
|
1626
|
+
// expected disk-write errors return their own 500. So this is a
|
|
1627
|
+
// server bug, not a client one. Returning 400 here misled clients
|
|
1628
|
+
// into believing they had sent a bad body when in fact something
|
|
1629
|
+
// crashed serverside.
|
|
1630
|
+
this.logger.error(`[/config/patchwork] unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
1631
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1632
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
1633
|
+
}
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
// /kill-switch — dedicated endpoint for the global write-tier kill switch.
|
|
1637
|
+
// See issue #422 v2: not folded into /settings because kill-switch has
|
|
1638
|
+
// audit + idempotency + env-lock semantics nothing else on /settings has.
|
|
1639
|
+
//
|
|
1640
|
+
// POST {engage: boolean, reason?: string} → toggle; idempotent.
|
|
1641
|
+
// 200 {engaged, changed, locked: false} — accepted
|
|
1642
|
+
// 200 {engaged, changed: false, locked: false} — no-op (already in that state)
|
|
1643
|
+
// 409 {error: "env_locked", flag, frozenValue, lockedReason}
|
|
1644
|
+
// — env-locked, cannot toggle
|
|
1645
|
+
// 400 {error: "invalid_request"} — malformed body
|
|
1646
|
+
//
|
|
1647
|
+
// GET → status. 200 {engaged, locked, lockedReason?, lockedValue?}
|
|
1648
|
+
//
|
|
1649
|
+
// Audit emit: state transitions log to this.logger.info (always)
|
|
1650
|
+
// AND record via this.recordKillSwitchTraceFn (when wired by the
|
|
1651
|
+
// bridge — see src/bridge.ts where it threads decisionTraceLog
|
|
1652
|
+
// through to ~/.patchwork/decision_traces.jsonl). Tests / headless
|
|
1653
|
+
// contexts that don't wire the callback still get the log line.
|
|
1654
|
+
if (parsedUrl.pathname === "/kill-switch") {
|
|
1655
|
+
if (req.method === "GET") {
|
|
1656
|
+
const engaged = isWriteKillSwitchActive();
|
|
1657
|
+
const locked = isEnvLockedFor(KILL_SWITCH_WRITES);
|
|
1658
|
+
const body = { engaged, locked };
|
|
1659
|
+
if (locked) {
|
|
1660
|
+
const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
|
|
1661
|
+
body.lockedValue = lockedValue;
|
|
1662
|
+
body.lockedReason = `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`;
|
|
1663
|
+
}
|
|
1664
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1665
|
+
res.end(JSON.stringify(body));
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
if (req.method === "POST") {
|
|
1669
|
+
// 1 KB — body is `{engage: bool, reason?: string}`; reason is a
|
|
1670
|
+
// short audit note, 1 KB is generous.
|
|
1671
|
+
const KS_BODY_CAP = 1 * 1024;
|
|
1672
|
+
const parsed = await readJsonBody(req, KS_BODY_CAP);
|
|
1673
|
+
if (!parsed.ok) {
|
|
1674
|
+
if (parsed.code === "too_large") {
|
|
1675
|
+
respond413(res, KS_BODY_CAP);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1679
|
+
res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const body = parsed.value ?? {};
|
|
1683
|
+
if (respondIfUnknownBodyKeys(res, body, ["engage", "reason"])) {
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (typeof body.engage !== "boolean") {
|
|
1109
1687
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1110
1688
|
res.end(JSON.stringify({
|
|
1111
|
-
error:
|
|
1689
|
+
error: "invalid_request",
|
|
1690
|
+
reason: "engage must be boolean",
|
|
1112
1691
|
}));
|
|
1692
|
+
return;
|
|
1113
1693
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1694
|
+
const reason = typeof body.reason === "string" && body.reason.trim().length > 0
|
|
1695
|
+
? body.reason.trim().slice(0, 500)
|
|
1696
|
+
: undefined;
|
|
1697
|
+
// v2-B2 + I3: surface env-lock conflict as structured 409 so CLI
|
|
1698
|
+
// + dashboard can distinguish "you sent garbage" from
|
|
1699
|
+
// "policy-locked by sysadmin via PATCHWORK_FLAG_*".
|
|
1700
|
+
if (isEnvLockedFor(KILL_SWITCH_WRITES)) {
|
|
1701
|
+
const lockedValue = getEnvLockedValue(KILL_SWITCH_WRITES);
|
|
1702
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1703
|
+
res.end(JSON.stringify({
|
|
1704
|
+
error: "env_locked",
|
|
1705
|
+
flag: KILL_SWITCH_WRITES,
|
|
1706
|
+
frozenValue: lockedValue,
|
|
1707
|
+
lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${lockedValue ? "1" : "0"} at bridge startup`,
|
|
1708
|
+
}));
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
// v2-I12: idempotent. State transitions emit audit; no-ops don't.
|
|
1712
|
+
const prev = isWriteKillSwitchActive();
|
|
1713
|
+
const next = body.engage;
|
|
1714
|
+
const changed = prev !== next;
|
|
1715
|
+
if (changed) {
|
|
1716
|
+
try {
|
|
1717
|
+
setFlag(KILL_SWITCH_WRITES, next, true);
|
|
1718
|
+
}
|
|
1719
|
+
catch (err) {
|
|
1720
|
+
// Belt-and-suspenders: setFlag now throws EnvLockedFlagError if
|
|
1721
|
+
// the flag was env-locked (we already checked isEnvLockedFor above,
|
|
1722
|
+
// but a race with lockKillSwitchEnv() in tests warrants this).
|
|
1723
|
+
if (err instanceof EnvLockedFlagError) {
|
|
1724
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1725
|
+
res.end(JSON.stringify({
|
|
1726
|
+
error: "env_locked",
|
|
1727
|
+
flag: KILL_SWITCH_WRITES,
|
|
1728
|
+
frozenValue: err.frozenValue,
|
|
1729
|
+
lockedReason: `PATCHWORK_FLAG_KILL_SWITCH_WRITES=${err.frozenValue ? "1" : "0"} at bridge startup`,
|
|
1730
|
+
}));
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
throw err;
|
|
1734
|
+
}
|
|
1735
|
+
// v2-I6: audit emit on every state transition; no-ops skip.
|
|
1736
|
+
// The bridge wires recordKillSwitchTraceFn to write a
|
|
1737
|
+
// DecisionTrace entry to ~/.patchwork/decision_traces.jsonl
|
|
1738
|
+
// (see src/bridge.ts). The logger.info line stays as a
|
|
1739
|
+
// secondary signal in the bridge log and is the only output
|
|
1740
|
+
// when the trace fn is unset (tests, headless contexts).
|
|
1741
|
+
this.logger.info(`[kill-switch] ${next ? "ENGAGED" : "RELEASED"}${reason ? ` (reason: ${reason})` : ""} — actor=http`);
|
|
1742
|
+
this.recordKillSwitchTraceFn?.({
|
|
1743
|
+
engaged: next,
|
|
1744
|
+
reason,
|
|
1745
|
+
ts: Date.now(),
|
|
1746
|
+
});
|
|
1747
|
+
// v2-I8: broadcast SSE kind:"kill-switch" so dashboard updates
|
|
1748
|
+
// in <1s without changing the poll cadence.
|
|
1749
|
+
this.broadcastKillSwitchEventFn?.(next, reason);
|
|
1750
|
+
}
|
|
1751
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1752
|
+
res.end(JSON.stringify({
|
|
1753
|
+
engaged: next,
|
|
1754
|
+
changed,
|
|
1755
|
+
locked: false,
|
|
1756
|
+
}));
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1116
1759
|
}
|
|
1117
|
-
//
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1760
|
+
// /feature-flags — list + toggle USER-OPT-IN UI flags only.
|
|
1761
|
+
//
|
|
1762
|
+
// Deliberately scoped: this endpoint can ONLY flip flags where
|
|
1763
|
+
// category === "ui" && requiresOptIn === true && !isKillSwitch
|
|
1764
|
+
// so it can never be used to disable a safety kill-switch (those go
|
|
1765
|
+
// through the audited /kill-switch route) or enable an experimental
|
|
1766
|
+
// flag. The motivating use is the recipe-editor "Enable & retry"
|
|
1767
|
+
// affordance for `recipe.repair-ai` (default-off, opt-in, costs API
|
|
1768
|
+
// tokens — see featureFlags.ts FLAG_REPAIR_AI).
|
|
1769
|
+
//
|
|
1770
|
+
// POST {id: string, value: boolean}
|
|
1771
|
+
// 200 {id, value, changed} — accepted
|
|
1772
|
+
// 400 {error: "invalid_request"} — malformed body
|
|
1773
|
+
// 404 {error: "unknown_flag"} — not registered
|
|
1774
|
+
// 403 {error: "not_user_toggleable"} — flag not in the opt-in/ui set
|
|
1775
|
+
// 409 {error: "env_override", envVar} — PATCHWORK_FLAG_* pins the
|
|
1776
|
+
// value; the write persisted to config but isEnabled() still
|
|
1777
|
+
// resolves to the env value, so the toggle won't take effect.
|
|
1778
|
+
if (parsedUrl.pathname === "/feature-flags") {
|
|
1779
|
+
const isUserToggleable = (f) => f.category === "ui" && f.requiresOptIn && f.isKillSwitch !== true;
|
|
1780
|
+
if (req.method === "POST") {
|
|
1781
|
+
const FF_BODY_CAP = 1 * 1024;
|
|
1782
|
+
const parsed = await readJsonBody(req, FF_BODY_CAP);
|
|
1783
|
+
if (!parsed.ok) {
|
|
1784
|
+
if (parsed.code === "too_large") {
|
|
1785
|
+
respond413(res, FF_BODY_CAP);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1789
|
+
res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
const body = parsed.value ?? {};
|
|
1793
|
+
if (respondIfUnknownBodyKeys(res, body, ["id", "value"])) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
if (typeof body.id !== "string" || typeof body.value !== "boolean") {
|
|
1797
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1798
|
+
res.end(JSON.stringify({
|
|
1799
|
+
error: "invalid_request",
|
|
1800
|
+
reason: "id must be string and value must be boolean",
|
|
1801
|
+
}));
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const flagId = body.id;
|
|
1805
|
+
const flag = listFlags().find((f) => f.id === flagId);
|
|
1806
|
+
if (!flag) {
|
|
1807
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1808
|
+
res.end(JSON.stringify({ error: "unknown_flag", id: flagId }));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
if (!isUserToggleable(flag)) {
|
|
1812
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1813
|
+
res.end(JSON.stringify({ error: "not_user_toggleable", id: flagId }));
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
const prev = isEnabled(flagId);
|
|
1817
|
+
setFlag(flagId, body.value, true);
|
|
1818
|
+
// Non-kill-switch flags read PATCHWORK_FLAG_* dynamically with
|
|
1819
|
+
// precedence over the config we just wrote. If a sysadmin pinned
|
|
1820
|
+
// the flag via env, the write won't take effect — surface that as
|
|
1821
|
+
// a 409 instead of a misleading 200 (the editor would otherwise
|
|
1822
|
+
// "Enable & retry" straight into another 503).
|
|
1823
|
+
const effective = isEnabled(flagId);
|
|
1824
|
+
if (effective !== body.value) {
|
|
1825
|
+
const envVar = `PATCHWORK_FLAG_${flagId
|
|
1826
|
+
.replace(/[.-]/g, "_")
|
|
1827
|
+
.toUpperCase()}`;
|
|
1828
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1829
|
+
res.end(JSON.stringify({
|
|
1830
|
+
error: "env_override",
|
|
1831
|
+
id: flagId,
|
|
1832
|
+
envVar,
|
|
1833
|
+
effectiveValue: effective,
|
|
1834
|
+
}));
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
this.logger.info(`[feature-flags] ${flagId} = ${body.value} — actor=http`);
|
|
1838
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1839
|
+
res.end(JSON.stringify({
|
|
1840
|
+
id: flagId,
|
|
1841
|
+
value: body.value,
|
|
1842
|
+
changed: prev !== body.value,
|
|
1843
|
+
}));
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
// /telemetry-prefs — read/write per-flag telemetry preferences.
|
|
1848
|
+
// GET → {crashReports, usageStats, localDiagnostics, endpoint, endpointSource}
|
|
1849
|
+
// POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
|
|
1850
|
+
// DELETE → clears prefs file + analytics-config + install salt
|
|
1851
|
+
// (right-to-erasure affordance).
|
|
1852
|
+
if (parsedUrl.pathname === "/telemetry-prefs") {
|
|
1853
|
+
if (req.method === "GET") {
|
|
1854
|
+
const prefs = getTelemetryPrefs();
|
|
1855
|
+
const all = getAnalyticsPrefsAll();
|
|
1856
|
+
const response = { ...prefs };
|
|
1857
|
+
if (all?.lastSentAt !== undefined) {
|
|
1858
|
+
response.lastSentAt = all.lastSentAt;
|
|
1859
|
+
}
|
|
1860
|
+
// Destination visibility — consent baseline. Caller can see
|
|
1861
|
+
// where their data would go and whether the destination came
|
|
1862
|
+
// from env (CI/headless) or the on-disk config file.
|
|
1863
|
+
const envEndpoint = process.env.PATCHWORK_ANALYTICS_ENDPOINT;
|
|
1864
|
+
const cfgEndpoint = getAnalyticsConfig().endpoint;
|
|
1865
|
+
const defaultEndpoint = "https://analytics.claude-ide-bridge.dev/v1/usage";
|
|
1866
|
+
if (envEndpoint) {
|
|
1867
|
+
response.endpoint = envEndpoint;
|
|
1868
|
+
response.endpointSource = "env";
|
|
1869
|
+
}
|
|
1870
|
+
else if (cfgEndpoint) {
|
|
1871
|
+
response.endpoint = cfgEndpoint;
|
|
1872
|
+
response.endpointSource = "config";
|
|
1873
|
+
}
|
|
1874
|
+
else {
|
|
1875
|
+
response.endpoint = defaultEndpoint;
|
|
1876
|
+
response.endpointSource = "default";
|
|
1877
|
+
}
|
|
1878
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1879
|
+
res.end(JSON.stringify(response));
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (req.method === "DELETE") {
|
|
1883
|
+
// Right-to-erasure: drop prefs, analytics-config (endpoint /
|
|
1884
|
+
// shared secret), and the install salt. Next outbound send
|
|
1885
|
+
// (if any) will mint a fresh salt and treat this as a new
|
|
1886
|
+
// install. Kill-switch is honored here too — incident-mode
|
|
1887
|
+
// operators may still want to scrub local state, so allow
|
|
1888
|
+
// it; flip if the threat model changes.
|
|
1122
1889
|
try {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1890
|
+
unlinkSync(getAnalyticsPrefsPath());
|
|
1891
|
+
}
|
|
1892
|
+
catch {
|
|
1893
|
+
/* missing is fine */
|
|
1894
|
+
}
|
|
1895
|
+
try {
|
|
1896
|
+
unlinkSync(getAnalyticsSaltPath());
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
/* missing is fine */
|
|
1900
|
+
}
|
|
1901
|
+
clearAnalyticsConfig();
|
|
1902
|
+
if (this.activityLog) {
|
|
1903
|
+
this.activityLog.recordEvent("telemetry.reset", {
|
|
1904
|
+
actor: "http",
|
|
1905
|
+
ip: req.headers["x-forwarded-for"]
|
|
1906
|
+
?.split(",")[0]
|
|
1907
|
+
?.trim() ||
|
|
1908
|
+
req.socket.remoteAddress ||
|
|
1909
|
+
"unknown",
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1913
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
if (req.method === "POST") {
|
|
1917
|
+
// Kill-switch gate — telemetry prefs are config writes too.
|
|
1918
|
+
// GET stays open so operators can verify state during an
|
|
1919
|
+
// incident.
|
|
1920
|
+
if (isWriteKillSwitchActive()) {
|
|
1921
|
+
res.writeHead(423, { "Content-Type": "application/json" });
|
|
1922
|
+
res.end(JSON.stringify({
|
|
1923
|
+
error: "kill_switch_blocked",
|
|
1924
|
+
reason: "Telemetry pref writes are disabled while the write kill-switch is engaged.",
|
|
1925
|
+
}));
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
const TP_BODY_CAP = 1 * 1024;
|
|
1929
|
+
const parsed = await readJsonBody(req, TP_BODY_CAP);
|
|
1930
|
+
if (!parsed.ok) {
|
|
1931
|
+
if (parsed.code === "too_large") {
|
|
1932
|
+
respond413(res, TP_BODY_CAP);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1936
|
+
res.end(JSON.stringify({ error: "invalid_request", reason: "bad_json" }));
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
const body = parsed.value ?? {};
|
|
1940
|
+
if (respondIfUnknownBodyKeys(res, body, [
|
|
1941
|
+
"crashReports",
|
|
1942
|
+
"usageStats",
|
|
1943
|
+
"localDiagnostics",
|
|
1944
|
+
])) {
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
const update = {};
|
|
1948
|
+
// Reject non-boolean values for known keys instead of silently
|
|
1949
|
+
// dropping them. Previously `{"crashReports": "true"}` (string)
|
|
1950
|
+
// got a 200 with no effect — caller never learned the toggle
|
|
1951
|
+
// did nothing. Misrepresented consent is the worst-case
|
|
1952
|
+
// outcome on the telemetry surface, so be strict here.
|
|
1953
|
+
for (const key of [
|
|
1954
|
+
"crashReports",
|
|
1955
|
+
"usageStats",
|
|
1956
|
+
"localDiagnostics",
|
|
1957
|
+
]) {
|
|
1958
|
+
if (body[key] !== undefined && typeof body[key] !== "boolean") {
|
|
1959
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1129
1960
|
res.end(JSON.stringify({
|
|
1130
|
-
|
|
1131
|
-
|
|
1961
|
+
error: "invalid_request",
|
|
1962
|
+
reason: `${key} must be a boolean`,
|
|
1132
1963
|
}));
|
|
1133
1964
|
return;
|
|
1134
1965
|
}
|
|
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
1966
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1967
|
+
if (typeof body.crashReports === "boolean") {
|
|
1968
|
+
update.crashReports = body.crashReports;
|
|
1969
|
+
}
|
|
1970
|
+
if (typeof body.usageStats === "boolean") {
|
|
1971
|
+
update.usageStats = body.usageStats;
|
|
1972
|
+
}
|
|
1973
|
+
if (typeof body.localDiagnostics === "boolean") {
|
|
1974
|
+
update.localDiagnostics = body.localDiagnostics;
|
|
1975
|
+
}
|
|
1976
|
+
setTelemetryPrefs(update);
|
|
1977
|
+
const prefs = getTelemetryPrefs();
|
|
1978
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1979
|
+
res.end(JSON.stringify(prefs));
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
// /restart — graceful bridge restart endpoint.
|
|
1984
|
+
// POST → triggers SIGTERM if no active work; returns 409 if busy.
|
|
1985
|
+
// Safety checks: rejects restart if sessions have in-flight tool calls.
|
|
1986
|
+
if (parsedUrl.pathname === "/restart" && req.method === "POST") {
|
|
1987
|
+
if (!this.restartCheckFn) {
|
|
1988
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1989
|
+
res.end(JSON.stringify({
|
|
1990
|
+
ok: false,
|
|
1991
|
+
error: "restart_unavailable",
|
|
1992
|
+
reason: "Restart endpoint not configured",
|
|
1993
|
+
}));
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
const check = this.restartCheckFn();
|
|
1997
|
+
// Reject restart if there's active work
|
|
1998
|
+
if (check.inFlightCalls > 0) {
|
|
1999
|
+
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"}`);
|
|
2000
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
2001
|
+
res.end(JSON.stringify({
|
|
2002
|
+
ok: false,
|
|
2003
|
+
error: "restart_blocked",
|
|
2004
|
+
reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
|
|
2005
|
+
activeSessions: check.totalSessions,
|
|
2006
|
+
inFlightCalls: check.inFlightCalls,
|
|
2007
|
+
busySessions: check.busySessions,
|
|
2008
|
+
}));
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
// Safe to restart — log and trigger SIGTERM
|
|
2012
|
+
this.logger.info(`[/restart] Initiating graceful restart — ${check.totalSessions} session${check.totalSessions === 1 ? "" : "s"}, 0 in-flight calls`);
|
|
2013
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
2014
|
+
res.end(JSON.stringify({
|
|
2015
|
+
ok: true,
|
|
2016
|
+
message: "Restart initiated. Bridge will shut down gracefully.",
|
|
2017
|
+
activeSessions: check.totalSessions,
|
|
2018
|
+
}));
|
|
2019
|
+
// Trigger shutdown after response is sent (100ms delay to ensure response delivery).
|
|
2020
|
+
// Uses this.restartKillFn so tests can override without killing the runner.
|
|
2021
|
+
const killFn = this.restartKillFn;
|
|
2022
|
+
setTimeout(() => {
|
|
2023
|
+
this.logger.info("[/restart] Sending SIGTERM to self");
|
|
2024
|
+
killFn();
|
|
2025
|
+
}, 100);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
// /shutdown — graceful exit endpoint. POST → triggers the bridge's
|
|
2029
|
+
// internal shutdown sequence (lockfile unlink, HTTP close, telemetry
|
|
2030
|
+
// flush). Same in-flight safety check as /restart; pass `?force=1` to
|
|
2031
|
+
// skip it. Necessary on Windows where `process.kill(pid, 'SIGTERM')`
|
|
2032
|
+
// is TerminateProcess and cleanup handlers never fire — the bridge
|
|
2033
|
+
// wires `shutdownFn` to call the shutdown sequence directly.
|
|
2034
|
+
if (parsedUrl.pathname === "/shutdown" && req.method === "POST") {
|
|
2035
|
+
const force = parsedUrl.searchParams.get("force") === "1";
|
|
2036
|
+
if (!force && this.restartCheckFn) {
|
|
2037
|
+
const check = this.restartCheckFn();
|
|
2038
|
+
if (check.inFlightCalls > 0) {
|
|
2039
|
+
this.logger.warn(`[/shutdown] Rejected — ${check.inFlightCalls} in-flight tool call${check.inFlightCalls === 1 ? "" : "s"}`);
|
|
2040
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
2041
|
+
res.end(JSON.stringify({
|
|
2042
|
+
ok: false,
|
|
2043
|
+
error: "shutdown_blocked",
|
|
2044
|
+
reason: `${check.inFlightCalls} tool call${check.inFlightCalls === 1 ? "" : "s"} in progress`,
|
|
2045
|
+
inFlightCalls: check.inFlightCalls,
|
|
2046
|
+
busySessions: check.busySessions,
|
|
2047
|
+
}));
|
|
2048
|
+
return;
|
|
1144
2049
|
}
|
|
2050
|
+
}
|
|
2051
|
+
this.logger.info("[/shutdown] Initiating graceful shutdown");
|
|
2052
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
2053
|
+
res.end(JSON.stringify({
|
|
2054
|
+
ok: true,
|
|
2055
|
+
message: "Shutdown initiated.",
|
|
2056
|
+
}));
|
|
2057
|
+
const shutdownFn = this.shutdownFn;
|
|
2058
|
+
setTimeout(() => {
|
|
2059
|
+
this.logger.info("[/shutdown] Calling bridge shutdown sequence");
|
|
2060
|
+
shutdownFn();
|
|
2061
|
+
}, 100);
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
// CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
|
|
2065
|
+
if (parsedUrl.pathname === "/notify" && req.method === "POST") {
|
|
2066
|
+
// 8 KB — notify payloads carry an event name + small arg map
|
|
2067
|
+
// (taskId, prompt, tool name). 8 KB fits everything we send today
|
|
2068
|
+
// with headroom.
|
|
2069
|
+
const NOTIFY_BODY_CAP = 8 * 1024;
|
|
2070
|
+
const parsed = await readJsonBody(req, NOTIFY_BODY_CAP);
|
|
2071
|
+
if (!parsed.ok) {
|
|
2072
|
+
if (parsed.code === "too_large") {
|
|
2073
|
+
respond413(res, NOTIFY_BODY_CAP);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2077
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
const event = parsed.value?.event ?? "";
|
|
2081
|
+
const args = parsed.value?.args ?? {};
|
|
2082
|
+
if (!this.notifyFn) {
|
|
2083
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
2084
|
+
res.end(JSON.stringify({
|
|
2085
|
+
ok: false,
|
|
2086
|
+
error: "Automation not enabled",
|
|
2087
|
+
}));
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
const result = this.notifyFn(event, args);
|
|
2091
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
2092
|
+
"Content-Type": "application/json",
|
|
1145
2093
|
});
|
|
2094
|
+
res.end(JSON.stringify(result));
|
|
1146
2095
|
return;
|
|
1147
2096
|
}
|
|
1148
2097
|
// Single-approval detail lookup for the dashboard detail page.
|
|
@@ -1160,10 +2109,7 @@ export class Server extends EventEmitter {
|
|
|
1160
2109
|
res.end(JSON.stringify(data));
|
|
1161
2110
|
}
|
|
1162
2111
|
catch (err) {
|
|
1163
|
-
res
|
|
1164
|
-
res.end(JSON.stringify({
|
|
1165
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1166
|
-
}));
|
|
2112
|
+
respond500(res, err);
|
|
1167
2113
|
}
|
|
1168
2114
|
return;
|
|
1169
2115
|
}
|
|
@@ -1177,101 +2123,103 @@ export class Server extends EventEmitter {
|
|
|
1177
2123
|
if (parsedUrl.pathname === "/approvals" ||
|
|
1178
2124
|
parsedUrl.pathname === "/cc-permissions" ||
|
|
1179
2125
|
/^\/(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
|
-
}));
|
|
2126
|
+
// 32 KB — approvals carry decision + reason + optional permission
|
|
2127
|
+
// rule patches; 32 KB matches RECIPE_ROUTE_BODY_CAPS.run. Critical
|
|
2128
|
+
// here: /approve/:id and /reject/:id are reachable via the
|
|
2129
|
+
// x-approval-token phone-path bypass at line 609-612 — without a
|
|
2130
|
+
// cap an unbounded body read happens *before* token validation.
|
|
2131
|
+
const APPROVALS_BODY_CAP = 32 * 1024;
|
|
2132
|
+
const parsed = await readJsonBody(req, APPROVALS_BODY_CAP);
|
|
2133
|
+
if (!parsed.ok) {
|
|
2134
|
+
if (parsed.code === "too_large") {
|
|
2135
|
+
respond413(res, APPROVALS_BODY_CAP);
|
|
2136
|
+
return;
|
|
1231
2137
|
}
|
|
1232
|
-
|
|
2138
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2139
|
+
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
const parsedBody = parsed.value;
|
|
2143
|
+
try {
|
|
2144
|
+
const result = await routeApprovalRequest({
|
|
2145
|
+
method: req.method ?? "GET",
|
|
2146
|
+
path: parsedUrl.pathname,
|
|
2147
|
+
body: parsedBody,
|
|
2148
|
+
query: parsedUrl.searchParams,
|
|
2149
|
+
approvalToken: req.headers["x-approval-token"] ??
|
|
2150
|
+
parsedUrl.searchParams.get("token") ??
|
|
2151
|
+
undefined,
|
|
2152
|
+
}, {
|
|
2153
|
+
queue: getApprovalQueue(),
|
|
2154
|
+
workspace: process.cwd(),
|
|
2155
|
+
managedSettingsPath: this.managedSettingsPath,
|
|
2156
|
+
onDecision: this.onApprovalDecision,
|
|
2157
|
+
webhookUrl: this.approvalWebhookUrl,
|
|
2158
|
+
approvalGate: this.approvalGate,
|
|
2159
|
+
pushServiceUrl: this.pushServiceUrl,
|
|
2160
|
+
pushServiceToken: this.pushServiceToken,
|
|
2161
|
+
pushServiceBaseUrl: this.pushServiceBaseUrl,
|
|
2162
|
+
ntfyTopic: this.ntfyTopic,
|
|
2163
|
+
ntfyServer: this.ntfyServer,
|
|
2164
|
+
activityLog: this.activityLog,
|
|
2165
|
+
// RecipeRunLog satisfies RecipeRunQuerier structurally
|
|
2166
|
+
// — the cast bridges TS contravariance: RecipeRunQuerier's
|
|
2167
|
+
// narrow query interface (`status?: string`) is deliberately
|
|
2168
|
+
// loose so tests can mock it; RecipeRunLog's stricter
|
|
2169
|
+
// RunStatus union is a strict subset and fails the param
|
|
2170
|
+
// contravariance check despite being safe at runtime.
|
|
2171
|
+
recipeRunLog: this.recipeRunLog,
|
|
2172
|
+
enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
|
|
2173
|
+
});
|
|
2174
|
+
res.writeHead(result.status, {
|
|
2175
|
+
"Content-Type": "application/json",
|
|
2176
|
+
});
|
|
2177
|
+
res.end(JSON.stringify(result.body));
|
|
2178
|
+
}
|
|
2179
|
+
catch (err) {
|
|
2180
|
+
respond500(res, err);
|
|
2181
|
+
}
|
|
1233
2182
|
return;
|
|
1234
2183
|
}
|
|
1235
2184
|
// Quick-task launch endpoint — mirrors /notify pattern. Bearer auth already checked above.
|
|
1236
2185
|
if (parsedUrl.pathname === "/launch-quick-task" &&
|
|
1237
2186
|
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
|
-
})();
|
|
2187
|
+
// 4 KB — body is `{ presetId?: string; source?: string }`, two
|
|
2188
|
+
// short strings. 4 KB is plenty.
|
|
2189
|
+
const QUICK_TASK_BODY_CAP = 4 * 1024;
|
|
2190
|
+
const parsed = await readJsonBody(req, QUICK_TASK_BODY_CAP);
|
|
2191
|
+
if (!parsed.ok) {
|
|
2192
|
+
if (parsed.code === "too_large") {
|
|
2193
|
+
respond413(res, QUICK_TASK_BODY_CAP);
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2197
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
const presetId = parsed.value?.presetId;
|
|
2201
|
+
const source = parsed.value?.source ?? "cli";
|
|
2202
|
+
if (typeof presetId !== "string" || !presetId) {
|
|
2203
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2204
|
+
res.end(JSON.stringify({
|
|
2205
|
+
ok: false,
|
|
2206
|
+
error: "presetId required",
|
|
2207
|
+
}));
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
if (!this.launchQuickTaskFn) {
|
|
2211
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
2212
|
+
res.end(JSON.stringify({
|
|
2213
|
+
ok: false,
|
|
2214
|
+
error: "Quick tasks unavailable — requires --driver subprocess",
|
|
2215
|
+
}));
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const result = await this.launchQuickTaskFn(presetId, source);
|
|
2219
|
+
res.writeHead(result.ok ? 200 : 429, {
|
|
2220
|
+
"Content-Type": "application/json",
|
|
1274
2221
|
});
|
|
2222
|
+
res.end(JSON.stringify(result));
|
|
1275
2223
|
return;
|
|
1276
2224
|
}
|
|
1277
2225
|
// MCP Streamable HTTP transport — POST/GET/DELETE /mcp.
|
|
@@ -1283,11 +2231,9 @@ export class Server extends EventEmitter {
|
|
|
1283
2231
|
req.method === "GET" ||
|
|
1284
2232
|
req.method === "DELETE") {
|
|
1285
2233
|
this.httpMcpHandler(req, res).catch((err) => {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
res.end(JSON.stringify({ error: String(err) }));
|
|
1290
|
-
}
|
|
2234
|
+
// respond500 logs the underlying error detail server-side; no
|
|
2235
|
+
// need to also funnel it through this.logger.
|
|
2236
|
+
respond500(res, err, "/mcp HTTP handler");
|
|
1291
2237
|
});
|
|
1292
2238
|
return;
|
|
1293
2239
|
}
|
|
@@ -1420,6 +2366,41 @@ export class Server extends EventEmitter {
|
|
|
1420
2366
|
this.emit("connection", ws);
|
|
1421
2367
|
});
|
|
1422
2368
|
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Resolve the client IP for rate-limit bucketing on the phone-path
|
|
2371
|
+
* approval bypass. Default: the direct socket peer. When trustedProxies
|
|
2372
|
+
* is set AND the socket peer is one of them, the rightmost X-Forwarded-For
|
|
2373
|
+
* entry not in the trusted list is the real client. XFF from an untrusted
|
|
2374
|
+
* peer is ignored — that header is spoofable by anyone who can reach the
|
|
2375
|
+
* server directly. Returns null when no IP can be determined; the caller
|
|
2376
|
+
* should fail closed.
|
|
2377
|
+
*/
|
|
2378
|
+
getClientIp(req) {
|
|
2379
|
+
const socketIp = req.socket?.remoteAddress;
|
|
2380
|
+
if (socketIp &&
|
|
2381
|
+
this.trustedProxies.length > 0 &&
|
|
2382
|
+
this.trustedProxies.includes(socketIp)) {
|
|
2383
|
+
const xffRaw = req.headers["x-forwarded-for"];
|
|
2384
|
+
const xffStr = Array.isArray(xffRaw) ? xffRaw[0] : xffRaw;
|
|
2385
|
+
if (typeof xffStr === "string" && xffStr.length > 0) {
|
|
2386
|
+
const hops = xffStr
|
|
2387
|
+
.split(",")
|
|
2388
|
+
.map((s) => s.trim())
|
|
2389
|
+
.filter((s) => s.length > 0);
|
|
2390
|
+
// Walk right→left (proxies append to the right). The rightmost hop
|
|
2391
|
+
// we DON'T trust is the client; everything to its right is our own
|
|
2392
|
+
// hop chain.
|
|
2393
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
2394
|
+
const hop = hops[i];
|
|
2395
|
+
if (hop !== undefined && !this.trustedProxies.includes(hop)) {
|
|
2396
|
+
return hop.slice(0, 64);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
// Edge case: every hop is in trustedProxies — unusual; fall through.
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
return socketIp ? socketIp.slice(0, 64) : null;
|
|
2403
|
+
}
|
|
1423
2404
|
async listen(port, bindAddress = "127.0.0.1") {
|
|
1424
2405
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
1425
2406
|
if (!LOOPBACK.has(bindAddress)) {
|