patchwork-os 0.2.0-beta.0 → 0.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.bridge.md +14 -14
- package/README.md +204 -34
- package/deploy/README.md +1 -1
- package/deploy/bootstrap-vps.sh +14 -9
- package/deploy/deploy-dashboard.sh +25 -1
- package/deploy/macos/README.md +153 -0
- package/deploy/macos/com.patchwork.bridge.plist.template +54 -0
- package/deploy/macos/com.patchwork.tunnel.plist.template +76 -0
- package/deploy/macos/install-mac-bridge.sh +244 -0
- package/deploy/macos/uninstall-mac-bridge.sh +22 -0
- package/dist/activityLog.d.ts +6 -0
- package/dist/activityLog.js +63 -26
- package/dist/activityLog.js.map +1 -1
- package/dist/adapters/claude.js +22 -16
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/gemini.js +15 -11
- package/dist/adapters/gemini.js.map +1 -1
- package/dist/adapters/openai.js +9 -9
- package/dist/adapters/openai.js.map +1 -1
- package/dist/ajv2020.d.ts +25 -0
- package/dist/ajv2020.js +33 -0
- package/dist/ajv2020.js.map +1 -0
- package/dist/analyticsAggregator.d.ts +5 -1
- package/dist/analyticsAggregator.js +16 -5
- package/dist/analyticsAggregator.js.map +1 -1
- package/dist/analyticsConfig.d.ts +29 -0
- package/dist/analyticsConfig.js +102 -0
- package/dist/analyticsConfig.js.map +1 -0
- package/dist/analyticsPrefs.d.ts +49 -2
- package/dist/analyticsPrefs.js +177 -19
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/analyticsSend.d.ts +17 -1
- package/dist/analyticsSend.js +67 -5
- package/dist/analyticsSend.js.map +1 -1
- package/dist/approvalHttp.d.ts +14 -0
- package/dist/approvalHttp.js +212 -9
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalInsights.js +13 -5
- package/dist/approvalInsights.js.map +1 -1
- package/dist/approvalQueue.d.ts +97 -3
- package/dist/approvalQueue.js +193 -19
- package/dist/approvalQueue.js.map +1 -1
- package/dist/approvalSignals.js +19 -11
- package/dist/approvalSignals.js.map +1 -1
- package/dist/automation.d.ts +35 -10
- package/dist/automation.js +133 -37
- package/dist/automation.js.map +1 -1
- package/dist/automationSuggestions.js +10 -8
- package/dist/automationSuggestions.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +238 -51
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +27 -1
- package/dist/bridgeLockDiscovery.js +38 -11
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/bridgeToken.js +3 -2
- package/dist/bridgeToken.js.map +1 -1
- package/dist/claudeDriver.d.ts +0 -16
- package/dist/claudeDriver.js +42 -28
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeMdPatch.d.ts +9 -3
- package/dist/claudeMdPatch.js +79 -13
- package/dist/claudeMdPatch.js.map +1 -1
- package/dist/claudeOrchestrator.d.ts +13 -1
- package/dist/claudeOrchestrator.js +89 -44
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/analytics.d.ts +8 -0
- package/dist/commands/analytics.js +134 -0
- package/dist/commands/analytics.js.map +1 -0
- package/dist/commands/auditEnv.d.ts +36 -0
- package/dist/commands/auditEnv.js +202 -0
- package/dist/commands/auditEnv.js.map +1 -0
- package/dist/commands/dashboard.js +8 -1
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/doctor.d.ts +35 -0
- package/dist/commands/doctor.js +51 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/install.js +3 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/launchd.js +15 -16
- package/dist/commands/launchd.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +5 -0
- package/dist/commands/patchworkInit.js +89 -7
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +110 -0
- package/dist/commands/recipe.js +512 -9
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +11 -4
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/shadowScan.d.ts +34 -0
- package/dist/commands/shadowScan.js +142 -0
- package/dist/commands/shadowScan.js.map +1 -0
- package/dist/commands/task.js +2 -2
- package/dist/commands/task.js.map +1 -1
- package/dist/commands/tools.d.ts +20 -1
- package/dist/commands/tools.js +112 -3
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/tracesImport.js +21 -4
- package/dist/commands/tracesImport.js.map +1 -1
- package/dist/commitIssueLinkLog.d.ts +24 -0
- package/dist/commitIssueLinkLog.js +160 -22
- package/dist/commitIssueLinkLog.js.map +1 -1
- package/dist/companions/registry.js +15 -7
- package/dist/companions/registry.js.map +1 -1
- package/dist/config.d.ts +49 -5
- package/dist/config.js +123 -22
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +913 -413
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/airtable.d.ts +230 -0
- package/dist/connectors/airtable.js +700 -0
- package/dist/connectors/airtable.js.map +1 -0
- package/dist/connectors/asana.js +13 -9
- package/dist/connectors/asana.js.map +1 -1
- package/dist/connectors/baseConnector.js +25 -3
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/caldiy.d.ts +137 -0
- package/dist/connectors/caldiy.js +424 -0
- package/dist/connectors/caldiy.js.map +1 -0
- package/dist/connectors/circleci.d.ts +162 -0
- package/dist/connectors/circleci.js +576 -0
- package/dist/connectors/circleci.js.map +1 -0
- package/dist/connectors/cloudflare.d.ts +132 -0
- package/dist/connectors/cloudflare.js +622 -0
- package/dist/connectors/cloudflare.js.map +1 -0
- package/dist/connectors/confluence.js +35 -0
- package/dist/connectors/confluence.js.map +1 -1
- package/dist/connectors/connectorRedirectUri.d.ts +30 -0
- package/dist/connectors/connectorRedirectUri.js +38 -0
- package/dist/connectors/connectorRedirectUri.js.map +1 -0
- package/dist/connectors/connectorRegistry.d.ts +63 -0
- package/dist/connectors/connectorRegistry.js +354 -0
- package/dist/connectors/connectorRegistry.js.map +1 -0
- package/dist/connectors/datadog.js +33 -4
- package/dist/connectors/datadog.js.map +1 -1
- package/dist/connectors/discord.js +14 -10
- package/dist/connectors/discord.js.map +1 -1
- package/dist/connectors/elasticsearch.d.ts +141 -0
- package/dist/connectors/elasticsearch.js +601 -0
- package/dist/connectors/elasticsearch.js.map +1 -0
- package/dist/connectors/figma.d.ts +130 -0
- package/dist/connectors/figma.js +387 -0
- package/dist/connectors/figma.js.map +1 -0
- package/dist/connectors/github.d.ts +17 -0
- package/dist/connectors/github.js +53 -2
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.js +12 -6
- package/dist/connectors/gitlab.js.map +1 -1
- package/dist/connectors/gmail.js +72 -21
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.js +8 -8
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/connectors/googleDocs.d.ts +103 -0
- package/dist/connectors/googleDocs.js +409 -0
- package/dist/connectors/googleDocs.js.map +1 -0
- package/dist/connectors/googleDrive.d.ts +12 -0
- package/dist/connectors/googleDrive.js +35 -8
- package/dist/connectors/googleDrive.js.map +1 -1
- package/dist/connectors/grafana.d.ts +133 -0
- package/dist/connectors/grafana.js +478 -0
- package/dist/connectors/grafana.js.map +1 -0
- package/dist/connectors/jira.d.ts +25 -0
- package/dist/connectors/jira.js +245 -2
- package/dist/connectors/jira.js.map +1 -1
- package/dist/connectors/linear.js +1 -1
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +78 -16
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/monday.d.ts +217 -0
- package/dist/connectors/monday.js +655 -0
- package/dist/connectors/monday.js.map +1 -0
- package/dist/connectors/mongodb.d.ts +139 -0
- package/dist/connectors/mongodb.js +455 -0
- package/dist/connectors/mongodb.js.map +1 -0
- package/dist/connectors/oauthStateStore.d.ts +20 -0
- package/dist/connectors/oauthStateStore.js +94 -4
- package/dist/connectors/oauthStateStore.js.map +1 -1
- package/dist/connectors/obsidian.d.ts +83 -0
- package/dist/connectors/obsidian.js +441 -0
- package/dist/connectors/obsidian.js.map +1 -0
- package/dist/connectors/paystack.d.ts +159 -0
- package/dist/connectors/paystack.js +607 -0
- package/dist/connectors/paystack.js.map +1 -0
- package/dist/connectors/pipedrive.d.ts +189 -0
- package/dist/connectors/pipedrive.js +559 -0
- package/dist/connectors/pipedrive.js.map +1 -0
- package/dist/connectors/postgres.d.ts +127 -0
- package/dist/connectors/postgres.js +512 -0
- package/dist/connectors/postgres.js.map +1 -0
- package/dist/connectors/posthog.d.ts +119 -0
- package/dist/connectors/posthog.js +479 -0
- package/dist/connectors/posthog.js.map +1 -0
- package/dist/connectors/redis.d.ts +140 -0
- package/dist/connectors/redis.js +571 -0
- package/dist/connectors/redis.js.map +1 -0
- package/dist/connectors/resend.d.ts +131 -0
- package/dist/connectors/resend.js +529 -0
- package/dist/connectors/resend.js.map +1 -0
- package/dist/connectors/salesforce.d.ts +136 -0
- package/dist/connectors/salesforce.js +603 -0
- package/dist/connectors/salesforce.js.map +1 -0
- package/dist/connectors/secrets.d.ts +51 -0
- package/dist/connectors/secrets.js +102 -0
- package/dist/connectors/secrets.js.map +1 -0
- package/dist/connectors/sendgrid.d.ts +102 -0
- package/dist/connectors/sendgrid.js +423 -0
- package/dist/connectors/sendgrid.js.map +1 -0
- package/dist/connectors/shopify.d.ts +145 -0
- package/dist/connectors/shopify.js +502 -0
- package/dist/connectors/shopify.js.map +1 -0
- package/dist/connectors/slack.d.ts +1 -1
- package/dist/connectors/slack.js +61 -9
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/snowflake.d.ts +119 -0
- package/dist/connectors/snowflake.js +615 -0
- package/dist/connectors/snowflake.js.map +1 -0
- package/dist/connectors/supabase.d.ts +92 -0
- package/dist/connectors/supabase.js +630 -0
- package/dist/connectors/supabase.js.map +1 -0
- package/dist/connectors/todoist.d.ts +117 -0
- package/dist/connectors/todoist.js +485 -0
- package/dist/connectors/todoist.js.map +1 -0
- package/dist/connectors/tokenStorage.js +56 -14
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/connectors/twilio.d.ts +118 -0
- package/dist/connectors/twilio.js +475 -0
- package/dist/connectors/twilio.js.map +1 -0
- package/dist/connectors/vercel.d.ts +110 -0
- package/dist/connectors/vercel.js +479 -0
- package/dist/connectors/vercel.js.map +1 -0
- package/dist/connectors/webflow.d.ts +118 -0
- package/dist/connectors/webflow.js +393 -0
- package/dist/connectors/webflow.js.map +1 -0
- package/dist/connectors/woocommerce.d.ts +220 -0
- package/dist/connectors/woocommerce.js +464 -0
- package/dist/connectors/woocommerce.js.map +1 -0
- package/dist/crypto.js +7 -2
- package/dist/crypto.js.map +1 -1
- package/dist/decisionReplay.js +15 -6
- package/dist/decisionReplay.js.map +1 -1
- package/dist/decisionTraceLog.d.ts +28 -0
- package/dist/decisionTraceLog.js +156 -29
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/drivers/claude/api.js +5 -4
- package/dist/drivers/claude/api.js.map +1 -1
- package/dist/drivers/claude/envSanitizer.d.ts +0 -6
- package/dist/drivers/claude/envSanitizer.js +17 -2
- package/dist/drivers/claude/envSanitizer.js.map +1 -1
- package/dist/drivers/claude/streamParser.js +5 -4
- package/dist/drivers/claude/streamParser.js.map +1 -1
- package/dist/drivers/claude/subprocess.d.ts +12 -2
- package/dist/drivers/claude/subprocess.js +100 -8
- package/dist/drivers/claude/subprocess.js.map +1 -1
- package/dist/drivers/gemini/api.d.ts +18 -0
- package/dist/drivers/gemini/api.js +29 -0
- package/dist/drivers/gemini/api.js.map +1 -0
- package/dist/drivers/gemini/index.d.ts +22 -0
- package/dist/drivers/gemini/index.js +256 -129
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/drivers/index.d.ts +3 -1
- package/dist/drivers/index.js +9 -1
- package/dist/drivers/index.js.map +1 -1
- package/dist/drivers/local/index.d.ts +43 -0
- package/dist/drivers/local/index.js +140 -0
- package/dist/drivers/local/index.js.map +1 -0
- package/dist/drivers/openai/index.js +30 -2
- package/dist/drivers/openai/index.js.map +1 -1
- package/dist/extensionClient.d.ts +37 -4
- package/dist/extensionClient.js +58 -16
- package/dist/extensionClient.js.map +1 -1
- package/dist/featureFlags.d.ts +91 -0
- package/dist/featureFlags.js +174 -3
- package/dist/featureFlags.js.map +1 -1
- package/dist/fileLock.js +21 -12
- package/dist/fileLock.js.map +1 -1
- package/dist/fileLockSync.d.ts +67 -0
- package/dist/fileLockSync.js +126 -0
- package/dist/fileLockSync.js.map +1 -0
- package/dist/fp/activityAnalytics.js +26 -12
- package/dist/fp/activityAnalytics.js.map +1 -1
- package/dist/fp/automationInterpreter.d.ts +15 -1
- package/dist/fp/automationInterpreter.js +217 -82
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationProgram.d.ts +30 -0
- package/dist/fp/automationProgram.js.map +1 -1
- package/dist/fp/automationState.d.ts +24 -5
- package/dist/fp/automationState.js +56 -9
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/automationUtils.js +1 -1
- package/dist/fp/automationUtils.js.map +1 -1
- package/dist/fp/commandDescription.js +7 -1
- package/dist/fp/commandDescription.js.map +1 -1
- package/dist/fp/extensionSnapshot.js +8 -2
- package/dist/fp/extensionSnapshot.js.map +1 -1
- package/dist/fp/interpreterContext.d.ts +66 -1
- package/dist/fp/interpreterContext.js +91 -1
- package/dist/fp/interpreterContext.js.map +1 -1
- package/dist/fp/policyParser.js +29 -1
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/fsWatchWithFallback.d.ts +36 -0
- package/dist/fsWatchWithFallback.js +128 -0
- package/dist/fsWatchWithFallback.js.map +1 -0
- package/dist/haltPushDispatch.d.ts +33 -0
- package/dist/haltPushDispatch.js +103 -0
- package/dist/haltPushDispatch.js.map +1 -0
- package/dist/httpBodyValidation.d.ts +41 -0
- package/dist/httpBodyValidation.js +45 -0
- package/dist/httpBodyValidation.js.map +1 -0
- package/dist/httpErrorResponse.d.ts +36 -0
- package/dist/httpErrorResponse.js +46 -0
- package/dist/httpErrorResponse.js.map +1 -0
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +151 -12
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1054 -76
- package/dist/index.js.map +1 -1
- package/dist/installGuard.js +6 -2
- package/dist/installGuard.js.map +1 -1
- package/dist/lockfile.js +31 -4
- package/dist/lockfile.js.map +1 -1
- package/dist/oauth.d.ts +22 -0
- package/dist/oauth.js +46 -0
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.d.ts +1 -1
- package/dist/oauthRoutes.js +8 -11
- package/dist/oauthRoutes.js.map +1 -1
- package/dist/orchestrator/childBridgeRegistry.js +29 -11
- package/dist/orchestrator/childBridgeRegistry.js.map +1 -1
- package/dist/patchworkConfig.d.ts +57 -1
- package/dist/patchworkConfig.js +125 -5
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.js +10 -1
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +12 -14
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.js +10 -3
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/processTree.d.ts +34 -0
- package/dist/processTree.js +105 -0
- package/dist/processTree.js.map +1 -0
- package/dist/prompts.js +7 -3
- package/dist/prompts.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +9 -0
- package/dist/recipeOrchestration.js +354 -27
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +63 -2
- package/dist/recipeRoutes.js +790 -137
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/RecipeOrchestrator.d.ts +2 -0
- package/dist/recipes/RecipeOrchestrator.js +6 -1
- package/dist/recipes/RecipeOrchestrator.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +34 -5
- package/dist/recipes/agentExecutor.js +5 -4
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +2 -0
- package/dist/recipes/chainedRunner.js +142 -5
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/connectorPreflight.d.ts +66 -0
- package/dist/recipes/connectorPreflight.js +169 -0
- package/dist/recipes/connectorPreflight.js.map +1 -0
- package/dist/recipes/dependencyGraph.js +13 -5
- package/dist/recipes/dependencyGraph.js.map +1 -1
- package/dist/recipes/githubInstallSource.d.ts +128 -0
- package/dist/recipes/githubInstallSource.js +206 -0
- package/dist/recipes/githubInstallSource.js.map +1 -0
- package/dist/recipes/haltCategory.d.ts +119 -0
- package/dist/recipes/haltCategory.js +188 -0
- package/dist/recipes/haltCategory.js.map +1 -0
- package/dist/recipes/idempotencyKey.d.ts +134 -0
- package/dist/recipes/idempotencyKey.js +322 -0
- package/dist/recipes/idempotencyKey.js.map +1 -0
- package/dist/recipes/installer.js +48 -2
- package/dist/recipes/installer.js.map +1 -1
- package/dist/recipes/judgeSummary.d.ts +50 -0
- package/dist/recipes/judgeSummary.js +47 -0
- package/dist/recipes/judgeSummary.js.map +1 -0
- package/dist/recipes/judgeVerdict.d.ts +48 -0
- package/dist/recipes/judgeVerdict.js +174 -0
- package/dist/recipes/judgeVerdict.js.map +1 -0
- package/dist/recipes/migrations/index.d.ts +9 -0
- package/dist/recipes/migrations/index.js +133 -0
- package/dist/recipes/migrations/index.js.map +1 -1
- package/dist/recipes/names.d.ts +20 -0
- package/dist/recipes/names.js +25 -0
- package/dist/recipes/names.js.map +1 -1
- package/dist/recipes/parser.js +88 -5
- package/dist/recipes/parser.js.map +1 -1
- package/dist/recipes/replayRun.js +1 -1
- package/dist/recipes/replayRun.js.map +1 -1
- package/dist/recipes/runBudget.d.ts +70 -0
- package/dist/recipes/runBudget.js +109 -0
- package/dist/recipes/runBudget.js.map +1 -0
- package/dist/recipes/scheduler.d.ts +30 -0
- package/dist/recipes/scheduler.js +69 -19
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +36 -0
- package/dist/recipes/schemaGenerator.js +103 -5
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/stepObservation.js +9 -0
- package/dist/recipes/stepObservation.js.map +1 -1
- package/dist/recipes/toolRegistry.js +20 -3
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/fanOut.d.ts +20 -0
- package/dist/recipes/tools/fanOut.js +199 -0
- package/dist/recipes/tools/fanOut.js.map +1 -0
- package/dist/recipes/tools/file.js +5 -2
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/github.d.ts +1 -1
- package/dist/recipes/tools/github.js +75 -1
- package/dist/recipes/tools/github.js.map +1 -1
- package/dist/recipes/tools/gmail.js +45 -6
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/googleDrive.js +64 -0
- package/dist/recipes/tools/googleDrive.js.map +1 -1
- package/dist/recipes/tools/http.d.ts +10 -0
- package/dist/recipes/tools/http.js +176 -0
- package/dist/recipes/tools/http.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +2 -0
- package/dist/recipes/tools/index.js +2 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/slack.js +1 -1
- package/dist/recipes/validation.d.ts +17 -0
- package/dist/recipes/validation.js +29 -11
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/workspaceRoot.d.ts +37 -0
- package/dist/recipes/workspaceRoot.js +73 -0
- package/dist/recipes/workspaceRoot.js.map +1 -0
- package/dist/recipes/yamlPositions.d.ts +56 -0
- package/dist/recipes/yamlPositions.js +183 -0
- package/dist/recipes/yamlPositions.js.map +1 -0
- package/dist/recipes/yamlRunner.d.ts +195 -8
- package/dist/recipes/yamlRunner.js +877 -209
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +33 -3
- package/dist/recipesHttp.js +126 -12
- package/dist/recipesHttp.js.map +1 -1
- package/dist/resources.js +21 -13
- package/dist/resources.js.map +1 -1
- package/dist/runLog.d.ts +58 -5
- package/dist/runLog.js +98 -21
- package/dist/runLog.js.map +1 -1
- package/dist/sanitizeParsedJson.d.ts +39 -0
- package/dist/sanitizeParsedJson.js +55 -0
- package/dist/sanitizeParsedJson.js.map +1 -0
- package/dist/schemas/recipe.v1.json +885 -196
- package/dist/server.d.ts +166 -3
- package/dist/server.js +1313 -332
- package/dist/server.js.map +1 -1
- package/dist/sessionCheckpoint.d.ts +8 -0
- package/dist/sessionCheckpoint.js +33 -3
- package/dist/sessionCheckpoint.js.map +1 -1
- package/dist/ssrfGuard.d.ts +16 -0
- package/dist/ssrfGuard.js +87 -4
- package/dist/ssrfGuard.js.map +1 -1
- package/dist/streamableHttp.d.ts +9 -4
- package/dist/streamableHttp.js +76 -20
- package/dist/streamableHttp.js.map +1 -1
- package/dist/telemetry.js +19 -12
- package/dist/telemetry.js.map +1 -1
- package/dist/testing/shadowRun.d.ts +52 -0
- package/dist/testing/shadowRun.js +71 -0
- package/dist/testing/shadowRun.js.map +1 -0
- package/dist/tools/batchLsp.d.ts +3 -0
- package/dist/tools/bridgeDoctor.d.ts +11 -0
- package/dist/tools/bridgeDoctor.js +48 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/bridgeStatus.d.ts +0 -12
- package/dist/tools/bridgeStatus.js +2 -28
- package/dist/tools/bridgeStatus.js.map +1 -1
- package/dist/tools/cancelClaudeTask.d.ts +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/closeTabs.d.ts +1 -0
- package/dist/tools/codeLens.d.ts +1 -0
- package/dist/tools/createIssueFromAIComment.d.ts +1 -0
- package/dist/tools/ctxGetTaskContext.d.ts +9 -0
- package/dist/tools/ctxGetTaskContext.js +50 -2
- package/dist/tools/ctxGetTaskContext.js.map +1 -1
- package/dist/tools/ctxQueryTraces.js +32 -24
- package/dist/tools/ctxQueryTraces.js.map +1 -1
- package/dist/tools/ctxSaveTrace.d.ts +1 -0
- package/dist/tools/debug.d.ts +4 -0
- package/dist/tools/decorations.d.ts +2 -0
- package/dist/tools/detectUnusedCode.js +9 -7
- package/dist/tools/detectUnusedCode.js.map +1 -1
- package/dist/tools/documentLinks.d.ts +1 -0
- package/dist/tools/editText.d.ts +1 -0
- package/dist/tools/editText.js +2 -1
- package/dist/tools/editText.js.map +1 -1
- package/dist/tools/enrichCommit.d.ts +1 -0
- package/dist/tools/enrichStackTrace.js +11 -5
- package/dist/tools/enrichStackTrace.js.map +1 -1
- package/dist/tools/explainDiagnostic.d.ts +1 -0
- package/dist/tools/explainSymbol.d.ts +1 -0
- package/dist/tools/fileOperations.d.ts +3 -0
- package/dist/tools/fileOperations.js +2 -1
- package/dist/tools/fileOperations.js.map +1 -1
- package/dist/tools/fileWatcher.d.ts +2 -0
- package/dist/tools/fileWatcher.js +8 -2
- package/dist/tools/fileWatcher.js.map +1 -1
- package/dist/tools/findFiles.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.js +10 -5
- package/dist/tools/fixAllLintErrors.js.map +1 -1
- package/dist/tools/foldingRanges.d.ts +1 -0
- package/dist/tools/formatDocument.d.ts +1 -0
- package/dist/tools/formatDocument.js +10 -5
- package/dist/tools/formatDocument.js.map +1 -1
- package/dist/tools/generateTests.d.ts +1 -0
- package/dist/tools/getAIComments.d.ts +1 -0
- package/dist/tools/getAnalyticsReport.js +5 -1
- package/dist/tools/getAnalyticsReport.js.map +1 -1
- package/dist/tools/getBufferContent.d.ts +1 -0
- package/dist/tools/getChangeImpact.d.ts +1 -0
- package/dist/tools/getChangeImpact.js +23 -14
- package/dist/tools/getChangeImpact.js.map +1 -1
- package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
- package/dist/tools/getCodeCoverage.d.ts +1 -0
- package/dist/tools/getCodeCoverage.js +32 -14
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/getCommitsForIssue.d.ts +1 -0
- package/dist/tools/getDebugState.d.ts +1 -0
- package/dist/tools/getDiagnostics.js +32 -29
- package/dist/tools/getDiagnostics.js.map +1 -1
- package/dist/tools/getDiffFromHandoff.js +8 -2
- package/dist/tools/getDiffFromHandoff.js.map +1 -1
- package/dist/tools/getDocumentSymbols.d.ts +1 -0
- package/dist/tools/getGitHotspots.d.ts +1 -0
- package/dist/tools/getImportedSignatures.d.ts +1 -0
- package/dist/tools/getImportedSignatures.js +18 -14
- package/dist/tools/getImportedSignatures.js.map +1 -1
- package/dist/tools/getPRTemplate.d.ts +1 -0
- package/dist/tools/getProjectContext.js +23 -10
- package/dist/tools/getProjectContext.js.map +1 -1
- package/dist/tools/getSymbolHistory.d.ts +1 -0
- package/dist/tools/getToolCapabilities.d.ts +10 -5
- package/dist/tools/getToolCapabilities.js +79 -202
- package/dist/tools/getToolCapabilities.js.map +1 -1
- package/dist/tools/getTypeSignature.d.ts +1 -0
- package/dist/tools/getWorkspaceSettings.d.ts +1 -0
- package/dist/tools/gitWrite.d.ts +11 -0
- package/dist/tools/github/actions.d.ts +2 -0
- package/dist/tools/github/composite.d.ts +3 -0
- package/dist/tools/github/issues.d.ts +4 -0
- package/dist/tools/github/pr.d.ts +7 -0
- package/dist/tools/handoffNote.d.ts +1 -0
- package/dist/tools/handoffNote.js +2 -1
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/headless/lspClient.js +3 -0
- package/dist/tools/headless/lspClient.js.map +1 -1
- package/dist/tools/hoverAtCursor.d.ts +1 -0
- package/dist/tools/httpClient.d.ts +2 -0
- package/dist/tools/httpClient.js +38 -71
- package/dist/tools/httpClient.js.map +1 -1
- package/dist/tools/inlayHints.d.ts +1 -0
- package/dist/tools/launchQuickTask.d.ts +1 -0
- package/dist/tools/listClaudeTasks.d.ts +1 -0
- package/dist/tools/listClaudeTasks.js +10 -8
- package/dist/tools/listClaudeTasks.js.map +1 -1
- package/dist/tools/listTerminals.d.ts +1 -0
- package/dist/tools/lsp.d.ts +15 -0
- package/dist/tools/lsp.js +17 -0
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/navigateToSymbolByName.d.ts +1 -0
- package/dist/tools/openDiff.d.ts +1 -0
- package/dist/tools/openDiff.js +4 -1
- package/dist/tools/openDiff.js.map +1 -1
- package/dist/tools/openFile.d.ts +1 -0
- package/dist/tools/openFile.js +4 -1
- package/dist/tools/openFile.js.map +1 -1
- package/dist/tools/openInBrowser.js +6 -1
- package/dist/tools/openInBrowser.js.map +1 -1
- package/dist/tools/organizeImports.d.ts +1 -0
- package/dist/tools/organizeImports.js +5 -3
- package/dist/tools/organizeImports.js.map +1 -1
- package/dist/tools/performanceReport.js +5 -3
- package/dist/tools/performanceReport.js.map +1 -1
- package/dist/tools/planPersistence.d.ts +3 -0
- package/dist/tools/planPersistence.js +4 -1
- package/dist/tools/planPersistence.js.map +1 -1
- package/dist/tools/previewEdit.d.ts +1 -0
- package/dist/tools/previewEdit.js +15 -4
- package/dist/tools/previewEdit.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +54 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/refactorAnalyze.d.ts +1 -0
- package/dist/tools/refactorExtractFunction.js +4 -1
- package/dist/tools/refactorExtractFunction.js.map +1 -1
- package/dist/tools/refactorPreview.d.ts +1 -0
- package/dist/tools/refactorPreview.js +10 -2
- package/dist/tools/refactorPreview.js.map +1 -1
- package/dist/tools/replaceBlock.d.ts +1 -0
- package/dist/tools/replaceBlock.js +2 -1
- package/dist/tools/replaceBlock.js.map +1 -1
- package/dist/tools/resumeClaudeTask.d.ts +1 -0
- package/dist/tools/runClaudeTask.d.ts +1 -0
- package/dist/tools/runCommand.js +5 -0
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/runTests.js +15 -4
- package/dist/tools/runTests.js.map +1 -1
- package/dist/tools/screenshot.d.ts +1 -0
- package/dist/tools/screenshotAndAnnotate.js +6 -2
- package/dist/tools/screenshotAndAnnotate.js.map +1 -1
- package/dist/tools/searchAndReplace.d.ts +1 -0
- package/dist/tools/searchAndReplace.js +17 -7
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/searchTools.js +20 -19
- package/dist/tools/searchTools.js.map +1 -1
- package/dist/tools/searchWorkspace.d.ts +1 -0
- package/dist/tools/selectionRanges.d.ts +1 -0
- package/dist/tools/semanticTokens.d.ts +1 -0
- package/dist/tools/signatureHelp.d.ts +1 -0
- package/dist/tools/spawnWorkspace.js +15 -7
- package/dist/tools/spawnWorkspace.js.map +1 -1
- package/dist/tools/terminal.d.ts +6 -0
- package/dist/tools/terminal.js +4 -0
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/testRunners/pytest.js +6 -2
- package/dist/tools/testRunners/pytest.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/testTraceToSource.d.ts +1 -0
- package/dist/tools/transaction.d.ts +4 -0
- package/dist/tools/transaction.js +4 -1
- package/dist/tools/transaction.js.map +1 -1
- package/dist/tools/typeHierarchy.d.ts +1 -0
- package/dist/tools/utils.d.ts +22 -0
- package/dist/tools/utils.js +158 -14
- package/dist/tools/utils.js.map +1 -1
- package/dist/tools/vscodeCommands.d.ts +2 -0
- package/dist/tools/vscodeTasks.d.ts +2 -0
- package/dist/tools/workspaceSettings.d.ts +1 -0
- package/dist/transport.d.ts +3 -1
- package/dist/transport.js +103 -52
- package/dist/transport.js.map +1 -1
- package/dist/winShim.d.ts +34 -0
- package/dist/winShim.js +94 -0
- package/dist/winShim.js.map +1 -0
- package/dist/wireHaltPushDispatch.d.ts +38 -0
- package/dist/wireHaltPushDispatch.js +71 -0
- package/dist/wireHaltPushDispatch.js.map +1 -0
- package/dist/writeFileAtomic.d.ts +23 -0
- package/dist/writeFileAtomic.js +121 -0
- package/dist/writeFileAtomic.js.map +1 -0
- package/package.json +23 -7
- package/scripts/postinstall.mjs +42 -2
- package/scripts/smoke/run-all.mjs +213 -0
- package/scripts/start-all.mjs +572 -0
- package/scripts/start-all.ps1 +209 -0
- package/scripts/start-all.sh +77 -19
- package/scripts/start-orchestrator.ps1 +158 -0
- package/scripts/start-remote.mjs +122 -0
- package/templates/automation-policies/recipe-authoring.json +1 -1
- package/templates/automation-policies/security-first.json +1 -1
- package/templates/automation-policies/strict-lint.json +1 -1
- package/templates/automation-policies/test-driven.json +1 -1
- package/templates/automation-policy.example.json +1 -1
- package/templates/co.patchwork-os.bridge.plist +2 -2
- package/templates/recipes/approval-queue-ui-test.yaml +205 -0
- package/templates/recipes/ctx-loop-test.yaml +1 -1
- package/templates/recipes/fix-errors-on-save.yaml +71 -0
- package/templates/recipes/morning-brief.yaml +5 -2
- package/templates/recipes/project-health-check.yaml +4 -1
- package/templates/recipes/sentry-to-linear.yaml +72 -38
- package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
- package/templates/recipes/webhook/customer-escalation.yaml +8 -9
- package/templates/recipes/webhook/meeting-prep.yaml +11 -5
- package/dist/commands/marketplace.d.ts +0 -11
- package/dist/commands/marketplace.js +0 -120
- package/dist/commands/marketplace.js.map +0 -1
- package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
- package/dist/recipes/legacyRecipeCompat.js +0 -131
- package/dist/recipes/legacyRecipeCompat.js.map +0 -1
package/dist/recipeRoutes.js
CHANGED
|
@@ -29,12 +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";
|
|
35
|
+
import { respond500 } from "./httpErrorResponse.js";
|
|
33
36
|
import { validateSafeUrl } from "./ssrfGuard.js";
|
|
34
37
|
// 5-minute cache of the public template registry from the patchworkos/recipes
|
|
35
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.
|
|
36
41
|
let templatesCache = null;
|
|
37
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
|
+
}
|
|
38
59
|
/**
|
|
39
60
|
* Per-process token bucket guarding `/recipes/generate`. Every call to the
|
|
40
61
|
* route enqueues a Claude subprocess via the orchestrator — without a cap a
|
|
@@ -62,6 +83,26 @@ export function _resetGenerateRateLimitForTests() {
|
|
|
62
83
|
lastRefill: 0,
|
|
63
84
|
};
|
|
64
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
|
+
}
|
|
65
106
|
// G-security R2 C-3 / I-3 / F-02: HTTP `vars` validation.
|
|
66
107
|
//
|
|
67
108
|
// The post-render path jail in `src/recipes/resolveRecipePath.ts` is the
|
|
@@ -141,6 +182,12 @@ export const RECIPE_ROUTE_BODY_CAPS = {
|
|
|
141
182
|
run: 32 * 1024,
|
|
142
183
|
/** /recipes (POST), PUT/PATCH /recipes/:name, /recipes/lint — yaml content. */
|
|
143
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,
|
|
144
191
|
};
|
|
145
192
|
/**
|
|
146
193
|
* Read an HTTP request body up to `max` bytes. Returns the raw string or
|
|
@@ -187,7 +234,11 @@ export function readBodyWithCap(req, max) {
|
|
|
187
234
|
const onEnd = () => {
|
|
188
235
|
if (aborted)
|
|
189
236
|
return;
|
|
190
|
-
|
|
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 });
|
|
191
242
|
};
|
|
192
243
|
const onError = () => {
|
|
193
244
|
if (aborted)
|
|
@@ -253,6 +304,24 @@ function respondInvalidJson(res) {
|
|
|
253
304
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
254
305
|
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
255
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
|
+
}
|
|
256
325
|
/**
|
|
257
326
|
* Try to handle a recipe / run-audit / template route. Returns true if
|
|
258
327
|
* the route was dispatched (caller should `return` from the request
|
|
@@ -280,6 +349,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
280
349
|
}
|
|
281
350
|
try {
|
|
282
351
|
const parsed = parsedBody.value ?? {};
|
|
352
|
+
if (respondIfUnknownBodyKeys(res, parsed, ["vars", "inputs"]))
|
|
353
|
+
return;
|
|
283
354
|
const varsRaw = parsed.vars ?? parsed.inputs;
|
|
284
355
|
const varsErr = validateRecipeVars(varsRaw);
|
|
285
356
|
if (varsErr) {
|
|
@@ -294,7 +365,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
294
365
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
295
366
|
res.end(JSON.stringify({
|
|
296
367
|
ok: false,
|
|
297
|
-
error: "Recipe execution unavailable — requires --
|
|
368
|
+
error: "Recipe execution unavailable — requires --driver subprocess",
|
|
298
369
|
}));
|
|
299
370
|
return;
|
|
300
371
|
}
|
|
@@ -325,17 +396,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
325
396
|
}
|
|
326
397
|
try {
|
|
327
398
|
const parsed = parsedBody.value ?? {};
|
|
399
|
+
if (respondIfUnknownBodyKeys(res, parsed, ["name", "vars", "inputs"]))
|
|
400
|
+
return;
|
|
328
401
|
const name = parsed.name;
|
|
329
|
-
|
|
402
|
+
// #605 symmetry — accept `inputs` here like /recipes/:name/run does.
|
|
403
|
+
const varsRaw = parsed.vars ?? parsed.inputs;
|
|
404
|
+
const varsErr = validateRecipeVars(varsRaw);
|
|
330
405
|
if (varsErr) {
|
|
331
406
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
332
407
|
res.end(JSON.stringify(varsErr));
|
|
333
408
|
return;
|
|
334
409
|
}
|
|
335
|
-
const vars =
|
|
336
|
-
|
|
337
|
-
!Array.isArray(parsed.vars)
|
|
338
|
-
? parsed.vars
|
|
410
|
+
const vars = varsRaw && typeof varsRaw === "object" && !Array.isArray(varsRaw)
|
|
411
|
+
? varsRaw
|
|
339
412
|
: undefined;
|
|
340
413
|
if (typeof name !== "string" || !name) {
|
|
341
414
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -346,7 +419,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
346
419
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
347
420
|
res.end(JSON.stringify({
|
|
348
421
|
ok: false,
|
|
349
|
-
error: "Recipe execution unavailable — requires --
|
|
422
|
+
error: "Recipe execution unavailable — requires --driver subprocess",
|
|
350
423
|
}));
|
|
351
424
|
return;
|
|
352
425
|
}
|
|
@@ -370,10 +443,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
370
443
|
res.end(JSON.stringify({ metrics, summary }));
|
|
371
444
|
}
|
|
372
445
|
catch (err) {
|
|
373
|
-
res
|
|
374
|
-
res.end(JSON.stringify({
|
|
375
|
-
error: err instanceof Error ? err.message : String(err),
|
|
376
|
-
}));
|
|
446
|
+
respond500(res, err);
|
|
377
447
|
}
|
|
378
448
|
return true;
|
|
379
449
|
}
|
|
@@ -385,6 +455,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
385
455
|
const trigger = sp.get("trigger");
|
|
386
456
|
const status = sp.get("status");
|
|
387
457
|
const recipe = sp.get("recipe");
|
|
458
|
+
const manualRunId = sp.get("manualRunId");
|
|
388
459
|
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
389
460
|
const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
|
|
390
461
|
const runs = deps.runsFn?.({
|
|
@@ -392,19 +463,127 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
392
463
|
...(trigger && { trigger }),
|
|
393
464
|
...(status && { status }),
|
|
394
465
|
...(recipe && { recipe }),
|
|
466
|
+
...(manualRunId && { manualRunId }),
|
|
395
467
|
...(Number.isFinite(after) && { after }),
|
|
396
468
|
}) ?? [];
|
|
397
469
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
398
470
|
res.end(JSON.stringify({ runs }));
|
|
399
471
|
}
|
|
400
472
|
catch (err) {
|
|
401
|
-
res
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
473
|
+
respond500(res, err);
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
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);
|
|
405
521
|
}
|
|
406
522
|
return true;
|
|
407
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
|
+
}
|
|
408
587
|
// GET /runs/:seq — single run detail (includes stepResults if present)
|
|
409
588
|
const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
|
|
410
589
|
if (runDetailMatch?.[1]) {
|
|
@@ -421,10 +600,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
421
600
|
}
|
|
422
601
|
}
|
|
423
602
|
catch (err) {
|
|
424
|
-
res
|
|
425
|
-
res.end(JSON.stringify({
|
|
426
|
-
error: err instanceof Error ? err.message : String(err),
|
|
427
|
-
}));
|
|
603
|
+
respond500(res, err);
|
|
428
604
|
}
|
|
429
605
|
return true;
|
|
430
606
|
}
|
|
@@ -458,9 +634,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
458
634
|
res.end(JSON.stringify(result));
|
|
459
635
|
}
|
|
460
636
|
catch (err) {
|
|
461
|
-
|
|
462
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
463
|
-
res.end(JSON.stringify({ error: msg }));
|
|
637
|
+
respond500(res, err, "runs/:seq detail");
|
|
464
638
|
}
|
|
465
639
|
})();
|
|
466
640
|
return true;
|
|
@@ -491,10 +665,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
491
665
|
res.end(JSON.stringify({ plan }));
|
|
492
666
|
}
|
|
493
667
|
catch (err) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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") {
|
|
675
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
676
|
+
res.end(JSON.stringify({ error: "Run not found" }));
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
respond500(res, err, "recipes plan");
|
|
680
|
+
}
|
|
498
681
|
}
|
|
499
682
|
})();
|
|
500
683
|
return true;
|
|
@@ -545,22 +728,160 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
545
728
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
546
729
|
res.end(JSON.stringify({
|
|
547
730
|
ok: false,
|
|
548
|
-
error: "Recipe generation unavailable — requires --
|
|
731
|
+
error: "Recipe generation unavailable — requires --driver subprocess",
|
|
549
732
|
unavailable: true,
|
|
550
733
|
}));
|
|
551
734
|
return;
|
|
552
735
|
}
|
|
553
736
|
try {
|
|
554
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
|
+
});
|
|
555
875
|
const status = result.ok ? 200 : result.unavailable ? 503 : 422;
|
|
556
876
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
557
877
|
res.end(JSON.stringify(result));
|
|
558
878
|
}
|
|
559
879
|
catch (err) {
|
|
880
|
+
console.error(`[recipes/repair] internal error:`, err);
|
|
560
881
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
561
882
|
res.end(JSON.stringify({
|
|
562
883
|
ok: false,
|
|
563
|
-
error:
|
|
884
|
+
error: "Internal server error",
|
|
564
885
|
}));
|
|
565
886
|
}
|
|
566
887
|
})();
|
|
@@ -595,6 +916,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
595
916
|
return;
|
|
596
917
|
}
|
|
597
918
|
const result = deps.saveRecipeFn(draft);
|
|
919
|
+
if (result.ok)
|
|
920
|
+
fireOnRecipesChanged(deps);
|
|
598
921
|
res.writeHead(result.ok ? 201 : 400, {
|
|
599
922
|
"Content-Type": "application/json",
|
|
600
923
|
});
|
|
@@ -623,6 +946,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
623
946
|
}
|
|
624
947
|
return;
|
|
625
948
|
}
|
|
949
|
+
if (respondIfUnknownBodyKeys(res, parsedBody.value, ["level"]))
|
|
950
|
+
return;
|
|
626
951
|
const level = parsedBody.value
|
|
627
952
|
?.level;
|
|
628
953
|
if (typeof level !== "string" || !level) {
|
|
@@ -674,6 +999,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
674
999
|
return;
|
|
675
1000
|
}
|
|
676
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);
|
|
677
1007
|
res.writeHead(result.ok ? 200 : 400, {
|
|
678
1008
|
"Content-Type": "application/json",
|
|
679
1009
|
});
|
|
@@ -746,10 +1076,15 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
746
1076
|
res.end(JSON.stringify({ plan }));
|
|
747
1077
|
}
|
|
748
1078
|
catch (err) {
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
1079
|
+
// #605: classify by code, not substring (see /runs/:seq/plan).
|
|
1080
|
+
const code = err?.code;
|
|
1081
|
+
if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
|
|
1082
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1083
|
+
res.end(JSON.stringify({ error: "Recipe not found" }));
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
respond500(res, err, "recipes plan");
|
|
1087
|
+
}
|
|
753
1088
|
}
|
|
754
1089
|
})();
|
|
755
1090
|
return true;
|
|
@@ -805,6 +1140,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
805
1140
|
return;
|
|
806
1141
|
}
|
|
807
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);
|
|
808
1148
|
res.writeHead(result.ok ? 200 : 400, {
|
|
809
1149
|
"Content-Type": "application/json",
|
|
810
1150
|
});
|
|
@@ -827,6 +1167,33 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
827
1167
|
return true;
|
|
828
1168
|
}
|
|
829
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);
|
|
1174
|
+
const status = result.ok
|
|
1175
|
+
? 200
|
|
1176
|
+
: result.error === "Recipe not found"
|
|
1177
|
+
? 404
|
|
1178
|
+
: 400;
|
|
1179
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1180
|
+
res.end(JSON.stringify(result));
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
// POST /recipes/:name/archive — move recipe (+ sidecar) into <recipesDir>/.archive/
|
|
1184
|
+
const archiveMatch = /^\/recipes\/([^/]+)\/archive$/.exec(parsedUrl.pathname);
|
|
1185
|
+
if (archiveMatch && req.method === "POST") {
|
|
1186
|
+
const name = decodeURIComponent(archiveMatch[1] ?? "");
|
|
1187
|
+
if (!deps.archiveRecipeFn) {
|
|
1188
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1189
|
+
res.end(JSON.stringify({ ok: false, error: "Recipe archive unavailable" }));
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
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);
|
|
830
1197
|
const status = result.ok
|
|
831
1198
|
? 200
|
|
832
1199
|
: result.error === "Recipe not found"
|
|
@@ -846,6 +1213,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
846
1213
|
return true;
|
|
847
1214
|
}
|
|
848
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);
|
|
849
1220
|
const status = result.ok
|
|
850
1221
|
? 201
|
|
851
1222
|
: result.error === "Recipe not found"
|
|
@@ -863,10 +1234,21 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
863
1234
|
void (async () => {
|
|
864
1235
|
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
865
1236
|
if (!parsedBody.ok) {
|
|
866
|
-
|
|
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
|
+
}
|
|
867
1246
|
return;
|
|
868
1247
|
}
|
|
869
|
-
const
|
|
1248
|
+
const promoteBody = parsedBody.value ?? {};
|
|
1249
|
+
if (respondIfUnknownBodyKeys(res, promoteBody, ["targetName", "force"]))
|
|
1250
|
+
return;
|
|
1251
|
+
const { targetName, force } = promoteBody;
|
|
870
1252
|
if (typeof targetName !== "string" || !targetName.trim()) {
|
|
871
1253
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
872
1254
|
res.end(JSON.stringify({ ok: false, error: "targetName required" }));
|
|
@@ -881,6 +1263,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
881
1263
|
const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
|
|
882
1264
|
force: force === true,
|
|
883
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);
|
|
884
1270
|
const httpStatus = result.ok
|
|
885
1271
|
? 200
|
|
886
1272
|
: result.targetExists
|
|
@@ -892,10 +1278,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
892
1278
|
res.end(JSON.stringify(result));
|
|
893
1279
|
}
|
|
894
1280
|
catch (err) {
|
|
1281
|
+
console.error(`[recipes/install] internal error:`, err);
|
|
895
1282
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
896
1283
|
res.end(JSON.stringify({
|
|
897
1284
|
ok: false,
|
|
898
|
-
error:
|
|
1285
|
+
error: "Internal server error",
|
|
899
1286
|
}));
|
|
900
1287
|
}
|
|
901
1288
|
})();
|
|
@@ -908,10 +1295,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
908
1295
|
res.end(JSON.stringify(data));
|
|
909
1296
|
}
|
|
910
1297
|
catch (err) {
|
|
911
|
-
res
|
|
912
|
-
res.end(JSON.stringify({
|
|
913
|
-
error: err instanceof Error ? err.message : String(err),
|
|
914
|
-
}));
|
|
1298
|
+
respond500(res, err);
|
|
915
1299
|
}
|
|
916
1300
|
return true;
|
|
917
1301
|
}
|
|
@@ -919,22 +1303,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
919
1303
|
void (async () => {
|
|
920
1304
|
try {
|
|
921
1305
|
const now = Date.now();
|
|
922
|
-
if (
|
|
1306
|
+
if (templatesCache === null || now - templatesCacheTs > 5 * 60 * 1000) {
|
|
923
1307
|
const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
|
|
924
1308
|
if (!ghRes.ok) {
|
|
925
1309
|
throw new Error(`GitHub returned ${ghRes.status}`);
|
|
926
1310
|
}
|
|
927
|
-
|
|
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;
|
|
928
1325
|
templatesCacheTs = now;
|
|
929
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
|
+
}
|
|
930
1332
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
931
1333
|
res.end(JSON.stringify(templatesCache));
|
|
932
1334
|
}
|
|
933
1335
|
catch (err) {
|
|
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
|
|
934
1342
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
935
1343
|
res.end(JSON.stringify({
|
|
936
1344
|
ok: false,
|
|
937
|
-
error:
|
|
1345
|
+
error: "Upstream fetch failed",
|
|
938
1346
|
}));
|
|
939
1347
|
}
|
|
940
1348
|
})();
|
|
@@ -953,6 +1361,23 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
953
1361
|
// an explicitly-allowlisted hostname STILL has to clear the SSRF check
|
|
954
1362
|
// (so an admin can't accidentally allowlist `localhost`).
|
|
955
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
|
+
}
|
|
956
1381
|
void (async () => {
|
|
957
1382
|
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
|
|
958
1383
|
if (!parsedBody.ok) {
|
|
@@ -974,58 +1399,59 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
974
1399
|
// -----------------------------------------------------------------
|
|
975
1400
|
// BUNDLE INSTALL DISPATCH (#130 PR A).
|
|
976
1401
|
//
|
|
977
|
-
// `github
|
|
1402
|
+
// `github:<owner>/<repo>/bundles/<name>` installs every recipe
|
|
978
1403
|
// listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
|
|
979
1404
|
// and policy template (`policy_template`) declared in the manifest
|
|
980
1405
|
// are surfaced as advisory-only — wiring those needs separate
|
|
981
1406
|
// decisions (npm-install surface, policy application UX) tracked
|
|
982
|
-
// 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.
|
|
983
1413
|
// -----------------------------------------------------------------
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
}
|
|
997
|
-
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);
|
|
998
1426
|
const ctl = new AbortController();
|
|
999
1427
|
const timeout = setTimeout(() => ctl.abort(), 30_000);
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
signal: ctl.signal,
|
|
1004
|
-
redirect: "follow",
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
catch (err) {
|
|
1008
|
-
clearTimeout(timeout);
|
|
1009
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1010
|
-
res.end(JSON.stringify({
|
|
1011
|
-
ok: false,
|
|
1012
|
-
error: `Bundle manifest fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1013
|
-
code: "bundle_fetch_network_error",
|
|
1014
|
-
}));
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
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 });
|
|
1017
1431
|
clearTimeout(timeout);
|
|
1018
|
-
if (!
|
|
1019
|
-
|
|
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;
|
|
1020
1445
|
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1021
1446
|
res.end(JSON.stringify({
|
|
1022
1447
|
ok: false,
|
|
1023
|
-
error: `Bundle manifest at ${manifestUrl} returned ${
|
|
1448
|
+
error: `Bundle manifest at ${manifestUrl} returned ${failRes.status}`,
|
|
1024
1449
|
code: "bundle_fetch_upstream_error",
|
|
1025
|
-
upstreamStatus:
|
|
1450
|
+
upstreamStatus: failRes.status,
|
|
1026
1451
|
}));
|
|
1027
1452
|
return;
|
|
1028
1453
|
}
|
|
1454
|
+
const manifestRes = manifestFetched.response;
|
|
1029
1455
|
// 64 KB hard cap on manifest body — real `patchwork-bundle.json`
|
|
1030
1456
|
// is single-digit KB; anything past 64 KB is hostile or malformed.
|
|
1031
1457
|
const manifestBuf = await manifestRes.arrayBuffer();
|
|
@@ -1043,14 +1469,19 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1043
1469
|
manifest = JSON.parse(Buffer.from(manifestBuf).toString("utf-8"));
|
|
1044
1470
|
}
|
|
1045
1471
|
catch (err) {
|
|
1472
|
+
console.error(`[recipes/install] bundle manifest invalid JSON:`, err);
|
|
1046
1473
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1047
1474
|
res.end(JSON.stringify({
|
|
1048
1475
|
ok: false,
|
|
1049
|
-
error:
|
|
1476
|
+
error: "Bundle manifest is not valid JSON",
|
|
1050
1477
|
code: "bundle_manifest_invalid_json",
|
|
1051
1478
|
}));
|
|
1052
1479
|
return;
|
|
1053
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");
|
|
1054
1485
|
if (!Array.isArray(manifest.recipes) ||
|
|
1055
1486
|
manifest.recipes.length === 0 ||
|
|
1056
1487
|
!manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
|
|
@@ -1067,27 +1498,57 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1067
1498
|
// all-or-nothing when one of N recipes is broken.
|
|
1068
1499
|
const installed = [];
|
|
1069
1500
|
const failures = [];
|
|
1070
|
-
|
|
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");
|
|
1071
1510
|
const { installRecipeFromFile } = await import("./recipes/installer.js");
|
|
1072
1511
|
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1073
1512
|
mkdirSync(recipesDir, { recursive: true });
|
|
1074
1513
|
for (const r of manifest.recipes) {
|
|
1075
|
-
|
|
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).
|
|
1076
1531
|
const recipeCtl = new AbortController();
|
|
1077
1532
|
const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
|
|
1078
1533
|
try {
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1534
|
+
const recipeFetched = await fetchGithubInstallFile({
|
|
1535
|
+
kind: "recipe",
|
|
1536
|
+
owner: bundleOwner,
|
|
1537
|
+
repo: bundleRepo,
|
|
1538
|
+
name: r,
|
|
1539
|
+
}, { signal: recipeCtl.signal });
|
|
1083
1540
|
clearTimeout(recipeTimeout);
|
|
1084
|
-
if (!
|
|
1541
|
+
if (!recipeFetched.ok) {
|
|
1542
|
+
if (recipeFetched.kind === "network_error") {
|
|
1543
|
+
throw recipeFetched.error;
|
|
1544
|
+
}
|
|
1085
1545
|
failures.push({
|
|
1086
1546
|
name: r,
|
|
1087
|
-
error: `Upstream returned ${
|
|
1547
|
+
error: `Upstream returned ${recipeFetched.response.status}`,
|
|
1088
1548
|
});
|
|
1089
1549
|
continue;
|
|
1090
1550
|
}
|
|
1551
|
+
const recipeRes = recipeFetched.response;
|
|
1091
1552
|
const recipeBuf = await recipeRes.arrayBuffer();
|
|
1092
1553
|
if (recipeBuf.byteLength > 1024 * 1024) {
|
|
1093
1554
|
failures.push({
|
|
@@ -1097,13 +1558,47 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1097
1558
|
continue;
|
|
1098
1559
|
}
|
|
1099
1560
|
const yamlText = Buffer.from(recipeBuf).toString("utf-8");
|
|
1100
|
-
|
|
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`);
|
|
1101
1566
|
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1102
1567
|
try {
|
|
1103
1568
|
const installResult = installRecipeFromFile(tmpFile, {
|
|
1104
1569
|
recipesDir,
|
|
1105
1570
|
});
|
|
1106
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
|
+
}
|
|
1107
1602
|
}
|
|
1108
1603
|
finally {
|
|
1109
1604
|
try {
|
|
@@ -1116,9 +1611,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1116
1611
|
}
|
|
1117
1612
|
catch (err) {
|
|
1118
1613
|
clearTimeout(recipeTimeout);
|
|
1614
|
+
console.error(`[recipes/install] recipe "${r}" failed:`, err);
|
|
1119
1615
|
failures.push({
|
|
1120
1616
|
name: r,
|
|
1121
|
-
error:
|
|
1617
|
+
error: "Recipe install failed",
|
|
1122
1618
|
});
|
|
1123
1619
|
}
|
|
1124
1620
|
}
|
|
@@ -1133,6 +1629,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1133
1629
|
// 200 if any recipe installed; 502 otherwise. Always include both
|
|
1134
1630
|
// arrays so callers (CLI + dashboard) can render partial-success.
|
|
1135
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);
|
|
1136
1640
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1137
1641
|
res.end(JSON.stringify({
|
|
1138
1642
|
ok: installed.length > 0,
|
|
@@ -1140,28 +1644,54 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1140
1644
|
bundleName,
|
|
1141
1645
|
installed,
|
|
1142
1646
|
failures,
|
|
1647
|
+
...(aggregatedMissingConnectors.size > 0 && {
|
|
1648
|
+
missingConnectors: Array.from(aggregatedMissingConnectors),
|
|
1649
|
+
}),
|
|
1143
1650
|
...(Object.keys(advisory).length > 0 && { advisory }),
|
|
1144
1651
|
}));
|
|
1145
1652
|
return;
|
|
1146
1653
|
}
|
|
1147
|
-
const githubPrefix = "github:patchworkos/recipes/recipes/";
|
|
1148
1654
|
let fetchUrl;
|
|
1149
1655
|
let recipeName;
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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") {
|
|
1157
1684
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1158
1685
|
res.end(JSON.stringify({
|
|
1159
1686
|
ok: false,
|
|
1160
|
-
error: "
|
|
1687
|
+
error: "Bundle source on single-recipe install. Use the bundle install path.",
|
|
1688
|
+
code: "bad_shape",
|
|
1161
1689
|
}));
|
|
1162
1690
|
return;
|
|
1163
1691
|
}
|
|
1164
|
-
|
|
1692
|
+
recipeName = parsed.parsed.name;
|
|
1693
|
+
githubParsed = parsed.parsed;
|
|
1694
|
+
fetchUrl = buildGithubRawUrl(parsed.parsed);
|
|
1165
1695
|
}
|
|
1166
1696
|
else if (source.startsWith("https://")) {
|
|
1167
1697
|
// Non-github source: must clear the env-var allowlist AND the SSRF
|
|
@@ -1231,42 +1761,88 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1231
1761
|
const fetchCtl = new AbortController();
|
|
1232
1762
|
const fetchTimeout = setTimeout(() => fetchCtl.abort(), 30_000);
|
|
1233
1763
|
let yamlRes;
|
|
1234
|
-
|
|
1235
|
-
|
|
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, {
|
|
1236
1771
|
signal: fetchCtl.signal,
|
|
1237
|
-
redirect: "follow",
|
|
1238
1772
|
});
|
|
1239
|
-
}
|
|
1240
|
-
catch (err) {
|
|
1241
1773
|
clearTimeout(fetchTimeout);
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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;
|
|
1250
1806
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
+
}
|
|
1270
1846
|
}
|
|
1271
1847
|
// Streamed read with 1 MB cap (mirrors `httpClient` pattern).
|
|
1272
1848
|
const MAX_RECIPE_BYTES = 1024 * 1024;
|
|
@@ -1303,7 +1879,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1303
1879
|
return;
|
|
1304
1880
|
}
|
|
1305
1881
|
const yamlText = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
|
|
1306
|
-
|
|
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`);
|
|
1307
1889
|
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1308
1890
|
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1309
1891
|
let result;
|
|
@@ -1314,7 +1896,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1314
1896
|
const installResult = installRecipeFromFile(tmpFile, {
|
|
1315
1897
|
recipesDir,
|
|
1316
1898
|
});
|
|
1317
|
-
|
|
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
|
+
};
|
|
1318
1939
|
}
|
|
1319
1940
|
finally {
|
|
1320
1941
|
try {
|
|
@@ -1324,15 +1945,47 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1324
1945
|
// best-effort cleanup
|
|
1325
1946
|
}
|
|
1326
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);
|
|
1327
1956
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1328
1957
|
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1329
1958
|
}
|
|
1330
1959
|
catch (err) {
|
|
1331
|
-
//
|
|
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
|
+
}
|
|
1984
|
+
console.error(`[recipes/install] internal install error:`, err);
|
|
1332
1985
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1333
1986
|
res.end(JSON.stringify({
|
|
1334
1987
|
ok: false,
|
|
1335
|
-
error:
|
|
1988
|
+
error: "Internal server error",
|
|
1336
1989
|
code: "install_internal_error",
|
|
1337
1990
|
}));
|
|
1338
1991
|
}
|