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/recipeRoutes.js
CHANGED
|
@@ -29,13 +29,33 @@
|
|
|
29
29
|
import os from "node:os";
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { computeSummary as computeActivationSummary, loadMetrics as loadActivationMetrics, } from "./activationMetrics.js";
|
|
32
|
+
import { isWriteKillSwitchActive } from "./featureFlags.js";
|
|
32
33
|
import { consumeToken, refillBucket, } from "./fp/tokenBucket.js";
|
|
34
|
+
import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
|
|
33
35
|
import { respond500 } from "./httpErrorResponse.js";
|
|
34
36
|
import { validateSafeUrl } from "./ssrfGuard.js";
|
|
35
37
|
// 5-minute cache of the public template registry from the patchworkos/recipes
|
|
36
38
|
// GitHub repo. Process-wide; hoisted out of Server class state.
|
|
39
|
+
// Sentinel `false` marks a negative-cache entry (fetch failed) — distinct from
|
|
40
|
+
// `null` (never fetched) so the timestamp-based retry window is respected.
|
|
37
41
|
let templatesCache = null;
|
|
38
42
|
let templatesCacheTs = 0;
|
|
43
|
+
/**
|
|
44
|
+
* #605: shape-validate the upstream templates payload before caching.
|
|
45
|
+
* The minimal contract used by the dashboard marketplace is `{recipes:
|
|
46
|
+
* Array}` (other fields are optional). Anything else (an error page
|
|
47
|
+
* JSON, a tampered file with a flipped key, a future GitHub schema
|
|
48
|
+
* change) is rejected so we don't serve garbage for 5 minutes to every
|
|
49
|
+
* dashboard client.
|
|
50
|
+
*/
|
|
51
|
+
function isWellFormedTemplatesPayload(raw) {
|
|
52
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
53
|
+
// Some legacy callers used a bare array; accept that too.
|
|
54
|
+
return Array.isArray(raw);
|
|
55
|
+
}
|
|
56
|
+
const r = raw;
|
|
57
|
+
return Array.isArray(r.recipes);
|
|
58
|
+
}
|
|
39
59
|
/**
|
|
40
60
|
* Per-process token bucket guarding `/recipes/generate`. Every call to the
|
|
41
61
|
* route enqueues a Claude subprocess via the orchestrator — without a cap a
|
|
@@ -63,6 +83,26 @@ export function _resetGenerateRateLimitForTests() {
|
|
|
63
83
|
lastRefill: 0,
|
|
64
84
|
};
|
|
65
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Phase 2A: separate cooldown bucket for `/recipes/repair`. Repair
|
|
88
|
+
* costs the same per-call tokens as generate but a user dogfooding the
|
|
89
|
+
* "Fix with AI" button may legitimately click it 2-3× back-to-back
|
|
90
|
+
* (preview a fix, discard, ask again). Giving repair its own 10/min
|
|
91
|
+
* bucket avoids starving generate when a user is iterating on repair,
|
|
92
|
+
* and the loop-hazard cap I flagged in plan-review applies here
|
|
93
|
+
* separately.
|
|
94
|
+
*/
|
|
95
|
+
const RECIPE_REPAIR_LIMIT_PER_MIN = 10;
|
|
96
|
+
let recipeRepairBucket = {
|
|
97
|
+
tokens: RECIPE_REPAIR_LIMIT_PER_MIN,
|
|
98
|
+
lastRefill: 0,
|
|
99
|
+
};
|
|
100
|
+
export function _resetRepairRateLimitForTests() {
|
|
101
|
+
recipeRepairBucket = {
|
|
102
|
+
tokens: RECIPE_REPAIR_LIMIT_PER_MIN,
|
|
103
|
+
lastRefill: 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
66
106
|
// G-security R2 C-3 / I-3 / F-02: HTTP `vars` validation.
|
|
67
107
|
//
|
|
68
108
|
// The post-render path jail in `src/recipes/resolveRecipePath.ts` is the
|
|
@@ -142,6 +182,12 @@ export const RECIPE_ROUTE_BODY_CAPS = {
|
|
|
142
182
|
run: 32 * 1024,
|
|
143
183
|
/** /recipes (POST), PUT/PATCH /recipes/:name, /recipes/lint — yaml content. */
|
|
144
184
|
content: 256 * 1024,
|
|
185
|
+
/**
|
|
186
|
+
* /recipes/repair — `{ currentYaml: string, lintIssues: LintIssue[] }`.
|
|
187
|
+
* Cap matches `content` since the body carries the full recipe YAML
|
|
188
|
+
* (256 KB matches the existing PUT/POST/lint caps).
|
|
189
|
+
*/
|
|
190
|
+
repair: 256 * 1024,
|
|
145
191
|
};
|
|
146
192
|
/**
|
|
147
193
|
* Read an HTTP request body up to `max` bytes. Returns the raw string or
|
|
@@ -188,7 +234,11 @@ export function readBodyWithCap(req, max) {
|
|
|
188
234
|
const onEnd = () => {
|
|
189
235
|
if (aborted)
|
|
190
236
|
return;
|
|
191
|
-
|
|
237
|
+
const bytes = Buffer.concat(chunks);
|
|
238
|
+
// `bytes` is the raw on-the-wire body; `body` is the utf-8 decode used
|
|
239
|
+
// by JSON parsers. HMAC consumers must use `bytes` to avoid the
|
|
240
|
+
// utf-8 round-trip changing the signed payload.
|
|
241
|
+
resolve({ ok: true, body: bytes.toString("utf-8"), bytes });
|
|
192
242
|
};
|
|
193
243
|
const onError = () => {
|
|
194
244
|
if (aborted)
|
|
@@ -254,6 +304,24 @@ function respondInvalidJson(res) {
|
|
|
254
304
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
255
305
|
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
256
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Best-effort fire of the recipe-changed notification. Wraps the
|
|
309
|
+
* callback in try/catch + console.error so a misbehaving notifier
|
|
310
|
+
* (most likely scheduler.start() throwing) cannot turn a successful
|
|
311
|
+
* disk-write into a failed-looking HTTP response. Used by install /
|
|
312
|
+
* save / delete / archive / duplicate / setEnabled / saveContent
|
|
313
|
+
* routes after their respective success paths.
|
|
314
|
+
*/
|
|
315
|
+
function fireOnRecipesChanged(deps) {
|
|
316
|
+
if (!deps.onRecipesChangedFn)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
deps.onRecipesChangedFn();
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
console.error(`[recipeRoutes] onRecipesChangedFn threw:`, err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
257
325
|
/**
|
|
258
326
|
* Try to handle a recipe / run-audit / template route. Returns true if
|
|
259
327
|
* the route was dispatched (caller should `return` from the request
|
|
@@ -281,6 +349,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
281
349
|
}
|
|
282
350
|
try {
|
|
283
351
|
const parsed = parsedBody.value ?? {};
|
|
352
|
+
if (respondIfUnknownBodyKeys(res, parsed, ["vars", "inputs"]))
|
|
353
|
+
return;
|
|
284
354
|
const varsRaw = parsed.vars ?? parsed.inputs;
|
|
285
355
|
const varsErr = validateRecipeVars(varsRaw);
|
|
286
356
|
if (varsErr) {
|
|
@@ -295,7 +365,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
295
365
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
296
366
|
res.end(JSON.stringify({
|
|
297
367
|
ok: false,
|
|
298
|
-
error: "Recipe execution unavailable — requires --
|
|
368
|
+
error: "Recipe execution unavailable — requires --driver subprocess",
|
|
299
369
|
}));
|
|
300
370
|
return;
|
|
301
371
|
}
|
|
@@ -326,17 +396,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
326
396
|
}
|
|
327
397
|
try {
|
|
328
398
|
const parsed = parsedBody.value ?? {};
|
|
399
|
+
if (respondIfUnknownBodyKeys(res, parsed, ["name", "vars", "inputs"]))
|
|
400
|
+
return;
|
|
329
401
|
const name = parsed.name;
|
|
330
|
-
|
|
402
|
+
// #605 symmetry — accept `inputs` here like /recipes/:name/run does.
|
|
403
|
+
const varsRaw = parsed.vars ?? parsed.inputs;
|
|
404
|
+
const varsErr = validateRecipeVars(varsRaw);
|
|
331
405
|
if (varsErr) {
|
|
332
406
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
333
407
|
res.end(JSON.stringify(varsErr));
|
|
334
408
|
return;
|
|
335
409
|
}
|
|
336
|
-
const vars =
|
|
337
|
-
|
|
338
|
-
!Array.isArray(parsed.vars)
|
|
339
|
-
? parsed.vars
|
|
410
|
+
const vars = varsRaw && typeof varsRaw === "object" && !Array.isArray(varsRaw)
|
|
411
|
+
? varsRaw
|
|
340
412
|
: undefined;
|
|
341
413
|
if (typeof name !== "string" || !name) {
|
|
342
414
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -347,7 +419,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
347
419
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
348
420
|
res.end(JSON.stringify({
|
|
349
421
|
ok: false,
|
|
350
|
-
error: "Recipe execution unavailable — requires --
|
|
422
|
+
error: "Recipe execution unavailable — requires --driver subprocess",
|
|
351
423
|
}));
|
|
352
424
|
return;
|
|
353
425
|
}
|
|
@@ -383,6 +455,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
383
455
|
const trigger = sp.get("trigger");
|
|
384
456
|
const status = sp.get("status");
|
|
385
457
|
const recipe = sp.get("recipe");
|
|
458
|
+
const manualRunId = sp.get("manualRunId");
|
|
386
459
|
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
387
460
|
const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
|
|
388
461
|
const runs = deps.runsFn?.({
|
|
@@ -390,6 +463,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
390
463
|
...(trigger && { trigger }),
|
|
391
464
|
...(status && { status }),
|
|
392
465
|
...(recipe && { recipe }),
|
|
466
|
+
...(manualRunId && { manualRunId }),
|
|
393
467
|
...(Number.isFinite(after) && { after }),
|
|
394
468
|
}) ?? [];
|
|
395
469
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -400,6 +474,116 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
400
474
|
}
|
|
401
475
|
return true;
|
|
402
476
|
}
|
|
477
|
+
// GET /runs/halt-summary — aggregated halt categories over recent runs.
|
|
478
|
+
// Drives the dashboard /runs page header widget that answers "is the
|
|
479
|
+
// haltReason work surfacing real signal, or is everything 'unknown'?".
|
|
480
|
+
if (parsedUrl.pathname === "/runs/halt-summary" && req.method === "GET") {
|
|
481
|
+
try {
|
|
482
|
+
const sp = parsedUrl.searchParams;
|
|
483
|
+
const sinceMsRaw = sp.get("sinceMs");
|
|
484
|
+
const limitRaw = sp.get("limit");
|
|
485
|
+
const recipe = sp.get("recipe");
|
|
486
|
+
const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
|
|
487
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
488
|
+
const summary = deps.haltSummaryFn?.({
|
|
489
|
+
...(Number.isFinite(sinceMs) && { sinceMs }),
|
|
490
|
+
...(Number.isFinite(limit) && { limit }),
|
|
491
|
+
...(recipe && { recipe }),
|
|
492
|
+
}) ?? { total: 0, byCategory: {}, recent: [] };
|
|
493
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
494
|
+
res.end(JSON.stringify(summary));
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
respond500(res, err);
|
|
498
|
+
}
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
// GET /runs/judge-summary — PR3b sibling of /runs/halt-summary.
|
|
502
|
+
// Same query shape (sinceMs, limit, recipe), returns JudgeSummary.
|
|
503
|
+
if (parsedUrl.pathname === "/runs/judge-summary" && req.method === "GET") {
|
|
504
|
+
try {
|
|
505
|
+
const sp = parsedUrl.searchParams;
|
|
506
|
+
const sinceMsRaw = sp.get("sinceMs");
|
|
507
|
+
const limitRaw = sp.get("limit");
|
|
508
|
+
const recipe = sp.get("recipe");
|
|
509
|
+
const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
|
|
510
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
511
|
+
const summary = deps.judgeSummaryFn?.({
|
|
512
|
+
...(Number.isFinite(sinceMs) && { sinceMs }),
|
|
513
|
+
...(Number.isFinite(limit) && { limit }),
|
|
514
|
+
...(recipe && { recipe }),
|
|
515
|
+
}) ?? { total: 0, byVerdict: {}, recent: [] };
|
|
516
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
517
|
+
res.end(JSON.stringify(summary));
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
respond500(res, err);
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
// GET /recipes/doctor?recipe=<name> — one-call recipe health diagnosis.
|
|
525
|
+
// Server-side home for the `recipe doctor` CLI: composes the static
|
|
526
|
+
// preflight check (lint + write-policy + plan) with the recipe-scoped
|
|
527
|
+
// runtime halt summary (reusing deps.haltSummaryFn in-process — no lock
|
|
528
|
+
// walk), each finding mapped to a fix hint. Read-only; fail-soft.
|
|
529
|
+
if (parsedUrl.pathname === "/recipes/doctor" && req.method === "GET") {
|
|
530
|
+
void (async () => {
|
|
531
|
+
try {
|
|
532
|
+
const recipe = parsedUrl.searchParams.get("recipe");
|
|
533
|
+
if (!recipe) {
|
|
534
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
535
|
+
res.end(JSON.stringify({
|
|
536
|
+
error: "missing_recipe",
|
|
537
|
+
message: "?recipe= is required",
|
|
538
|
+
}));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// Name-only over HTTP — the dashboard always sends an installed
|
|
542
|
+
// recipe name. Reject path separators / traversal so this endpoint
|
|
543
|
+
// can't be coaxed into linting an arbitrary file off disk (the CLI
|
|
544
|
+
// path-resolution affordance stays CLI-only).
|
|
545
|
+
if (/[\\/]/.test(recipe) || recipe.includes("..")) {
|
|
546
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
547
|
+
res.end(JSON.stringify({
|
|
548
|
+
error: "invalid_recipe",
|
|
549
|
+
message: "recipe must be a bare name",
|
|
550
|
+
}));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const { runRecipeDoctor } = await import("./commands/recipe.js");
|
|
554
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
555
|
+
const fetchHalts = deps.haltSummaryFn
|
|
556
|
+
? async (recipeName) =>
|
|
557
|
+
// deps.haltSummaryFn is sync; the doctor contract is async.
|
|
558
|
+
deps.haltSummaryFn?.({
|
|
559
|
+
sinceMs: Date.now() - SEVEN_DAYS_MS,
|
|
560
|
+
recipe: recipeName,
|
|
561
|
+
}) ?? null
|
|
562
|
+
: undefined;
|
|
563
|
+
const result = await runRecipeDoctor(recipe, { fetchHalts });
|
|
564
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
565
|
+
res.end(JSON.stringify(result));
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
// resolveRecipePath throws "recipe ... not found" for unknown
|
|
569
|
+
// names — map that to 404; anything else is a real 500. Never echo
|
|
570
|
+
// err.message back to the client: it embeds the absolute recipes
|
|
571
|
+
// path (info-exposure / js/stack-trace-exposure). Return a static
|
|
572
|
+
// message; respond500 handles logging the detail server-side.
|
|
573
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
574
|
+
if (/not found/i.test(msg)) {
|
|
575
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
576
|
+
res.end(JSON.stringify({
|
|
577
|
+
error: "recipe_not_found",
|
|
578
|
+
message: "recipe not found",
|
|
579
|
+
}));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
respond500(res, err);
|
|
583
|
+
}
|
|
584
|
+
})();
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
403
587
|
// GET /runs/:seq — single run detail (includes stepResults if present)
|
|
404
588
|
const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
|
|
405
589
|
if (runDetailMatch?.[1]) {
|
|
@@ -481,9 +665,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
481
665
|
res.end(JSON.stringify({ plan }));
|
|
482
666
|
}
|
|
483
667
|
catch (err) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
668
|
+
// #605: classify by error code, not message substring.
|
|
669
|
+
// Previously `msg.includes("not found")` would mis-map any
|
|
670
|
+
// deep error coincidentally containing that phrase (e.g. a
|
|
671
|
+
// connector returning "credential not found") to 404. Use the
|
|
672
|
+
// structured `code` on Node fs / our explicit error.code.
|
|
673
|
+
const code = err?.code;
|
|
674
|
+
if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
|
|
487
675
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
488
676
|
res.end(JSON.stringify({ error: "Run not found" }));
|
|
489
677
|
}
|
|
@@ -540,19 +728,156 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
540
728
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
541
729
|
res.end(JSON.stringify({
|
|
542
730
|
ok: false,
|
|
543
|
-
error: "Recipe generation unavailable — requires --
|
|
731
|
+
error: "Recipe generation unavailable — requires --driver subprocess",
|
|
544
732
|
unavailable: true,
|
|
545
733
|
}));
|
|
546
734
|
return;
|
|
547
735
|
}
|
|
548
736
|
try {
|
|
549
737
|
const result = await deps.generateRecipeFn(prompt.trim());
|
|
738
|
+
// #605: stop collapsing every failure to 422. The dashboard
|
|
739
|
+
// can't distinguish 'driver crashed' from 'user prompt refused'
|
|
740
|
+
// from 'generated YAML failed lint' when everything maps to one
|
|
741
|
+
// status. Use the result.errorKind discriminant when present;
|
|
742
|
+
// fall back to 422 for the unstructured case.
|
|
743
|
+
let status;
|
|
744
|
+
if (result.ok) {
|
|
745
|
+
status = 200;
|
|
746
|
+
}
|
|
747
|
+
else if (result.unavailable) {
|
|
748
|
+
status = 503;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
const kind = result.errorKind;
|
|
752
|
+
if (kind === "driver_error" || kind === "timeout") {
|
|
753
|
+
status = 502; // upstream failure
|
|
754
|
+
}
|
|
755
|
+
else if (kind === "refused" || kind === "rate_limited") {
|
|
756
|
+
status = 429;
|
|
757
|
+
}
|
|
758
|
+
else if (kind === "lint_failed" || kind === "invalid_yaml") {
|
|
759
|
+
status = 422; // semantic generation failure
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
status = 422; // unknown — preserve legacy
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
766
|
+
res.end(JSON.stringify(result));
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
console.error(`[recipes/generate] internal error:`, err);
|
|
770
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
771
|
+
res.end(JSON.stringify({
|
|
772
|
+
ok: false,
|
|
773
|
+
error: "Internal server error",
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
})();
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
if (parsedUrl.pathname === "/recipes/repair" && req.method === "POST") {
|
|
780
|
+
// Phase 2A: LLM-driven repair of a broken recipe. Gated behind
|
|
781
|
+
// the `recipe.repair-ai` feature flag (default off) — the input
|
|
782
|
+
// body carries arbitrary YAML which may have been marketplace-
|
|
783
|
+
// installed (= untrusted), and the orchestrator-bound prompt
|
|
784
|
+
// costs API tokens per call. Same shape as /recipes/generate
|
|
785
|
+
// (rate limit + body cap + sanitization + post-lint) but a
|
|
786
|
+
// distinct token bucket so back-to-back repair clicks don't
|
|
787
|
+
// starve generate.
|
|
788
|
+
void (async () => {
|
|
789
|
+
// Flag gate first — fail fast before consuming a bucket token.
|
|
790
|
+
const { isEnabled, FLAG_REPAIR_AI } = await import("./featureFlags.js");
|
|
791
|
+
if (!isEnabled(FLAG_REPAIR_AI)) {
|
|
792
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
793
|
+
res.end(JSON.stringify({
|
|
794
|
+
ok: false,
|
|
795
|
+
code: "feature_disabled",
|
|
796
|
+
error: "Recipe repair is gated behind the `recipe.repair-ai` feature flag (default off). Enable via the dashboard Settings → Feature flags or POST /feature-flags.",
|
|
797
|
+
unavailable: true,
|
|
798
|
+
}));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const now = Date.now();
|
|
802
|
+
const refilled = refillBucket(recipeRepairBucket, now, RECIPE_REPAIR_LIMIT_PER_MIN);
|
|
803
|
+
const consumed = consumeToken(refilled);
|
|
804
|
+
recipeRepairBucket = consumed.nextState;
|
|
805
|
+
if (!consumed.allowed) {
|
|
806
|
+
const secondsToOneToken = Math.ceil(((1 - consumed.nextState.tokens) / RECIPE_REPAIR_LIMIT_PER_MIN) * 60);
|
|
807
|
+
res.writeHead(429, {
|
|
808
|
+
"Content-Type": "application/json",
|
|
809
|
+
"Retry-After": String(Math.max(1, secondsToOneToken)),
|
|
810
|
+
});
|
|
811
|
+
res.end(JSON.stringify({
|
|
812
|
+
ok: false,
|
|
813
|
+
error: `Rate limit exceeded — max ${RECIPE_REPAIR_LIMIT_PER_MIN} repair calls per minute`,
|
|
814
|
+
retryAfterSeconds: Math.max(1, secondsToOneToken),
|
|
815
|
+
}));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.repair);
|
|
819
|
+
if (!parsedBody.ok) {
|
|
820
|
+
if (parsedBody.code === "too_large") {
|
|
821
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.repair);
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
respondInvalidJson(res);
|
|
825
|
+
}
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const body = parsedBody.value;
|
|
829
|
+
const currentYaml = body?.currentYaml;
|
|
830
|
+
if (typeof currentYaml !== "string" || !currentYaml.trim()) {
|
|
831
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
832
|
+
res.end(JSON.stringify({
|
|
833
|
+
ok: false,
|
|
834
|
+
error: "currentYaml must be a non-empty string",
|
|
835
|
+
}));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Coarse shape guard on lintIssues — accept array or default to
|
|
839
|
+
// empty. Each item is structurally checked (level + message
|
|
840
|
+
// strings); unknown extras are stripped to keep the prompt
|
|
841
|
+
// surface small.
|
|
842
|
+
const rawIssues = Array.isArray(body?.lintIssues) ? body.lintIssues : [];
|
|
843
|
+
const lintIssues = [];
|
|
844
|
+
for (const raw of rawIssues) {
|
|
845
|
+
if (raw &&
|
|
846
|
+
typeof raw === "object" &&
|
|
847
|
+
typeof raw.message === "string" &&
|
|
848
|
+
(raw.level === "error" ||
|
|
849
|
+
raw.level === "warning")) {
|
|
850
|
+
const r = raw;
|
|
851
|
+
lintIssues.push({
|
|
852
|
+
level: r.level,
|
|
853
|
+
message: r.message,
|
|
854
|
+
...(typeof r.line === "number" && { line: r.line }),
|
|
855
|
+
...(typeof r.column === "number" && { column: r.column }),
|
|
856
|
+
...(typeof r.code === "string" && { code: r.code }),
|
|
857
|
+
...(typeof r.path === "string" && { path: r.path }),
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (!deps.repairRecipeFn) {
|
|
862
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
863
|
+
res.end(JSON.stringify({
|
|
864
|
+
ok: false,
|
|
865
|
+
error: "Recipe repair unavailable — requires --driver subprocess",
|
|
866
|
+
unavailable: true,
|
|
867
|
+
}));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
const result = await deps.repairRecipeFn({
|
|
872
|
+
currentYaml: currentYaml.trim(),
|
|
873
|
+
lintIssues,
|
|
874
|
+
});
|
|
550
875
|
const status = result.ok ? 200 : result.unavailable ? 503 : 422;
|
|
551
876
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
552
877
|
res.end(JSON.stringify(result));
|
|
553
878
|
}
|
|
554
879
|
catch (err) {
|
|
555
|
-
console.error(`[recipes/
|
|
880
|
+
console.error(`[recipes/repair] internal error:`, err);
|
|
556
881
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
557
882
|
res.end(JSON.stringify({
|
|
558
883
|
ok: false,
|
|
@@ -591,6 +916,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
591
916
|
return;
|
|
592
917
|
}
|
|
593
918
|
const result = deps.saveRecipeFn(draft);
|
|
919
|
+
if (result.ok)
|
|
920
|
+
fireOnRecipesChanged(deps);
|
|
594
921
|
res.writeHead(result.ok ? 201 : 400, {
|
|
595
922
|
"Content-Type": "application/json",
|
|
596
923
|
});
|
|
@@ -619,6 +946,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
619
946
|
}
|
|
620
947
|
return;
|
|
621
948
|
}
|
|
949
|
+
if (respondIfUnknownBodyKeys(res, parsedBody.value, ["level"]))
|
|
950
|
+
return;
|
|
622
951
|
const level = parsedBody.value
|
|
623
952
|
?.level;
|
|
624
953
|
if (typeof level !== "string" || !level) {
|
|
@@ -670,6 +999,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
670
999
|
return;
|
|
671
1000
|
}
|
|
672
1001
|
const result = deps.setRecipeEnabledFn(name, body.enabled);
|
|
1002
|
+
// Enable/disable changes which cron triggers should fire — the
|
|
1003
|
+
// RecipeScheduler honours the disabled set on every start(), so
|
|
1004
|
+
// re-priming after a toggle picks up the change without a restart.
|
|
1005
|
+
if (result.ok)
|
|
1006
|
+
fireOnRecipesChanged(deps);
|
|
673
1007
|
res.writeHead(result.ok ? 200 : 400, {
|
|
674
1008
|
"Content-Type": "application/json",
|
|
675
1009
|
});
|
|
@@ -742,11 +1076,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
742
1076
|
res.end(JSON.stringify({ plan }));
|
|
743
1077
|
}
|
|
744
1078
|
catch (err) {
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
if (
|
|
1079
|
+
// #605: classify by code, not substring (see /runs/:seq/plan).
|
|
1080
|
+
const code = err?.code;
|
|
1081
|
+
if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
|
|
748
1082
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
749
|
-
res.end(JSON.stringify({ error: "
|
|
1083
|
+
res.end(JSON.stringify({ error: "Recipe not found" }));
|
|
750
1084
|
}
|
|
751
1085
|
else {
|
|
752
1086
|
respond500(res, err, "recipes plan");
|
|
@@ -806,6 +1140,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
806
1140
|
return;
|
|
807
1141
|
}
|
|
808
1142
|
const result = deps.saveRecipeContentFn(name, body.content);
|
|
1143
|
+
// Editing recipe YAML can change cron schedule, webhook path,
|
|
1144
|
+
// or trigger type entirely — re-prime the scheduler so the new
|
|
1145
|
+
// shape takes effect without a bridge restart.
|
|
1146
|
+
if (result.ok)
|
|
1147
|
+
fireOnRecipesChanged(deps);
|
|
809
1148
|
res.writeHead(result.ok ? 200 : 400, {
|
|
810
1149
|
"Content-Type": "application/json",
|
|
811
1150
|
});
|
|
@@ -828,6 +1167,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
828
1167
|
return true;
|
|
829
1168
|
}
|
|
830
1169
|
const result = deps.deleteRecipeContentFn(name);
|
|
1170
|
+
// Deleting a cron recipe leaves an orphaned interval in the scheduler
|
|
1171
|
+
// until the next start() — re-prime so it goes away.
|
|
1172
|
+
if (result.ok)
|
|
1173
|
+
fireOnRecipesChanged(deps);
|
|
831
1174
|
const status = result.ok
|
|
832
1175
|
? 200
|
|
833
1176
|
: result.error === "Recipe not found"
|
|
@@ -847,6 +1190,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
847
1190
|
return true;
|
|
848
1191
|
}
|
|
849
1192
|
const result = deps.archiveRecipeFn(name);
|
|
1193
|
+
// Archiving moves the recipe under .archive/ where the scheduler
|
|
1194
|
+
// ignores it — same orphan-interval cleanup needed as for delete.
|
|
1195
|
+
if (result.ok)
|
|
1196
|
+
fireOnRecipesChanged(deps);
|
|
850
1197
|
const status = result.ok
|
|
851
1198
|
? 200
|
|
852
1199
|
: result.error === "Recipe not found"
|
|
@@ -866,6 +1213,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
866
1213
|
return true;
|
|
867
1214
|
}
|
|
868
1215
|
const result = deps.duplicateRecipeFn(name);
|
|
1216
|
+
// Duplication adds a new recipe file to the dir — re-prime so any
|
|
1217
|
+
// cron trigger inside the duplicate starts firing immediately.
|
|
1218
|
+
if (result.ok)
|
|
1219
|
+
fireOnRecipesChanged(deps);
|
|
869
1220
|
const status = result.ok
|
|
870
1221
|
? 201
|
|
871
1222
|
: result.error === "Recipe not found"
|
|
@@ -883,10 +1234,21 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
883
1234
|
void (async () => {
|
|
884
1235
|
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
885
1236
|
if (!parsedBody.ok) {
|
|
886
|
-
|
|
1237
|
+
// #605: distinguish too_large (413) from invalid JSON (400) —
|
|
1238
|
+
// sibling routes already do this; promote was the only handler
|
|
1239
|
+
// collapsing both to 400.
|
|
1240
|
+
if (parsedBody.code === "too_large") {
|
|
1241
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
respondInvalidJson(res);
|
|
1245
|
+
}
|
|
887
1246
|
return;
|
|
888
1247
|
}
|
|
889
|
-
const
|
|
1248
|
+
const promoteBody = parsedBody.value ?? {};
|
|
1249
|
+
if (respondIfUnknownBodyKeys(res, promoteBody, ["targetName", "force"]))
|
|
1250
|
+
return;
|
|
1251
|
+
const { targetName, force } = promoteBody;
|
|
890
1252
|
if (typeof targetName !== "string" || !targetName.trim()) {
|
|
891
1253
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
892
1254
|
res.end(JSON.stringify({ ok: false, error: "targetName required" }));
|
|
@@ -901,6 +1263,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
901
1263
|
const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
|
|
902
1264
|
force: force === true,
|
|
903
1265
|
});
|
|
1266
|
+
// Promotion overwrites the canonical file with the variant's
|
|
1267
|
+
// contents — same scheduler refresh story as save/edit.
|
|
1268
|
+
if (result.ok)
|
|
1269
|
+
fireOnRecipesChanged(deps);
|
|
904
1270
|
const httpStatus = result.ok
|
|
905
1271
|
? 200
|
|
906
1272
|
: result.targetExists
|
|
@@ -937,19 +1303,42 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
937
1303
|
void (async () => {
|
|
938
1304
|
try {
|
|
939
1305
|
const now = Date.now();
|
|
940
|
-
if (
|
|
1306
|
+
if (templatesCache === null || now - templatesCacheTs > 5 * 60 * 1000) {
|
|
941
1307
|
const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
|
|
942
1308
|
if (!ghRes.ok) {
|
|
943
1309
|
throw new Error(`GitHub returned ${ghRes.status}`);
|
|
944
1310
|
}
|
|
945
|
-
|
|
1311
|
+
// #605: validate content-type AND shape before caching.
|
|
1312
|
+
// Previously we just `await ghRes.json()` and cached whatever
|
|
1313
|
+
// came back for 5 minutes — any error page (HTML/text JSON),
|
|
1314
|
+
// any MITM-tampered payload, or any future GitHub schema
|
|
1315
|
+
// change would poison the cache for every dashboard client.
|
|
1316
|
+
const ct = ghRes.headers.get("content-type") ?? "";
|
|
1317
|
+
if (!ct.includes("application/json") && !ct.includes("text/plain")) {
|
|
1318
|
+
throw new Error(`GitHub returned non-JSON content-type: ${ct}`);
|
|
1319
|
+
}
|
|
1320
|
+
const raw = (await ghRes.json());
|
|
1321
|
+
if (!isWellFormedTemplatesPayload(raw)) {
|
|
1322
|
+
throw new Error("GitHub payload failed shape validation");
|
|
1323
|
+
}
|
|
1324
|
+
templatesCache = raw;
|
|
946
1325
|
templatesCacheTs = now;
|
|
947
1326
|
}
|
|
1327
|
+
if (templatesCache === false) {
|
|
1328
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1329
|
+
res.end(JSON.stringify({ ok: false, error: "Upstream fetch failed" }));
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
948
1332
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
949
1333
|
res.end(JSON.stringify(templatesCache));
|
|
950
1334
|
}
|
|
951
1335
|
catch (err) {
|
|
952
|
-
console.error(`[recipes/
|
|
1336
|
+
console.error(`[recipes/templates] upstream error:`, err);
|
|
1337
|
+
// #605: negative cache — short window so an upstream 502 doesn't
|
|
1338
|
+
// pile up requests; clients see a fast 502 instead of waiting
|
|
1339
|
+
// for the next GH round-trip.
|
|
1340
|
+
templatesCache = false; // negative-cache sentinel — prevents !templatesCache bypass
|
|
1341
|
+
templatesCacheTs = Date.now() - 5 * 60 * 1000 + 30_000; // 30s before next try
|
|
953
1342
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
954
1343
|
res.end(JSON.stringify({
|
|
955
1344
|
ok: false,
|
|
@@ -972,6 +1361,23 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
972
1361
|
// an explicitly-allowlisted hostname STILL has to clear the SSRF check
|
|
973
1362
|
// (so an admin can't accidentally allowlist `localhost`).
|
|
974
1363
|
// ---------------------------------------------------------------------
|
|
1364
|
+
//
|
|
1365
|
+
// Marketplace trust Wave 0 (#782 follow-up): gate the route on the
|
|
1366
|
+
// global write kill-switch. Install writes recipe files to disk;
|
|
1367
|
+
// when an operator engages `PATCHWORK_FLAG_KILL_SWITCH_WRITES` or
|
|
1368
|
+
// flips the `kill-switch.writes` flag, this previously kept writing
|
|
1369
|
+
// anyway — a latent trust gap. Returns 503 with a code the
|
|
1370
|
+
// dashboard can match against to render the right banner.
|
|
1371
|
+
if (isWriteKillSwitchActive()) {
|
|
1372
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1373
|
+
res.end(JSON.stringify({
|
|
1374
|
+
ok: false,
|
|
1375
|
+
code: "kill_switch_blocked",
|
|
1376
|
+
error: "Recipe install blocked by kill switch (PATCHWORK_FLAG_KILL_SWITCH_WRITES / kill-switch.writes). " +
|
|
1377
|
+
"Unset the env var or POST /kill-switch with {engaged:false} to restore.",
|
|
1378
|
+
}));
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
975
1381
|
void (async () => {
|
|
976
1382
|
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
|
|
977
1383
|
if (!parsedBody.ok) {
|
|
@@ -993,59 +1399,59 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
993
1399
|
// -----------------------------------------------------------------
|
|
994
1400
|
// BUNDLE INSTALL DISPATCH (#130 PR A).
|
|
995
1401
|
//
|
|
996
|
-
// `github
|
|
1402
|
+
// `github:<owner>/<repo>/bundles/<name>` installs every recipe
|
|
997
1403
|
// listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
|
|
998
1404
|
// and policy template (`policy_template`) declared in the manifest
|
|
999
1405
|
// are surfaced as advisory-only — wiring those needs separate
|
|
1000
1406
|
// decisions (npm-install surface, policy application UX) tracked
|
|
1001
|
-
// outside this PR.
|
|
1407
|
+
// outside this PR.
|
|
1408
|
+
//
|
|
1409
|
+
// Org allowlist (#audit-thread): historically the path was hard-
|
|
1410
|
+
// coded to `patchworkos/recipes`. Now any allowlisted org/repo
|
|
1411
|
+
// can host a bundle; parse + validate via the shared helper so
|
|
1412
|
+
// single-recipe and bundle install share one source-of-truth.
|
|
1002
1413
|
// -----------------------------------------------------------------
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
}
|
|
1016
|
-
const manifestUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/bundles/${bundleName}/patchwork-bundle.json`;
|
|
1414
|
+
const bundleParse = source.startsWith("github:")
|
|
1415
|
+
? await (async () => {
|
|
1416
|
+
const { parseGithubInstallSource } = await import("./recipes/githubInstallSource.js");
|
|
1417
|
+
return parseGithubInstallSource(source);
|
|
1418
|
+
})()
|
|
1419
|
+
: null;
|
|
1420
|
+
if (bundleParse?.ok && bundleParse.parsed.kind === "bundle") {
|
|
1421
|
+
const { buildGithubRawUrl, fetchGithubInstallFile, SEGMENT_RE } = await import("./recipes/githubInstallSource.js");
|
|
1422
|
+
const bundleName = bundleParse.parsed.name;
|
|
1423
|
+
const bundleOwner = bundleParse.parsed.owner;
|
|
1424
|
+
const bundleRepo = bundleParse.parsed.repo;
|
|
1425
|
+
const manifestUrl = buildGithubRawUrl(bundleParse.parsed);
|
|
1017
1426
|
const ctl = new AbortController();
|
|
1018
1427
|
const timeout = setTimeout(() => ctl.abort(), 30_000);
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
signal: ctl.signal,
|
|
1023
|
-
redirect: "follow",
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
catch (err) {
|
|
1027
|
-
clearTimeout(timeout);
|
|
1028
|
-
console.error(`[recipes/install] bundle manifest fetch failed:`, err);
|
|
1029
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1030
|
-
res.end(JSON.stringify({
|
|
1031
|
-
ok: false,
|
|
1032
|
-
error: "Bundle manifest fetch failed",
|
|
1033
|
-
code: "bundle_fetch_network_error",
|
|
1034
|
-
}));
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1428
|
+
// Raw-first / api.github.com-fallback: raw.githubusercontent.com
|
|
1429
|
+
// is blocked on many corporate / proxied networks.
|
|
1430
|
+
const manifestFetched = await fetchGithubInstallFile(bundleParse.parsed, { signal: ctl.signal });
|
|
1037
1431
|
clearTimeout(timeout);
|
|
1038
|
-
if (!
|
|
1039
|
-
|
|
1432
|
+
if (!manifestFetched.ok) {
|
|
1433
|
+
if (manifestFetched.kind === "network_error") {
|
|
1434
|
+
console.error(`[recipes/install] bundle manifest fetch failed (raw + api):`, manifestFetched.error);
|
|
1435
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1436
|
+
res.end(JSON.stringify({
|
|
1437
|
+
ok: false,
|
|
1438
|
+
error: "Bundle manifest fetch failed",
|
|
1439
|
+
code: "bundle_fetch_network_error",
|
|
1440
|
+
}));
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
const failRes = manifestFetched.response;
|
|
1444
|
+
const outStatus = failRes.status === 404 ? 404 : 502;
|
|
1040
1445
|
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1041
1446
|
res.end(JSON.stringify({
|
|
1042
1447
|
ok: false,
|
|
1043
|
-
error: `Bundle manifest at ${manifestUrl} returned ${
|
|
1448
|
+
error: `Bundle manifest at ${manifestUrl} returned ${failRes.status}`,
|
|
1044
1449
|
code: "bundle_fetch_upstream_error",
|
|
1045
|
-
upstreamStatus:
|
|
1450
|
+
upstreamStatus: failRes.status,
|
|
1046
1451
|
}));
|
|
1047
1452
|
return;
|
|
1048
1453
|
}
|
|
1454
|
+
const manifestRes = manifestFetched.response;
|
|
1049
1455
|
// 64 KB hard cap on manifest body — real `patchwork-bundle.json`
|
|
1050
1456
|
// is single-digit KB; anything past 64 KB is hostile or malformed.
|
|
1051
1457
|
const manifestBuf = await manifestRes.arrayBuffer();
|
|
@@ -1072,6 +1478,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1072
1478
|
}));
|
|
1073
1479
|
return;
|
|
1074
1480
|
}
|
|
1481
|
+
// Validate each declared recipe basename to block traversal +
|
|
1482
|
+
// junk segments. `isSafeBasename` lives in the legacy recipe-
|
|
1483
|
+
// install command but the predicate is the right shape here.
|
|
1484
|
+
const { isSafeBasename } = await import("./commands/recipeInstall.js");
|
|
1075
1485
|
if (!Array.isArray(manifest.recipes) ||
|
|
1076
1486
|
manifest.recipes.length === 0 ||
|
|
1077
1487
|
!manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
|
|
@@ -1088,27 +1498,57 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1088
1498
|
// all-or-nothing when one of N recipes is broken.
|
|
1089
1499
|
const installed = [];
|
|
1090
1500
|
const failures = [];
|
|
1091
|
-
|
|
1501
|
+
// Wave 1 fix: aggregate connector preflight across every
|
|
1502
|
+
// bundle-installed recipe so the dashboard can surface a
|
|
1503
|
+
// single "you need to connect Gmail + Slack + Calendar before
|
|
1504
|
+
// running these" notice, matching the single-recipe install's
|
|
1505
|
+
// missingConnectors behaviour. Previously bundle installs
|
|
1506
|
+
// returned no preflight hint → users hit first-run "no Slack
|
|
1507
|
+
// token" surprise on every recipe.
|
|
1508
|
+
const aggregatedMissingConnectors = new Set();
|
|
1509
|
+
const { writeFileSync, mkdirSync, unlinkSync, readFileSync } = await import("node:fs");
|
|
1092
1510
|
const { installRecipeFromFile } = await import("./recipes/installer.js");
|
|
1093
1511
|
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1094
1512
|
mkdirSync(recipesDir, { recursive: true });
|
|
1095
1513
|
for (const r of manifest.recipes) {
|
|
1096
|
-
|
|
1514
|
+
// The recipe name comes from the bundle manifest's JSON body
|
|
1515
|
+
// (GitHub-hosted, but still external input). Validate it
|
|
1516
|
+
// before it reaches URL construction in fetchGithubInstallFile
|
|
1517
|
+
// — the `github:` source path gets this check via
|
|
1518
|
+
// parseGithubInstallSource, but the bundle loop builds the
|
|
1519
|
+
// struct directly and would otherwise bypass it.
|
|
1520
|
+
if (typeof r !== "string" || !SEGMENT_RE.test(r.toLowerCase())) {
|
|
1521
|
+
failures.push({
|
|
1522
|
+
name: String(r),
|
|
1523
|
+
error: "invalid recipe name in bundle manifest",
|
|
1524
|
+
});
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
// Bundle's manifest is allowed to declare recipes that
|
|
1528
|
+
// live in the same repo as the bundle. Fetch with the
|
|
1529
|
+
// parsed owner/repo via the raw-first / api.github.com
|
|
1530
|
+
// fallback (raw host blocked on many proxied networks).
|
|
1097
1531
|
const recipeCtl = new AbortController();
|
|
1098
1532
|
const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
|
|
1099
1533
|
try {
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1534
|
+
const recipeFetched = await fetchGithubInstallFile({
|
|
1535
|
+
kind: "recipe",
|
|
1536
|
+
owner: bundleOwner,
|
|
1537
|
+
repo: bundleRepo,
|
|
1538
|
+
name: r,
|
|
1539
|
+
}, { signal: recipeCtl.signal });
|
|
1104
1540
|
clearTimeout(recipeTimeout);
|
|
1105
|
-
if (!
|
|
1541
|
+
if (!recipeFetched.ok) {
|
|
1542
|
+
if (recipeFetched.kind === "network_error") {
|
|
1543
|
+
throw recipeFetched.error;
|
|
1544
|
+
}
|
|
1106
1545
|
failures.push({
|
|
1107
1546
|
name: r,
|
|
1108
|
-
error: `Upstream returned ${
|
|
1547
|
+
error: `Upstream returned ${recipeFetched.response.status}`,
|
|
1109
1548
|
});
|
|
1110
1549
|
continue;
|
|
1111
1550
|
}
|
|
1551
|
+
const recipeRes = recipeFetched.response;
|
|
1112
1552
|
const recipeBuf = await recipeRes.arrayBuffer();
|
|
1113
1553
|
if (recipeBuf.byteLength > 1024 * 1024) {
|
|
1114
1554
|
failures.push({
|
|
@@ -1118,13 +1558,47 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1118
1558
|
continue;
|
|
1119
1559
|
}
|
|
1120
1560
|
const yamlText = Buffer.from(recipeBuf).toString("utf-8");
|
|
1121
|
-
|
|
1561
|
+
// #605: same race-fix as /recipes/install — embed pid +
|
|
1562
|
+
// randomBytes so concurrent bundle installs of the same
|
|
1563
|
+
// recipe inside one millisecond don't collide.
|
|
1564
|
+
const { randomBytes: randomBytesFn } = await import("node:crypto");
|
|
1565
|
+
const tmpFile = path.join(os.tmpdir(), `patchwork-bundle-install-${process.pid}-${Date.now()}-${randomBytesFn(6).toString("hex")}-${r}.yaml`);
|
|
1122
1566
|
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1123
1567
|
try {
|
|
1124
1568
|
const installResult = installRecipeFromFile(tmpFile, {
|
|
1125
1569
|
recipesDir,
|
|
1126
1570
|
});
|
|
1127
1571
|
installed.push({ name: r, action: installResult.action });
|
|
1572
|
+
// Per-recipe connector preflight — same shape as the
|
|
1573
|
+
// single-recipe path (lines ~2005+). Defensive: any
|
|
1574
|
+
// failure must not roll back the install.
|
|
1575
|
+
try {
|
|
1576
|
+
const installedJson = readFileSync(installResult.installedPath, "utf-8");
|
|
1577
|
+
const recipeForPreflight = JSON.parse(installedJson);
|
|
1578
|
+
if (Array.isArray(recipeForPreflight.steps)) {
|
|
1579
|
+
const { detectRequiredConnectors, findMissingConnectors } = await import("./recipes/connectorPreflight.js");
|
|
1580
|
+
const required = detectRequiredConnectors(recipeForPreflight);
|
|
1581
|
+
if (required.length > 0) {
|
|
1582
|
+
const { handleConnectionsList } = await import("./connectors/gmail.js");
|
|
1583
|
+
const connsResult = await handleConnectionsList();
|
|
1584
|
+
let connections = [];
|
|
1585
|
+
try {
|
|
1586
|
+
const body = JSON.parse(connsResult.body);
|
|
1587
|
+
connections = body.connectors ?? [];
|
|
1588
|
+
}
|
|
1589
|
+
catch {
|
|
1590
|
+
/* malformed body — treat as no connections */
|
|
1591
|
+
}
|
|
1592
|
+
const missing = findMissingConnectors(required, connections);
|
|
1593
|
+
for (const id of missing) {
|
|
1594
|
+
aggregatedMissingConnectors.add(id);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
catch (preflightErr) {
|
|
1600
|
+
console.warn(`[recipes/install] bundle preflight for "${r}" failed (non-blocking):`, preflightErr);
|
|
1601
|
+
}
|
|
1128
1602
|
}
|
|
1129
1603
|
finally {
|
|
1130
1604
|
try {
|
|
@@ -1155,6 +1629,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1155
1629
|
// 200 if any recipe installed; 502 otherwise. Always include both
|
|
1156
1630
|
// arrays so callers (CLI + dashboard) can render partial-success.
|
|
1157
1631
|
const status = installed.length > 0 ? 200 : 502;
|
|
1632
|
+
// Notify the scheduler so cron-trigger recipes in the bundle
|
|
1633
|
+
// start firing without a bridge restart. Fired once per bundle
|
|
1634
|
+
// (not per recipe inside) since scheduler.start() reads the
|
|
1635
|
+
// whole recipes dir anyway. Guarded by the partial-success
|
|
1636
|
+
// check — no point waking up the scheduler for a 0-installed
|
|
1637
|
+
// failure.
|
|
1638
|
+
if (installed.length > 0)
|
|
1639
|
+
fireOnRecipesChanged(deps);
|
|
1158
1640
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1159
1641
|
res.end(JSON.stringify({
|
|
1160
1642
|
ok: installed.length > 0,
|
|
@@ -1162,28 +1644,54 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1162
1644
|
bundleName,
|
|
1163
1645
|
installed,
|
|
1164
1646
|
failures,
|
|
1647
|
+
...(aggregatedMissingConnectors.size > 0 && {
|
|
1648
|
+
missingConnectors: Array.from(aggregatedMissingConnectors),
|
|
1649
|
+
}),
|
|
1165
1650
|
...(Object.keys(advisory).length > 0 && { advisory }),
|
|
1166
1651
|
}));
|
|
1167
1652
|
return;
|
|
1168
1653
|
}
|
|
1169
|
-
const githubPrefix = "github:patchworkos/recipes/recipes/";
|
|
1170
1654
|
let fetchUrl;
|
|
1171
1655
|
let recipeName;
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1656
|
+
// When the source is a `github:` shorthand we keep the parsed
|
|
1657
|
+
// form around so the fetch leg below can use the raw-first /
|
|
1658
|
+
// api.github.com-fallback strategy (raw is blocked on many
|
|
1659
|
+
// corporate / proxied networks). Non-github `https://` sources
|
|
1660
|
+
// fetch the URL directly, unchanged.
|
|
1661
|
+
let githubParsed = null;
|
|
1662
|
+
if (source.startsWith("github:")) {
|
|
1663
|
+
// Parse the new generalised shape (any allowlisted org/repo)
|
|
1664
|
+
// instead of only `github:patchworkos/recipes/recipes/<name>`.
|
|
1665
|
+
// Distinguishes bad shape (400) from not-on-allowlist (403)
|
|
1666
|
+
// so operators can spot a config error vs. a typo.
|
|
1667
|
+
const { parseGithubInstallSource, buildGithubRawUrl } = await import("./recipes/githubInstallSource.js");
|
|
1668
|
+
const parsed = parseGithubInstallSource(source);
|
|
1669
|
+
if (!parsed.ok) {
|
|
1670
|
+
const status = parsed.code === "not_allowlisted" ? 403 : 400;
|
|
1671
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1672
|
+
res.end(JSON.stringify({
|
|
1673
|
+
ok: false,
|
|
1674
|
+
error: parsed.error,
|
|
1675
|
+
code: parsed.code,
|
|
1676
|
+
}));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
// Bundle shape on /recipes/install (single-recipe path) is a
|
|
1680
|
+
// mistake — surface it explicitly rather than silently fetching
|
|
1681
|
+
// an unrelated URL. Bundle installs have their own code path
|
|
1682
|
+
// above this block.
|
|
1683
|
+
if (parsed.parsed.kind === "bundle") {
|
|
1179
1684
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1180
1685
|
res.end(JSON.stringify({
|
|
1181
1686
|
ok: false,
|
|
1182
|
-
error: "
|
|
1687
|
+
error: "Bundle source on single-recipe install. Use the bundle install path.",
|
|
1688
|
+
code: "bad_shape",
|
|
1183
1689
|
}));
|
|
1184
1690
|
return;
|
|
1185
1691
|
}
|
|
1186
|
-
|
|
1692
|
+
recipeName = parsed.parsed.name;
|
|
1693
|
+
githubParsed = parsed.parsed;
|
|
1694
|
+
fetchUrl = buildGithubRawUrl(parsed.parsed);
|
|
1187
1695
|
}
|
|
1188
1696
|
else if (source.startsWith("https://")) {
|
|
1189
1697
|
// Non-github source: must clear the env-var allowlist AND the SSRF
|
|
@@ -1253,43 +1761,88 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1253
1761
|
const fetchCtl = new AbortController();
|
|
1254
1762
|
const fetchTimeout = setTimeout(() => fetchCtl.abort(), 30_000);
|
|
1255
1763
|
let yamlRes;
|
|
1256
|
-
|
|
1257
|
-
|
|
1764
|
+
if (githubParsed) {
|
|
1765
|
+
// github: source — try raw.githubusercontent.com first, then
|
|
1766
|
+
// fall back to api.github.com/contents (raw host is blocked on
|
|
1767
|
+
// many corporate / proxied networks). The fallback collapses
|
|
1768
|
+
// back into the SAME error contract as the direct path below.
|
|
1769
|
+
const { fetchGithubInstallFile } = await import("./recipes/githubInstallSource.js");
|
|
1770
|
+
const fetched = await fetchGithubInstallFile(githubParsed, {
|
|
1258
1771
|
signal: fetchCtl.signal,
|
|
1259
|
-
redirect: "follow",
|
|
1260
1772
|
});
|
|
1261
|
-
}
|
|
1262
|
-
catch (err) {
|
|
1263
1773
|
clearTimeout(fetchTimeout);
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1774
|
+
if (!fetched.ok) {
|
|
1775
|
+
if (fetched.kind === "network_error") {
|
|
1776
|
+
console.error(`[recipes/install] fetch failed (raw + api):`, fetched.error);
|
|
1777
|
+
// Network-level error → 502 (upstream unreachable), not 500.
|
|
1778
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1779
|
+
res.end(JSON.stringify({
|
|
1780
|
+
ok: false,
|
|
1781
|
+
error: "Fetch failed",
|
|
1782
|
+
code: "fetch_network_error",
|
|
1783
|
+
}));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
// not_found / upstream_error — translate the HTTP status the
|
|
1787
|
+
// same way the direct path does.
|
|
1788
|
+
const failRes = fetched.response;
|
|
1789
|
+
let outStatus = 502;
|
|
1790
|
+
if (failRes.status === 404)
|
|
1791
|
+
outStatus = 404;
|
|
1792
|
+
else if (failRes.status === 403)
|
|
1793
|
+
outStatus = 403;
|
|
1794
|
+
else if (failRes.status >= 400 && failRes.status < 500)
|
|
1795
|
+
outStatus = 400;
|
|
1796
|
+
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1797
|
+
res.end(JSON.stringify({
|
|
1798
|
+
ok: false,
|
|
1799
|
+
error: `Upstream returned ${failRes.status} ${failRes.statusText}`,
|
|
1800
|
+
code: "fetch_upstream_error",
|
|
1801
|
+
upstreamStatus: failRes.status,
|
|
1802
|
+
}));
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
yamlRes = fetched.response;
|
|
1273
1806
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1807
|
+
else {
|
|
1808
|
+
try {
|
|
1809
|
+
yamlRes = await fetch(fetchUrl, {
|
|
1810
|
+
signal: fetchCtl.signal,
|
|
1811
|
+
redirect: "follow",
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
catch (err) {
|
|
1815
|
+
clearTimeout(fetchTimeout);
|
|
1816
|
+
console.error(`[recipes/install] fetch failed:`, err);
|
|
1817
|
+
// Network-level error → 502 (upstream unreachable), not 500.
|
|
1818
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1819
|
+
res.end(JSON.stringify({
|
|
1820
|
+
ok: false,
|
|
1821
|
+
error: "Fetch failed",
|
|
1822
|
+
code: "fetch_network_error",
|
|
1823
|
+
}));
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
clearTimeout(fetchTimeout);
|
|
1827
|
+
if (!yamlRes.ok) {
|
|
1828
|
+
// Translate upstream HTTP into proper status — 404→404, 403→403,
|
|
1829
|
+
// 5xx→502 (don't leak the upstream 500 as our 500). R2 H-routes Bug 2.
|
|
1830
|
+
let outStatus = 502;
|
|
1831
|
+
if (yamlRes.status === 404)
|
|
1832
|
+
outStatus = 404;
|
|
1833
|
+
else if (yamlRes.status === 403)
|
|
1834
|
+
outStatus = 403;
|
|
1835
|
+
else if (yamlRes.status >= 400 && yamlRes.status < 500)
|
|
1836
|
+
outStatus = 400;
|
|
1837
|
+
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1838
|
+
res.end(JSON.stringify({
|
|
1839
|
+
ok: false,
|
|
1840
|
+
error: `Upstream returned ${yamlRes.status} ${yamlRes.statusText}`,
|
|
1841
|
+
code: "fetch_upstream_error",
|
|
1842
|
+
upstreamStatus: yamlRes.status,
|
|
1843
|
+
}));
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1293
1846
|
}
|
|
1294
1847
|
// Streamed read with 1 MB cap (mirrors `httpClient` pattern).
|
|
1295
1848
|
const MAX_RECIPE_BYTES = 1024 * 1024;
|
|
@@ -1326,7 +1879,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1326
1879
|
return;
|
|
1327
1880
|
}
|
|
1328
1881
|
const yamlText = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
|
|
1329
|
-
|
|
1882
|
+
// #605: temp path must be unique per process+request. Previous
|
|
1883
|
+
// form was `Date.now()-${recipeName}` — two concurrent installs
|
|
1884
|
+
// of the same recipe inside one millisecond produced identical
|
|
1885
|
+
// paths → writeFileSync interleaved bytes, and the first finally
|
|
1886
|
+
// block unlinked the file the second still needed.
|
|
1887
|
+
const { randomBytes } = await import("node:crypto");
|
|
1888
|
+
const tmpFile = path.join(os.tmpdir(), `patchwork-install-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}-${recipeName}.yaml`);
|
|
1330
1889
|
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1331
1890
|
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1332
1891
|
let result;
|
|
@@ -1337,7 +1896,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1337
1896
|
const installResult = installRecipeFromFile(tmpFile, {
|
|
1338
1897
|
recipesDir,
|
|
1339
1898
|
});
|
|
1340
|
-
|
|
1899
|
+
// Soft preflight: detect which connectors the recipe uses
|
|
1900
|
+
// and surface the unconfigured ones as a warning. The recipe
|
|
1901
|
+
// is already on disk — this is a hint for the dashboard to
|
|
1902
|
+
// prompt "you'll need to connect Slack + Gmail to run this",
|
|
1903
|
+
// not a gate on the install itself. Defensive: any failure
|
|
1904
|
+
// here MUST NOT roll the install back, so the whole block is
|
|
1905
|
+
// wrapped in try/catch.
|
|
1906
|
+
let missingConnectors;
|
|
1907
|
+
try {
|
|
1908
|
+
const { readFileSync } = await import("node:fs");
|
|
1909
|
+
const installedJson = readFileSync(installResult.installedPath, "utf-8");
|
|
1910
|
+
const recipe = JSON.parse(installedJson);
|
|
1911
|
+
if (Array.isArray(recipe.steps)) {
|
|
1912
|
+
const { detectRequiredConnectors, findMissingConnectors } = await import("./recipes/connectorPreflight.js");
|
|
1913
|
+
const required = detectRequiredConnectors(recipe);
|
|
1914
|
+
if (required.length > 0) {
|
|
1915
|
+
const { handleConnectionsList } = await import("./connectors/gmail.js");
|
|
1916
|
+
const connsResult = await handleConnectionsList();
|
|
1917
|
+
let connections = [];
|
|
1918
|
+
try {
|
|
1919
|
+
const body = JSON.parse(connsResult.body);
|
|
1920
|
+
connections = body.connectors ?? [];
|
|
1921
|
+
}
|
|
1922
|
+
catch {
|
|
1923
|
+
/* malformed body — treat as no connections */
|
|
1924
|
+
}
|
|
1925
|
+
const missing = findMissingConnectors(required, connections);
|
|
1926
|
+
if (missing.length > 0)
|
|
1927
|
+
missingConnectors = missing;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
catch (preflightErr) {
|
|
1932
|
+
console.warn(`[recipes/install] connector preflight failed (non-blocking):`, preflightErr);
|
|
1933
|
+
}
|
|
1934
|
+
result = {
|
|
1935
|
+
action: installResult.action,
|
|
1936
|
+
name: recipeName,
|
|
1937
|
+
...(missingConnectors ? { missingConnectors } : {}),
|
|
1938
|
+
};
|
|
1341
1939
|
}
|
|
1342
1940
|
finally {
|
|
1343
1941
|
try {
|
|
@@ -1347,11 +1945,42 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1347
1945
|
// best-effort cleanup
|
|
1348
1946
|
}
|
|
1349
1947
|
}
|
|
1948
|
+
// Notify the scheduler so the new recipe's cron/webhook trigger
|
|
1949
|
+
// starts firing without a bridge restart. The recipe file is
|
|
1950
|
+
// already on disk (`writeFileSync` above), so the next
|
|
1951
|
+
// `scheduler.start()` will pick it up via its directory scan.
|
|
1952
|
+
// Errors here are logged but never surface to the caller — the
|
|
1953
|
+
// install itself succeeded; a scheduler restart bug must not
|
|
1954
|
+
// make the response look failed.
|
|
1955
|
+
fireOnRecipesChanged(deps);
|
|
1350
1956
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1351
1957
|
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1352
1958
|
}
|
|
1353
1959
|
catch (err) {
|
|
1354
|
-
//
|
|
1960
|
+
// Distinguish "the recipe YAML is malformed" (user-actionable, 400)
|
|
1961
|
+
// from "the installer itself crashed" (server bug, 500). Before this
|
|
1962
|
+
// every parser error came back as the same opaque 500 — dashboards
|
|
1963
|
+
// surfaced "Internal server error" with no way to know what was wrong.
|
|
1964
|
+
const errName = err instanceof Error ? err.name : "";
|
|
1965
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1966
|
+
const isParseError = errName === "RecipeParseError" ||
|
|
1967
|
+
// js-yaml / the `yaml` package both throw YAMLException / YAMLParseError.
|
|
1968
|
+
errName === "YAMLException" ||
|
|
1969
|
+
errName === "YAMLParseError" ||
|
|
1970
|
+
/yaml/i.test(errName);
|
|
1971
|
+
if (isParseError) {
|
|
1972
|
+
// Return only the first line of the parser message — strips any
|
|
1973
|
+
// embedded file path or stack frame that downstream parsers
|
|
1974
|
+
// sometimes include (CodeQL: js/stack-trace-exposure).
|
|
1975
|
+
const safeMsg = errMsg.split("\n", 1)[0]?.slice(0, 500) ?? "invalid recipe";
|
|
1976
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1977
|
+
res.end(JSON.stringify({
|
|
1978
|
+
ok: false,
|
|
1979
|
+
error: safeMsg,
|
|
1980
|
+
code: "invalid_recipe",
|
|
1981
|
+
}));
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1355
1984
|
console.error(`[recipes/install] internal install error:`, err);
|
|
1356
1985
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1357
1986
|
res.end(JSON.stringify({
|