patchwork-os 0.2.0-beta.1 → 0.2.0-beta.10.canary.95
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 +201 -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.js +1 -1
- 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 +38 -2
- package/dist/analyticsPrefs.js +148 -23
- 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 +202 -25
- 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.js +23 -8
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeOrchestrator.d.ts +1 -1
- package/dist/claudeOrchestrator.js +87 -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 +16 -0
- package/dist/commitIssueLinkLog.js +110 -24
- 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 +47 -3
- package/dist/config.js +111 -21
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +866 -57
- 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 +227 -1
- 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.js +22 -3
- package/dist/drivers/claude/subprocess.js.map +1 -1
- 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/local/index.d.ts +17 -0
- package/dist/drivers/local/index.js +99 -0
- package/dist/drivers/local/index.js.map +1 -1
- package/dist/drivers/openai/index.js +30 -2
- package/dist/drivers/openai/index.js.map +1 -1
- package/dist/extensionClient.d.ts +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/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +61 -1
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.js +1053 -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 +9 -0
- package/dist/oauth.js +33 -0
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.d.ts +1 -1
- package/dist/oauthRoutes.js +5 -3
- 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 +44 -1
- package/dist/patchworkConfig.js +28 -3
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.js +10 -1
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +12 -14
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.js +3 -2
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/processTree.d.ts +34 -0
- package/dist/processTree.js +105 -0
- package/dist/processTree.js.map +1 -0
- package/dist/prompts.js +3 -3
- package/dist/prompts.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +9 -0
- package/dist/recipeOrchestration.js +341 -24
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +58 -2
- package/dist/recipeRoutes.js +744 -115
- 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 +25 -5
- 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 +27 -5
- 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 +182 -8
- package/dist/recipes/yamlRunner.js +877 -217
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +19 -3
- package/dist/recipesHttp.js +67 -11
- 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 +160 -3
- package/dist/server.js +1099 -122
- 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/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/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 +18 -0
- package/dist/tools/utils.js +103 -18
- 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 +73 -17
- package/scripts/start-orchestrator.ps1 +158 -0
- package/scripts/start-remote.mjs +122 -0
- package/templates/automation-policies/recipe-authoring.json +1 -1
- package/templates/automation-policies/security-first.json +1 -1
- package/templates/automation-policies/strict-lint.json +1 -1
- package/templates/automation-policies/test-driven.json +1 -1
- package/templates/automation-policy.example.json +1 -1
- package/templates/co.patchwork-os.bridge.plist +2 -2
- package/templates/recipes/approval-queue-ui-test.yaml +1 -1
- 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 -16
- package/dist/commands/marketplace.js +0 -32
- package/dist/commands/marketplace.js.map +0 -1
- package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
- package/dist/recipes/legacyRecipeCompat.js +0 -131
- package/dist/recipes/legacyRecipeCompat.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
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";
|
|
10
17
|
import { respond500 } from "./httpErrorResponse.js";
|
|
11
18
|
import { tryHandleInboxRoute } from "./inboxRoutes.js";
|
|
12
19
|
import { tryHandleMcpRoute } from "./mcpRoutes.js";
|
|
@@ -49,6 +56,7 @@ export class Server extends EventEmitter {
|
|
|
49
56
|
logger;
|
|
50
57
|
extraCorsOrigins;
|
|
51
58
|
pingIntervalMs;
|
|
59
|
+
trustedProxies;
|
|
52
60
|
httpServer;
|
|
53
61
|
wss;
|
|
54
62
|
pingInterval = null;
|
|
@@ -59,9 +67,25 @@ export class Server extends EventEmitter {
|
|
|
59
67
|
oauthServer = null;
|
|
60
68
|
oauthIssuerUrl = null;
|
|
61
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();
|
|
62
82
|
/** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
|
|
63
83
|
_explainRulesCache = null;
|
|
64
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
|
+
}
|
|
65
89
|
/** Set by bridge to provide health data */
|
|
66
90
|
healthDataFn = null;
|
|
67
91
|
/** Set by bridge to provide Prometheus metrics */
|
|
@@ -80,6 +104,15 @@ export class Server extends EventEmitter {
|
|
|
80
104
|
setRecipeTrustFn = null;
|
|
81
105
|
/** Patchwork: set by bridge to generate a recipe YAML draft from a natural-language prompt. */
|
|
82
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;
|
|
83
116
|
/** Patchwork: set by bridge to list installed recipes for the dashboard. */
|
|
84
117
|
recipesFn = null;
|
|
85
118
|
/** Patchwork: set by bridge to load raw recipe source content by name. */
|
|
@@ -102,6 +135,10 @@ export class Server extends EventEmitter {
|
|
|
102
135
|
runsFn = null;
|
|
103
136
|
/** Patchwork: set by bridge to fetch a single run by seq for the detail page. */
|
|
104
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;
|
|
105
142
|
/** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
|
|
106
143
|
runPlanFn = null;
|
|
107
144
|
/** Patchwork (VD-4): mocked replay of an existing run. Returns the new
|
|
@@ -109,10 +146,25 @@ export class Server extends EventEmitter {
|
|
|
109
146
|
runReplayFn = null;
|
|
110
147
|
/** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
|
|
111
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;
|
|
112
157
|
/** Patchwork: admin-controlled managed settings path (highest rule precedence). */
|
|
113
158
|
managedSettingsPath = undefined;
|
|
114
159
|
/** Effective bridge config path to update when dashboard saves driver changes. */
|
|
115
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;
|
|
116
168
|
/** Patchwork: live approval gate level — mutated by POST /settings, read by bridge per-session setup. */
|
|
117
169
|
approvalGate = "off";
|
|
118
170
|
/** Patchwork: outbound webhook URL for approval notifications (from dashboard.webhookUrl in config). */
|
|
@@ -123,6 +175,10 @@ export class Server extends EventEmitter {
|
|
|
123
175
|
pushServiceToken = undefined;
|
|
124
176
|
/** Patchwork: public base URL of this bridge, embedded in push payloads as callback base. */
|
|
125
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;
|
|
126
182
|
/** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
|
|
127
183
|
onApprovalDecision = undefined;
|
|
128
184
|
/**
|
|
@@ -145,6 +201,33 @@ export class Server extends EventEmitter {
|
|
|
145
201
|
* the user's preference.
|
|
146
202
|
*/
|
|
147
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;
|
|
148
231
|
/** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
|
|
149
232
|
webhookFn = null;
|
|
150
233
|
/**
|
|
@@ -157,6 +240,36 @@ export class Server extends EventEmitter {
|
|
|
157
240
|
*/
|
|
158
241
|
webhookPayloads = new Map();
|
|
159
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;
|
|
160
273
|
/** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
|
|
161
274
|
httpMcpHandler = null;
|
|
162
275
|
/** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
|
|
@@ -180,6 +293,22 @@ export class Server extends EventEmitter {
|
|
|
180
293
|
/** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
|
|
181
294
|
launchQuickTaskFn = null;
|
|
182
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");
|
|
183
312
|
/**
|
|
184
313
|
* Attach an OAuth 2.0 Authorization Server.
|
|
185
314
|
* When set, the bridge exposes:
|
|
@@ -196,12 +325,19 @@ export class Server extends EventEmitter {
|
|
|
196
325
|
}
|
|
197
326
|
/** Hosts accepted in the WebSocket upgrade Host header (DNS-rebinding guard). */
|
|
198
327
|
allowedHosts;
|
|
199
|
-
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 = []) {
|
|
200
335
|
super();
|
|
201
336
|
this.authToken = authToken;
|
|
202
337
|
this.logger = logger;
|
|
203
338
|
this.extraCorsOrigins = extraCorsOrigins;
|
|
204
339
|
this.pingIntervalMs = pingIntervalMs;
|
|
340
|
+
this.trustedProxies = trustedProxies;
|
|
205
341
|
// Defense-in-depth: ensure token is non-empty so timingSafeTokenCompare
|
|
206
342
|
// cannot accept a blank Authorization header against an empty token.
|
|
207
343
|
if (authToken.length === 0) {
|
|
@@ -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
|
|
@@ -607,10 +799,18 @@ export class Server extends EventEmitter {
|
|
|
607
799
|
return;
|
|
608
800
|
}
|
|
609
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.
|
|
610
811
|
if (this.sseSubscriberCount >= Server.MAX_SSE_SUBSCRIBERS) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
return;
|
|
812
|
+
const oldest = this.sseSubscribers.values().next().value;
|
|
813
|
+
oldest?.();
|
|
614
814
|
}
|
|
615
815
|
this.sseSubscriberCount++;
|
|
616
816
|
// Disable socket timeout — SSE connections are long-lived by design
|
|
@@ -621,31 +821,58 @@ export class Server extends EventEmitter {
|
|
|
621
821
|
Connection: "keep-alive",
|
|
622
822
|
});
|
|
623
823
|
res.flushHeaders();
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
+
}) ?? (() => { });
|
|
633
863
|
// Keep-alive comment ping every 15s so proxies don't close idle connections
|
|
634
|
-
|
|
864
|
+
pingHandle = setInterval(() => {
|
|
635
865
|
try {
|
|
636
866
|
res.write(": ping\n\n");
|
|
637
867
|
}
|
|
638
868
|
catch {
|
|
639
|
-
|
|
640
|
-
unsub();
|
|
869
|
+
cleanup();
|
|
641
870
|
}
|
|
642
871
|
}, 15_000);
|
|
643
|
-
|
|
644
|
-
req.on("close",
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
unsub();
|
|
648
|
-
});
|
|
872
|
+
pingHandle.unref();
|
|
873
|
+
req.on("close", cleanup);
|
|
874
|
+
req.on("error", cleanup);
|
|
875
|
+
res.on("error", cleanup);
|
|
649
876
|
return;
|
|
650
877
|
}
|
|
651
878
|
if (req.url === "/tasks" && req.method === "GET") {
|
|
@@ -689,6 +916,33 @@ export class Server extends EventEmitter {
|
|
|
689
916
|
respond413(res, HOOKS_BODY_CAP);
|
|
690
917
|
return;
|
|
691
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
|
+
}
|
|
692
946
|
let payload;
|
|
693
947
|
if (read.body.trim()) {
|
|
694
948
|
try {
|
|
@@ -702,7 +956,7 @@ export class Server extends EventEmitter {
|
|
|
702
956
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
703
957
|
res.end(JSON.stringify({
|
|
704
958
|
ok: false,
|
|
705
|
-
error: "Webhooks unavailable — start bridge with --
|
|
959
|
+
error: "Webhooks unavailable — start bridge with --driver subprocess",
|
|
706
960
|
}));
|
|
707
961
|
return;
|
|
708
962
|
}
|
|
@@ -728,6 +982,19 @@ export class Server extends EventEmitter {
|
|
|
728
982
|
if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
|
|
729
983
|
existing.length = Server.MAX_WEBHOOK_PAYLOADS;
|
|
730
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
|
+
}
|
|
731
998
|
this.webhookPayloads.set(hookPath, existing);
|
|
732
999
|
}
|
|
733
1000
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
@@ -880,6 +1147,7 @@ export class Server extends EventEmitter {
|
|
|
880
1147
|
if (tryHandleRecipeRoute(req, res, parsedUrl, {
|
|
881
1148
|
setRecipeTrustFn: this.setRecipeTrustFn,
|
|
882
1149
|
generateRecipeFn: this.generateRecipeFn,
|
|
1150
|
+
repairRecipeFn: this.repairRecipeFn,
|
|
883
1151
|
recipesFn: this.recipesFn,
|
|
884
1152
|
loadRecipeContentFn: this.loadRecipeContentFn,
|
|
885
1153
|
saveRecipeContentFn: this.saveRecipeContentFn,
|
|
@@ -892,9 +1160,12 @@ export class Server extends EventEmitter {
|
|
|
892
1160
|
setRecipeEnabledFn: this.setRecipeEnabledFn,
|
|
893
1161
|
runsFn: this.runsFn,
|
|
894
1162
|
runDetailFn: this.runDetailFn,
|
|
1163
|
+
haltSummaryFn: this.haltSummaryFn,
|
|
1164
|
+
judgeSummaryFn: this.judgeSummaryFn,
|
|
895
1165
|
runPlanFn: this.runPlanFn,
|
|
896
1166
|
runReplayFn: this.runReplayFn,
|
|
897
1167
|
runRecipeFn: this.runRecipeFn,
|
|
1168
|
+
onRecipesChangedFn: this.onRecipesChangedFn,
|
|
898
1169
|
})) {
|
|
899
1170
|
return;
|
|
900
1171
|
}
|
|
@@ -933,6 +1204,21 @@ export class Server extends EventEmitter {
|
|
|
933
1204
|
return;
|
|
934
1205
|
}
|
|
935
1206
|
if (parsedUrl.pathname === "/settings" && req.method === "POST") {
|
|
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
|
+
}
|
|
936
1222
|
// 16 KB — settings POSTs are short-string fields (URLs, API keys,
|
|
937
1223
|
// gate level). 16 KB is generous; an authenticated attacker can't
|
|
938
1224
|
// stream gigabytes here.
|
|
@@ -950,55 +1236,59 @@ export class Server extends EventEmitter {
|
|
|
950
1236
|
try {
|
|
951
1237
|
{
|
|
952
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
|
|
953
1266
|
const hasWebhookUpdate = body.webhookUrl !== undefined;
|
|
954
|
-
const
|
|
1267
|
+
const webhookRaw = hasWebhookUpdate
|
|
955
1268
|
? (body.webhookUrl?.trim() ?? "")
|
|
956
1269
|
: undefined;
|
|
957
|
-
if (
|
|
958
|
-
|
|
959
|
-
|
|
1270
|
+
if (webhookRaw !== undefined &&
|
|
1271
|
+
webhookRaw !== "" &&
|
|
1272
|
+
!/^https:\/\/.+/.test(webhookRaw)) {
|
|
1273
|
+
respond400("webhookUrl must be HTTPS");
|
|
960
1274
|
return;
|
|
961
1275
|
}
|
|
1276
|
+
// approvalGate
|
|
962
1277
|
const gateRaw = body.approvalGate;
|
|
963
1278
|
if (gateRaw !== undefined &&
|
|
964
1279
|
gateRaw !== "off" &&
|
|
965
1280
|
gateRaw !== "high" &&
|
|
966
1281
|
gateRaw !== "all") {
|
|
967
|
-
|
|
968
|
-
res.end(JSON.stringify({
|
|
969
|
-
error: 'approvalGate must be "off", "high", or "all"',
|
|
970
|
-
}));
|
|
1282
|
+
respond400('approvalGate must be "off", "high", or "all"');
|
|
971
1283
|
return;
|
|
972
1284
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
pushNotifications: cfg.dashboard?.pushNotifications ?? false,
|
|
979
|
-
webhookUrl: hasWebhookUpdate
|
|
980
|
-
? raw || undefined
|
|
981
|
-
: cfg.dashboard?.webhookUrl,
|
|
982
|
-
};
|
|
983
|
-
if (gateRaw !== undefined) {
|
|
984
|
-
cfg.approvalGate = gateRaw;
|
|
985
|
-
this.approvalGate = gateRaw;
|
|
986
|
-
}
|
|
987
|
-
// h10 toggle: must be boolean if present. Persists to
|
|
988
|
-
// ~/.patchwork/config.json AND live-mutates the Server
|
|
989
|
-
// field so the next /approvals POST honors it without
|
|
990
|
-
// needing a bridge restart.
|
|
991
|
-
if (body.enableTimeOfDayAnomaly !== undefined) {
|
|
992
|
-
if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
|
|
993
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
994
|
-
res.end(JSON.stringify({
|
|
995
|
-
error: "enableTimeOfDayAnomaly must be a boolean",
|
|
996
|
-
}));
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1000
|
-
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;
|
|
1001
1290
|
}
|
|
1291
|
+
// driver
|
|
1002
1292
|
const driverRaw = body.driver;
|
|
1003
1293
|
if (driverRaw !== undefined) {
|
|
1004
1294
|
const validDrivers = [
|
|
@@ -1012,26 +1302,11 @@ export class Server extends EventEmitter {
|
|
|
1012
1302
|
"none",
|
|
1013
1303
|
];
|
|
1014
1304
|
if (!validDrivers.includes(driverRaw)) {
|
|
1015
|
-
|
|
1016
|
-
res.end(JSON.stringify({
|
|
1017
|
-
error: `driver must be one of: ${validDrivers.join(", ")}`,
|
|
1018
|
-
}));
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
const driver = driverRaw;
|
|
1022
|
-
cfg.driver = driver;
|
|
1023
|
-
try {
|
|
1024
|
-
saveBridgeConfigDriver(driver, this.bridgeConfigPath);
|
|
1025
|
-
}
|
|
1026
|
-
catch (writeErr) {
|
|
1027
|
-
this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
|
|
1028
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1029
|
-
res.end(JSON.stringify({
|
|
1030
|
-
error: "Failed to write bridge driver config",
|
|
1031
|
-
}));
|
|
1305
|
+
respond400(`driver must be one of: ${validDrivers.join(", ")}`);
|
|
1032
1306
|
return;
|
|
1033
1307
|
}
|
|
1034
1308
|
}
|
|
1309
|
+
// model
|
|
1035
1310
|
if (body.model !== undefined) {
|
|
1036
1311
|
const validModels = [
|
|
1037
1312
|
"claude",
|
|
@@ -1041,40 +1316,198 @@ export class Server extends EventEmitter {
|
|
|
1041
1316
|
"local",
|
|
1042
1317
|
];
|
|
1043
1318
|
if (!validModels.includes(body.model)) {
|
|
1044
|
-
|
|
1045
|
-
res.end(JSON.stringify({
|
|
1046
|
-
error: `model must be one of: ${validModels.join(", ")}`,
|
|
1047
|
-
}));
|
|
1319
|
+
respond400(`model must be one of: ${validModels.join(", ")}`);
|
|
1048
1320
|
return;
|
|
1049
1321
|
}
|
|
1050
|
-
cfg.model = body.model;
|
|
1051
|
-
if (body.model === "local") {
|
|
1052
|
-
if (body.localEndpoint !== undefined)
|
|
1053
|
-
cfg.localEndpoint = body.localEndpoint.trim() || undefined;
|
|
1054
|
-
if (body.localModel !== undefined)
|
|
1055
|
-
cfg.localModel = body.localModel.trim() || undefined;
|
|
1056
|
-
}
|
|
1057
1322
|
}
|
|
1323
|
+
// apiKey
|
|
1058
1324
|
if (body.apiKey) {
|
|
1059
1325
|
const { provider, key } = body.apiKey;
|
|
1060
1326
|
const validProviders = ["anthropic", "openai", "google", "xai"];
|
|
1061
1327
|
if (!validProviders.includes(provider) ||
|
|
1062
1328
|
typeof key !== "string") {
|
|
1063
|
-
|
|
1064
|
-
|
|
1329
|
+
respond400("Invalid apiKey provider or key");
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
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;
|
|
1368
|
+
}
|
|
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");
|
|
1065
1402
|
return;
|
|
1066
1403
|
}
|
|
1067
|
-
|
|
1068
|
-
// Service / AES-256-GCM file fallback) — never persisted to
|
|
1069
|
-
// ~/.patchwork/config.json. Empty string clears.
|
|
1404
|
+
let parsed;
|
|
1070
1405
|
try {
|
|
1071
|
-
|
|
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;
|
|
1463
|
+
}
|
|
1464
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1465
|
+
cfg.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1466
|
+
}
|
|
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);
|
|
1072
1505
|
}
|
|
1073
1506
|
catch (writeErr) {
|
|
1074
|
-
this.logger.error(`[/config/patchwork]
|
|
1507
|
+
this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
|
|
1075
1508
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1076
1509
|
res.end(JSON.stringify({
|
|
1077
|
-
error: "Failed to write
|
|
1510
|
+
error: "Failed to write bridge driver config",
|
|
1078
1511
|
}));
|
|
1079
1512
|
return;
|
|
1080
1513
|
}
|
|
@@ -1083,46 +1516,551 @@ export class Server extends EventEmitter {
|
|
|
1083
1516
|
savePatchworkConfig(cfg, configPath);
|
|
1084
1517
|
}
|
|
1085
1518
|
catch (writeErr) {
|
|
1086
|
-
this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ?
|
|
1519
|
+
this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
|
|
1087
1520
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1088
|
-
res.end(JSON.stringify({
|
|
1089
|
-
error: "Failed to write patchwork config",
|
|
1090
|
-
}));
|
|
1521
|
+
res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
|
|
1091
1522
|
return;
|
|
1092
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
|
+
}
|
|
1093
1545
|
if (hasWebhookUpdate) {
|
|
1094
|
-
this.approvalWebhookUrl =
|
|
1546
|
+
this.approvalWebhookUrl = webhookRaw || undefined;
|
|
1095
1547
|
}
|
|
1096
|
-
if (
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
this.pushServiceUrl = pushUrl || undefined;
|
|
1548
|
+
if (pushUrlTrimmed !== undefined) {
|
|
1549
|
+
this.pushServiceUrl = pushUrlTrimmed || undefined;
|
|
1550
|
+
}
|
|
1551
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1552
|
+
this.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1104
1553
|
}
|
|
1105
|
-
if (
|
|
1106
|
-
this.
|
|
1554
|
+
if (pushBaseTrimmed !== undefined) {
|
|
1555
|
+
this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
|
|
1107
1556
|
}
|
|
1108
|
-
if (
|
|
1109
|
-
this.
|
|
1110
|
-
body.pushServiceBaseUrl.trim() || undefined;
|
|
1557
|
+
if (ntfyTopicTrimmed !== undefined) {
|
|
1558
|
+
this.ntfyTopic = ntfyTopicTrimmed || undefined;
|
|
1111
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.
|
|
1112
1568
|
const restartRequired = driverRaw !== undefined ||
|
|
1113
1569
|
body.apiKey !== undefined ||
|
|
1114
|
-
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
|
+
}
|
|
1115
1619
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1116
1620
|
res.end(JSON.stringify({ ok: true, restartRequired }));
|
|
1117
1621
|
}
|
|
1118
1622
|
}
|
|
1119
1623
|
catch (err) {
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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" }));
|
|
1123
1633
|
}
|
|
1124
1634
|
return;
|
|
1125
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") {
|
|
1687
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1688
|
+
res.end(JSON.stringify({
|
|
1689
|
+
error: "invalid_request",
|
|
1690
|
+
reason: "engage must be boolean",
|
|
1691
|
+
}));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
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
|
+
}
|
|
1759
|
+
}
|
|
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.
|
|
1889
|
+
try {
|
|
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" });
|
|
1960
|
+
res.end(JSON.stringify({
|
|
1961
|
+
error: "invalid_request",
|
|
1962
|
+
reason: `${key} must be a boolean`,
|
|
1963
|
+
}));
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
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;
|
|
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
|
+
}
|
|
1126
2064
|
// CC hook notify endpoint — lightweight alternative to full MCP session for hook wiring.
|
|
1127
2065
|
if (parsedUrl.pathname === "/notify" && req.method === "POST") {
|
|
1128
2066
|
// 8 KB — notify payloads carry an event name + small arg map
|
|
@@ -1208,7 +2146,9 @@ export class Server extends EventEmitter {
|
|
|
1208
2146
|
path: parsedUrl.pathname,
|
|
1209
2147
|
body: parsedBody,
|
|
1210
2148
|
query: parsedUrl.searchParams,
|
|
1211
|
-
approvalToken: req.headers["x-approval-token"]
|
|
2149
|
+
approvalToken: req.headers["x-approval-token"] ??
|
|
2150
|
+
parsedUrl.searchParams.get("token") ??
|
|
2151
|
+
undefined,
|
|
1212
2152
|
}, {
|
|
1213
2153
|
queue: getApprovalQueue(),
|
|
1214
2154
|
workspace: process.cwd(),
|
|
@@ -1219,6 +2159,8 @@ export class Server extends EventEmitter {
|
|
|
1219
2159
|
pushServiceUrl: this.pushServiceUrl,
|
|
1220
2160
|
pushServiceToken: this.pushServiceToken,
|
|
1221
2161
|
pushServiceBaseUrl: this.pushServiceBaseUrl,
|
|
2162
|
+
ntfyTopic: this.ntfyTopic,
|
|
2163
|
+
ntfyServer: this.ntfyServer,
|
|
1222
2164
|
activityLog: this.activityLog,
|
|
1223
2165
|
// RecipeRunLog satisfies RecipeRunQuerier structurally
|
|
1224
2166
|
// — the cast bridges TS contravariance: RecipeRunQuerier's
|
|
@@ -1269,7 +2211,7 @@ export class Server extends EventEmitter {
|
|
|
1269
2211
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1270
2212
|
res.end(JSON.stringify({
|
|
1271
2213
|
ok: false,
|
|
1272
|
-
error: "Quick tasks unavailable — requires --
|
|
2214
|
+
error: "Quick tasks unavailable — requires --driver subprocess",
|
|
1273
2215
|
}));
|
|
1274
2216
|
return;
|
|
1275
2217
|
}
|
|
@@ -1424,6 +2366,41 @@ export class Server extends EventEmitter {
|
|
|
1424
2366
|
this.emit("connection", ws);
|
|
1425
2367
|
});
|
|
1426
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
|
+
}
|
|
1427
2404
|
async listen(port, bindAddress = "127.0.0.1") {
|
|
1428
2405
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
1429
2406
|
if (!LOOPBACK.has(bindAddress)) {
|