patchwork-os 0.2.0-alpha.9 → 0.2.0-beta.0
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 +6 -0
- package/README.md +315 -35
- package/deploy/bootstrap-new-vps.sh +12 -12
- package/deploy/bootstrap-vps.sh +187 -0
- package/deploy/deploy-dashboard.sh +174 -0
- package/deploy/deploy-landing.sh +136 -0
- package/dist/activationMetrics.d.ts +67 -0
- package/dist/activationMetrics.js +255 -0
- package/dist/activationMetrics.js.map +1 -0
- package/dist/activityLog.d.ts +49 -0
- package/dist/activityLog.js +78 -0
- package/dist/activityLog.js.map +1 -1
- package/dist/approvalHttp.d.ts +49 -2
- package/dist/approvalHttp.js +217 -21
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalInsights.d.ts +49 -0
- package/dist/approvalInsights.js +97 -0
- package/dist/approvalInsights.js.map +1 -0
- package/dist/approvalQueue.d.ts +27 -1
- package/dist/approvalQueue.js +123 -3
- package/dist/approvalQueue.js.map +1 -1
- package/dist/approvalSignals.d.ts +124 -0
- package/dist/approvalSignals.js +512 -0
- package/dist/approvalSignals.js.map +1 -0
- package/dist/automation.d.ts +57 -0
- package/dist/automation.js +156 -59
- package/dist/automation.js.map +1 -1
- package/dist/automationSuggestions.d.ts +79 -0
- package/dist/automationSuggestions.js +150 -0
- package/dist/automationSuggestions.js.map +1 -0
- package/dist/bridge.d.ts +3 -0
- package/dist/bridge.js +174 -143
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeToken.js +57 -19
- package/dist/bridgeToken.js.map +1 -1
- package/dist/ccPermissions.d.ts +15 -0
- package/dist/ccPermissions.js +21 -4
- package/dist/ccPermissions.js.map +1 -1
- package/dist/claudeDriver.js +74 -16
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeOrchestrator.d.ts +1 -1
- package/dist/claudeOrchestrator.js +14 -8
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/dashboard.js +1 -1
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/launchd.d.ts +2 -0
- package/dist/commands/launchd.js +94 -0
- package/dist/commands/launchd.js.map +1 -0
- package/dist/commands/patchworkInit.d.ts +8 -0
- package/dist/commands/patchworkInit.js +77 -11
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +289 -0
- package/dist/commands/recipe.js +1359 -0
- package/dist/commands/recipe.js.map +1 -0
- package/dist/commands/recipeInstall.d.ts +150 -0
- package/dist/commands/recipeInstall.js +647 -0
- package/dist/commands/recipeInstall.js.map +1 -0
- package/dist/commands/tracesExport.d.ts +83 -0
- package/dist/commands/tracesExport.js +269 -0
- package/dist/commands/tracesExport.js.map +1 -0
- package/dist/commands/tracesImport.d.ts +56 -0
- package/dist/commands/tracesImport.js +161 -0
- package/dist/commands/tracesImport.js.map +1 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +108 -9
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.d.ts +43 -0
- package/dist/connectorRoutes.js +1609 -0
- package/dist/connectorRoutes.js.map +1 -0
- package/dist/connectors/asana.d.ts +198 -0
- package/dist/connectors/asana.js +679 -0
- package/dist/connectors/asana.js.map +1 -0
- package/dist/connectors/baseConnector.d.ts +153 -0
- package/dist/connectors/baseConnector.js +336 -0
- package/dist/connectors/baseConnector.js.map +1 -0
- package/dist/connectors/confluence.d.ts +111 -0
- package/dist/connectors/confluence.js +406 -0
- package/dist/connectors/confluence.js.map +1 -0
- package/dist/connectors/datadog.d.ts +116 -0
- package/dist/connectors/datadog.js +385 -0
- package/dist/connectors/datadog.js.map +1 -0
- package/dist/connectors/discord.d.ts +150 -0
- package/dist/connectors/discord.js +543 -0
- package/dist/connectors/discord.js.map +1 -0
- package/dist/connectors/fixtureLibrary.d.ts +21 -0
- package/dist/connectors/fixtureLibrary.js +70 -0
- package/dist/connectors/fixtureLibrary.js.map +1 -0
- package/dist/connectors/fixtureRecorder.d.ts +1 -0
- package/dist/connectors/fixtureRecorder.js +35 -0
- package/dist/connectors/fixtureRecorder.js.map +1 -0
- package/dist/connectors/github.js +17 -18
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.d.ts +180 -0
- package/dist/connectors/gitlab.js +582 -0
- package/dist/connectors/gitlab.js.map +1 -0
- package/dist/connectors/gmail.d.ts +4 -1
- package/dist/connectors/gmail.js +149 -27
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.d.ts +4 -1
- package/dist/connectors/googleCalendar.js +88 -25
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/connectors/googleDrive.d.ts +34 -0
- package/dist/connectors/googleDrive.js +321 -0
- package/dist/connectors/googleDrive.js.map +1 -0
- package/dist/connectors/htmlEscape.d.ts +5 -0
- package/dist/connectors/htmlEscape.js +13 -0
- package/dist/connectors/htmlEscape.js.map +1 -0
- package/dist/connectors/hubspot.d.ts +112 -0
- package/dist/connectors/hubspot.js +408 -0
- package/dist/connectors/hubspot.js.map +1 -0
- package/dist/connectors/intercom.d.ts +102 -0
- package/dist/connectors/intercom.js +402 -0
- package/dist/connectors/intercom.js.map +1 -0
- package/dist/connectors/jira.d.ts +98 -0
- package/dist/connectors/jira.js +379 -0
- package/dist/connectors/jira.js.map +1 -0
- package/dist/connectors/linear.js +30 -19
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.d.ts +3 -0
- package/dist/connectors/mcpOAuth.js +64 -10
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/mockConnector.d.ts +28 -0
- package/dist/connectors/mockConnector.js +81 -0
- package/dist/connectors/mockConnector.js.map +1 -0
- package/dist/connectors/notion.d.ts +143 -0
- package/dist/connectors/notion.js +424 -0
- package/dist/connectors/notion.js.map +1 -0
- package/dist/connectors/oauthStateStore.d.ts +31 -0
- package/dist/connectors/oauthStateStore.js +52 -0
- package/dist/connectors/oauthStateStore.js.map +1 -0
- package/dist/connectors/pagerduty.d.ts +160 -0
- package/dist/connectors/pagerduty.js +464 -0
- package/dist/connectors/pagerduty.js.map +1 -0
- package/dist/connectors/sentry.js +5 -13
- package/dist/connectors/sentry.js.map +1 -1
- package/dist/connectors/slack.d.ts +16 -1
- package/dist/connectors/slack.js +155 -32
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/stripe.d.ts +116 -0
- package/dist/connectors/stripe.js +379 -0
- package/dist/connectors/stripe.js.map +1 -0
- package/dist/connectors/tokenStorage.d.ts +35 -0
- package/dist/connectors/tokenStorage.js +484 -0
- package/dist/connectors/tokenStorage.js.map +1 -0
- package/dist/connectors/zendesk.d.ts +104 -0
- package/dist/connectors/zendesk.js +442 -0
- package/dist/connectors/zendesk.js.map +1 -0
- package/dist/cors.d.ts +10 -0
- package/dist/cors.js +29 -0
- package/dist/cors.js.map +1 -0
- package/dist/decisionReplay.d.ts +72 -0
- package/dist/decisionReplay.js +92 -0
- package/dist/decisionReplay.js.map +1 -0
- package/dist/decisionTraceLog.d.ts +6 -0
- package/dist/decisionTraceLog.js +54 -2
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/drivers/gemini/index.d.ts +5 -1
- package/dist/drivers/gemini/index.js +39 -5
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.js +1 -1
- package/dist/drivers/index.js.map +1 -1
- package/dist/featureFlags.d.ts +79 -0
- package/dist/featureFlags.js +208 -0
- package/dist/featureFlags.js.map +1 -0
- package/dist/fp/automationInterpreter.js +26 -21
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationProgram.d.ts +1 -1
- package/dist/fp/automationProgram.js.map +1 -1
- package/dist/fp/automationState.js +4 -1
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/policyParser.js +21 -1
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +114 -0
- package/dist/inboxRoutes.js.map +1 -0
- package/dist/index.js +1400 -201
- package/dist/index.js.map +1 -1
- package/dist/installGuard.d.ts +25 -0
- package/dist/installGuard.js +48 -0
- package/dist/installGuard.js.map +1 -0
- package/dist/mcpRoutes.d.ts +37 -0
- package/dist/mcpRoutes.js +76 -0
- package/dist/mcpRoutes.js.map +1 -0
- package/dist/oauth.d.ts +7 -1
- package/dist/oauth.js +201 -39
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.d.ts +32 -0
- package/dist/oauthRoutes.js +124 -0
- package/dist/oauthRoutes.js.map +1 -0
- package/dist/orchestrator/orchestratorBridge.js +2 -2
- package/dist/orchestrator/orchestratorBridge.js.map +1 -1
- package/dist/patchworkConfig.d.ts +16 -0
- package/dist/patchworkConfig.js +1 -1
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.d.ts +28 -0
- package/dist/pluginLoader.js +77 -11
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +8 -3
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.d.ts +12 -0
- package/dist/preToolUseHook.js +23 -0
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +121 -0
- package/dist/recipeOrchestration.js +955 -0
- package/dist/recipeOrchestration.js.map +1 -0
- package/dist/recipeRoutes.d.ts +180 -0
- package/dist/recipeRoutes.js +1345 -0
- package/dist/recipeRoutes.js.map +1 -0
- package/dist/recipes/RecipeOrchestrator.d.ts +40 -0
- package/dist/recipes/RecipeOrchestrator.js +51 -0
- package/dist/recipes/RecipeOrchestrator.js.map +1 -0
- package/dist/recipes/agentExecutor.d.ts +29 -0
- package/dist/recipes/agentExecutor.js +49 -0
- package/dist/recipes/agentExecutor.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +191 -0
- package/dist/recipes/chainedRunner.js +759 -0
- package/dist/recipes/chainedRunner.js.map +1 -0
- package/dist/recipes/compiler.js +3 -3
- package/dist/recipes/compiler.js.map +1 -1
- package/dist/recipes/dependencyGraph.d.ts +39 -0
- package/dist/recipes/dependencyGraph.js +199 -0
- package/dist/recipes/dependencyGraph.js.map +1 -0
- package/dist/recipes/disabledMarkers.d.ts +48 -0
- package/dist/recipes/disabledMarkers.js +52 -0
- package/dist/recipes/disabledMarkers.js.map +1 -0
- package/dist/recipes/installer.js +3 -3
- package/dist/recipes/installer.js.map +1 -1
- package/dist/recipes/legacyRecipeCompat.d.ts +10 -0
- package/dist/recipes/legacyRecipeCompat.js +131 -0
- package/dist/recipes/legacyRecipeCompat.js.map +1 -0
- package/dist/recipes/manifest.d.ts +47 -0
- package/dist/recipes/manifest.js +156 -0
- package/dist/recipes/manifest.js.map +1 -0
- package/dist/recipes/migrationWarnings.d.ts +12 -0
- package/dist/recipes/migrationWarnings.js +44 -0
- package/dist/recipes/migrationWarnings.js.map +1 -0
- package/dist/recipes/migrations/index.d.ts +24 -0
- package/dist/recipes/migrations/index.js +55 -0
- package/dist/recipes/migrations/index.js.map +1 -0
- package/dist/recipes/migrations/types.d.ts +28 -0
- package/dist/recipes/migrations/types.js +2 -0
- package/dist/recipes/migrations/types.js.map +1 -0
- package/dist/recipes/migrations/v1.d.ts +11 -0
- package/dist/recipes/migrations/v1.js +18 -0
- package/dist/recipes/migrations/v1.js.map +1 -0
- package/dist/recipes/names.d.ts +40 -0
- package/dist/recipes/names.js +66 -0
- package/dist/recipes/names.js.map +1 -0
- package/dist/recipes/nestedRecipeStep.d.ts +58 -0
- package/dist/recipes/nestedRecipeStep.js +95 -0
- package/dist/recipes/nestedRecipeStep.js.map +1 -0
- package/dist/recipes/outputRegistry.d.ts +28 -0
- package/dist/recipes/outputRegistry.js +52 -0
- package/dist/recipes/outputRegistry.js.map +1 -0
- package/dist/recipes/parser.js +4 -1
- package/dist/recipes/parser.js.map +1 -1
- package/dist/recipes/replayRun.d.ts +62 -0
- package/dist/recipes/replayRun.js +97 -0
- package/dist/recipes/replayRun.js.map +1 -0
- package/dist/recipes/resolveRecipePath.d.ts +69 -0
- package/dist/recipes/resolveRecipePath.js +202 -0
- package/dist/recipes/resolveRecipePath.js.map +1 -0
- package/dist/recipes/scheduler.d.ts +23 -7
- package/dist/recipes/scheduler.js +225 -45
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +17 -2
- package/dist/recipes/schemaGenerator.d.ts +28 -0
- package/dist/recipes/schemaGenerator.js +565 -0
- package/dist/recipes/schemaGenerator.js.map +1 -0
- package/dist/recipes/stepObservation.d.ts +44 -0
- package/dist/recipes/stepObservation.js +232 -0
- package/dist/recipes/stepObservation.js.map +1 -0
- package/dist/recipes/templateEngine.d.ts +62 -0
- package/dist/recipes/templateEngine.js +201 -0
- package/dist/recipes/templateEngine.js.map +1 -0
- package/dist/recipes/toolRegistry.d.ts +186 -0
- package/dist/recipes/toolRegistry.js +309 -0
- package/dist/recipes/toolRegistry.js.map +1 -0
- package/dist/recipes/tools/asana.d.ts +16 -0
- package/dist/recipes/tools/asana.js +524 -0
- package/dist/recipes/tools/asana.js.map +1 -0
- package/dist/recipes/tools/calendar.d.ts +6 -0
- package/dist/recipes/tools/calendar.js +61 -0
- package/dist/recipes/tools/calendar.js.map +1 -0
- package/dist/recipes/tools/confluence.d.ts +6 -0
- package/dist/recipes/tools/confluence.js +254 -0
- package/dist/recipes/tools/confluence.js.map +1 -0
- package/dist/recipes/tools/datadog.d.ts +6 -0
- package/dist/recipes/tools/datadog.js +239 -0
- package/dist/recipes/tools/datadog.js.map +1 -0
- package/dist/recipes/tools/diagnostics.d.ts +6 -0
- package/dist/recipes/tools/diagnostics.js +36 -0
- package/dist/recipes/tools/diagnostics.js.map +1 -0
- package/dist/recipes/tools/discord.d.ts +18 -0
- package/dist/recipes/tools/discord.js +254 -0
- package/dist/recipes/tools/discord.js.map +1 -0
- package/dist/recipes/tools/file.d.ts +12 -0
- package/dist/recipes/tools/file.js +174 -0
- package/dist/recipes/tools/file.js.map +1 -0
- package/dist/recipes/tools/git.d.ts +6 -0
- package/dist/recipes/tools/git.js +63 -0
- package/dist/recipes/tools/git.js.map +1 -0
- package/dist/recipes/tools/github.d.ts +6 -0
- package/dist/recipes/tools/github.js +116 -0
- package/dist/recipes/tools/github.js.map +1 -0
- package/dist/recipes/tools/gitlab.d.ts +11 -0
- package/dist/recipes/tools/gitlab.js +285 -0
- package/dist/recipes/tools/gitlab.js.map +1 -0
- package/dist/recipes/tools/gmail.d.ts +6 -0
- package/dist/recipes/tools/gmail.js +434 -0
- package/dist/recipes/tools/gmail.js.map +1 -0
- package/dist/recipes/tools/googleDrive.d.ts +1 -0
- package/dist/recipes/tools/googleDrive.js +55 -0
- package/dist/recipes/tools/googleDrive.js.map +1 -0
- package/dist/recipes/tools/hubspot.d.ts +6 -0
- package/dist/recipes/tools/hubspot.js +232 -0
- package/dist/recipes/tools/hubspot.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +30 -0
- package/dist/recipes/tools/index.js +33 -0
- package/dist/recipes/tools/index.js.map +1 -0
- package/dist/recipes/tools/intercom.d.ts +6 -0
- package/dist/recipes/tools/intercom.js +226 -0
- package/dist/recipes/tools/intercom.js.map +1 -0
- package/dist/recipes/tools/jira.d.ts +14 -0
- package/dist/recipes/tools/jira.js +369 -0
- package/dist/recipes/tools/jira.js.map +1 -0
- package/dist/recipes/tools/linear.d.ts +7 -0
- package/dist/recipes/tools/linear.js +307 -0
- package/dist/recipes/tools/linear.js.map +1 -0
- package/dist/recipes/tools/meetingNotes.d.ts +21 -0
- package/dist/recipes/tools/meetingNotes.js +701 -0
- package/dist/recipes/tools/meetingNotes.js.map +1 -0
- package/dist/recipes/tools/notion.d.ts +6 -0
- package/dist/recipes/tools/notion.js +278 -0
- package/dist/recipes/tools/notion.js.map +1 -0
- package/dist/recipes/tools/pagerduty.d.ts +15 -0
- package/dist/recipes/tools/pagerduty.js +451 -0
- package/dist/recipes/tools/pagerduty.js.map +1 -0
- package/dist/recipes/tools/sentry.d.ts +12 -0
- package/dist/recipes/tools/sentry.js +73 -0
- package/dist/recipes/tools/sentry.js.map +1 -0
- package/dist/recipes/tools/slack.d.ts +6 -0
- package/dist/recipes/tools/slack.js +82 -0
- package/dist/recipes/tools/slack.js.map +1 -0
- package/dist/recipes/tools/stripe.d.ts +6 -0
- package/dist/recipes/tools/stripe.js +265 -0
- package/dist/recipes/tools/stripe.js.map +1 -0
- package/dist/recipes/tools/zendesk.d.ts +6 -0
- package/dist/recipes/tools/zendesk.js +245 -0
- package/dist/recipes/tools/zendesk.js.map +1 -0
- package/dist/recipes/validation.d.ts +13 -0
- package/dist/recipes/validation.js +617 -0
- package/dist/recipes/validation.js.map +1 -0
- package/dist/recipes/yamlRunner.d.ts +116 -1
- package/dist/recipes/yamlRunner.js +1000 -401
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +137 -6
- package/dist/recipesHttp.js +941 -29
- package/dist/recipesHttp.js.map +1 -1
- package/dist/riskTier.js +7 -1
- package/dist/riskTier.js.map +1 -1
- package/dist/runLog.d.ts +100 -1
- package/dist/runLog.js +258 -5
- package/dist/runLog.js.map +1 -1
- package/dist/schemas/dry-run-plan.v1.json +139 -0
- package/dist/schemas/recipe.v1.json +684 -0
- package/dist/server.d.ts +121 -8
- package/dist/server.js +538 -735
- package/dist/server.js.map +1 -1
- package/dist/ssrfGuard.d.ts +54 -0
- package/dist/ssrfGuard.js +122 -0
- package/dist/ssrfGuard.js.map +1 -0
- package/dist/streamableHttp.d.ts +39 -1
- package/dist/streamableHttp.js +128 -17
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tokenUsageTracker.d.ts +33 -0
- package/dist/tokenUsageTracker.js +146 -0
- package/dist/tokenUsageTracker.js.map +1 -0
- package/dist/tools/activityLog.d.ts +2 -0
- package/dist/tools/addLinearComment.d.ts +1 -0
- package/dist/tools/addLinearComment.js +4 -2
- package/dist/tools/addLinearComment.js.map +1 -1
- package/dist/tools/batchLsp.d.ts +3 -0
- package/dist/tools/bridgeDoctor.d.ts +1 -0
- package/dist/tools/bridgeDoctor.js +2 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/bridgeStatus.d.ts +1 -0
- package/dist/tools/cancelClaudeTask.d.ts +2 -0
- package/dist/tools/cancelClaudeTask.js +1 -0
- package/dist/tools/cancelClaudeTask.js.map +1 -1
- package/dist/tools/checkDocumentDirty.d.ts +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/closeTabs.d.ts +2 -0
- package/dist/tools/codeLens.d.ts +1 -0
- package/dist/tools/contextBundle.d.ts +1 -0
- package/dist/tools/createIssueFromAIComment.d.ts +1 -0
- package/dist/tools/createLinearIssue.d.ts +1 -0
- package/dist/tools/ctxGetTaskContext.d.ts +1 -0
- package/dist/tools/ctxQueryTraces.d.ts +1 -0
- 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/documentLinks.d.ts +1 -0
- package/dist/tools/editText.d.ts +1 -0
- package/dist/tools/enrichCommit.d.ts +1 -0
- package/dist/tools/enrichStackTrace.d.ts +1 -0
- package/dist/tools/explainDiagnostic.d.ts +1 -0
- package/dist/tools/explainSymbol.d.ts +1 -0
- package/dist/tools/fetchCalendarEvents.d.ts +1 -0
- package/dist/tools/fetchGithubIssue.d.ts +1 -0
- package/dist/tools/fetchGithubPR.d.ts +1 -0
- package/dist/tools/fetchLinearIssue.d.ts +1 -0
- package/dist/tools/fetchSentryIssue.d.ts +1 -0
- package/dist/tools/fetchSlackProfile.d.ts +1 -0
- package/dist/tools/fetchSlackProfile.js +4 -1
- package/dist/tools/fetchSlackProfile.js.map +1 -1
- package/dist/tools/fileOperations.d.ts +3 -0
- package/dist/tools/fileWatcher.d.ts +2 -0
- package/dist/tools/findFiles.d.ts +1 -0
- package/dist/tools/findRelatedTests.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.d.ts +1 -0
- package/dist/tools/foldingRanges.d.ts +1 -0
- package/dist/tools/formatDocument.d.ts +1 -0
- package/dist/tools/generateTests.d.ts +1 -0
- package/dist/tools/getAIComments.d.ts +1 -0
- package/dist/tools/getAnalyticsReport.d.ts +1 -0
- package/dist/tools/getArchitectureContext.d.ts +1 -0
- package/dist/tools/getBufferContent.d.ts +1 -0
- package/dist/tools/getChangeImpact.d.ts +1 -0
- package/dist/tools/getClaudeTaskStatus.d.ts +2 -0
- package/dist/tools/getClaudeTaskStatus.js +1 -0
- package/dist/tools/getClaudeTaskStatus.js.map +1 -1
- package/dist/tools/getCodeCoverage.d.ts +1 -0
- package/dist/tools/getCommitsForIssue.d.ts +1 -0
- package/dist/tools/getConnectorStatus.d.ts +1 -0
- package/dist/tools/getCurrentSelection.d.ts +2 -0
- package/dist/tools/getDebugState.d.ts +1 -0
- package/dist/tools/getDependencyTree.d.ts +1 -0
- package/dist/tools/getDiagnostics.d.ts +1 -0
- package/dist/tools/getDiffFromHandoff.d.ts +1 -0
- package/dist/tools/getDocumentSymbols.d.ts +25 -0
- package/dist/tools/getDocumentSymbols.js +74 -8
- package/dist/tools/getDocumentSymbols.js.map +1 -1
- package/dist/tools/getFileTree.d.ts +1 -0
- package/dist/tools/getGitDiff.d.ts +1 -0
- package/dist/tools/getGitHotspots.d.ts +1 -0
- package/dist/tools/getGitLog.d.ts +1 -0
- package/dist/tools/getGitStatus.d.ts +1 -0
- package/dist/tools/getImportTree.d.ts +1 -0
- package/dist/tools/getImportedSignatures.d.ts +1 -0
- package/dist/tools/getOpenEditors.d.ts +1 -0
- package/dist/tools/getPRTemplate.d.ts +1 -0
- package/dist/tools/getProjectContext.d.ts +1 -0
- package/dist/tools/getProjectInfo.d.ts +1 -0
- package/dist/tools/getSecurityAdvisories.d.ts +1 -0
- package/dist/tools/getSecurityAdvisories.js +10 -1
- package/dist/tools/getSecurityAdvisories.js.map +1 -1
- package/dist/tools/getSessionUsage.d.ts +4 -0
- package/dist/tools/getSessionUsage.js +3 -0
- package/dist/tools/getSessionUsage.js.map +1 -1
- package/dist/tools/getSymbolHistory.d.ts +1 -0
- package/dist/tools/getToolCapabilities.d.ts +1 -0
- package/dist/tools/getTypeSignature.d.ts +1 -0
- package/dist/tools/getWorkspaceFolders.d.ts +1 -0
- package/dist/tools/getWorkspaceSettings.d.ts +1 -0
- package/dist/tools/gitHistory.d.ts +2 -0
- package/dist/tools/gitWrite.d.ts +11 -0
- package/dist/tools/github/actions.d.ts +2 -0
- package/dist/tools/github/actions.js +4 -2
- package/dist/tools/github/actions.js.map +1 -1
- package/dist/tools/github/composite.d.ts +342 -0
- package/dist/tools/github/composite.js +343 -0
- package/dist/tools/github/composite.js.map +1 -0
- package/dist/tools/github/index.d.ts +1 -0
- package/dist/tools/github/index.js +1 -0
- package/dist/tools/github/index.js.map +1 -1
- package/dist/tools/github/issues.d.ts +4 -0
- package/dist/tools/github/issues.js +8 -4
- package/dist/tools/github/issues.js.map +1 -1
- package/dist/tools/github/pr.d.ts +7 -0
- package/dist/tools/github/pr.js +50 -12
- package/dist/tools/github/pr.js.map +1 -1
- package/dist/tools/handoffNote.d.ts +4 -0
- package/dist/tools/handoffNote.js +2 -0
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/hoverAtCursor.d.ts +1 -0
- package/dist/tools/httpClient.d.ts +2 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +47 -8
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inlayHints.d.ts +1 -0
- package/dist/tools/launchQuickTask.d.ts +2 -0
- package/dist/tools/launchQuickTask.js +1 -0
- package/dist/tools/launchQuickTask.js.map +1 -1
- package/dist/tools/listClaudeTasks.d.ts +2 -0
- package/dist/tools/listClaudeTasks.js +1 -0
- package/dist/tools/listClaudeTasks.js.map +1 -1
- package/dist/tools/listTerminals.d.ts +1 -0
- package/dist/tools/lsp.d.ts +14 -0
- package/dist/tools/navigateToSymbolByName.d.ts +1 -0
- package/dist/tools/openDiff.d.ts +1 -0
- package/dist/tools/openFile.d.ts +1 -0
- package/dist/tools/openInBrowser.d.ts +1 -0
- package/dist/tools/organizeImports.d.ts +1 -0
- package/dist/tools/performanceReport.d.ts +1 -0
- package/dist/tools/planPersistence.d.ts +5 -0
- package/dist/tools/previewEdit.d.ts +1 -0
- package/dist/tools/refactorAnalyze.d.ts +1 -0
- package/dist/tools/refactorPreview.d.ts +2 -0
- package/dist/tools/refactorPreview.js +1 -0
- package/dist/tools/refactorPreview.js.map +1 -1
- package/dist/tools/replaceBlock.d.ts +1 -0
- package/dist/tools/resumeClaudeTask.d.ts +2 -0
- package/dist/tools/resumeClaudeTask.js +1 -0
- package/dist/tools/resumeClaudeTask.js.map +1 -1
- package/dist/tools/runClaudeTask.d.ts +2 -0
- package/dist/tools/runClaudeTask.js +1 -0
- package/dist/tools/runClaudeTask.js.map +1 -1
- package/dist/tools/runCommand.d.ts +1 -0
- package/dist/tools/runTests.d.ts +1 -0
- package/dist/tools/saveDocument.d.ts +1 -0
- package/dist/tools/screenshotAndAnnotate.d.ts +1 -0
- package/dist/tools/searchAndReplace.d.ts +1 -0
- package/dist/tools/searchTools.d.ts +1 -0
- package/dist/tools/searchTools.js +1 -1
- 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/setActiveWorkspaceFolder.d.ts +1 -0
- package/dist/tools/signatureHelp.d.ts +1 -0
- package/dist/tools/slackListChannels.d.ts +1 -0
- package/dist/tools/slackListChannels.js.map +1 -1
- package/dist/tools/slackPostMessage.d.ts +1 -0
- package/dist/tools/slackPostMessage.js +11 -6
- package/dist/tools/slackPostMessage.js.map +1 -1
- package/dist/tools/terminal.d.ts +6 -0
- package/dist/tools/testTraceToSource.d.ts +1 -0
- package/dist/tools/testTraceToSource.js +2 -2
- package/dist/tools/testTraceToSource.js.map +1 -1
- package/dist/tools/transaction.d.ts +23 -0
- package/dist/tools/transaction.js +29 -0
- package/dist/tools/transaction.js.map +1 -1
- package/dist/tools/typeHierarchy.d.ts +1 -0
- package/dist/tools/updateLinearIssue.d.ts +1 -0
- package/dist/tools/updateLinearIssue.js +20 -6
- package/dist/tools/updateLinearIssue.js.map +1 -1
- package/dist/tools/utils.d.ts +2 -0
- 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/traceEncryption.d.ts +46 -0
- package/dist/traceEncryption.js +124 -0
- package/dist/traceEncryption.js.map +1 -0
- package/dist/transport.d.ts +46 -1
- package/dist/transport.js +173 -19
- package/dist/transport.js.map +1 -1
- package/package.json +30 -8
- package/scripts/mcp-stdio-shim.cjs +19 -3
- package/scripts/start-all.sh +30 -1
- package/templates/automation-policies/recipe-authoring.json +25 -0
- package/templates/automation-policy.example.json +6 -0
- package/templates/co.patchwork-os.bridge.plist +34 -0
- package/templates/policies/README.md +72 -0
- package/templates/policies/conservative.json +14 -0
- package/templates/policies/developer.json +14 -0
- package/templates/policies/headless-ci.json +24 -0
- package/templates/policies/personal-assistant.json +15 -0
- package/templates/policies/regulated-industry.json +18 -0
- package/templates/recipes/lint-on-save.yaml +1 -2
- package/templates/recipes/morning-brief-slack.yaml +57 -0
- package/templates/recipes/morning-brief.yaml +2 -2
- package/templates/recipes/project-health-check.yaml +50 -0
- package/templates/recipes/webhook/README.md +70 -0
- package/templates/recipes/webhook/capture-thought.yaml +26 -0
- package/templates/recipes/webhook/customer-escalation.yaml +49 -0
- package/templates/recipes/webhook/incident-intake.yaml +46 -0
- package/templates/recipes/webhook/meeting-prep.yaml +48 -0
- package/templates/recipes/webhook/morning-brief.yaml +57 -0
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe + run-audit route dispatcher — extracted from src/server.ts.
|
|
3
|
+
*
|
|
4
|
+
* Owns 16 routes covering the recipe authoring loop (CRUD + lint + run),
|
|
5
|
+
* the run-audit log (`/runs`, `/runs/:seq`, replay, plan), the public
|
|
6
|
+
* template registry (`/templates`), and recipe installation
|
|
7
|
+
* (`/recipes/install`). Plus the `/activation-metrics` siblings that
|
|
8
|
+
* read the same audit log.
|
|
9
|
+
*
|
|
10
|
+
* DI shape: handlers depend on 12 nullable callbacks the bridge wires
|
|
11
|
+
* onto the Server instance post-construction (`runRecipeFn`,
|
|
12
|
+
* `recipesFn`, etc.). They're passed as a `RecipeRouteDeps` struct
|
|
13
|
+
* matching the pattern from oauthRoutes.ts and mcpRoutes.ts.
|
|
14
|
+
*
|
|
15
|
+
* Module-level state: the `/templates` 5-minute cache used to live as
|
|
16
|
+
* `_templatesCache`/`_templatesCacheTs` instance fields on Server.
|
|
17
|
+
* Lifetime is process-wide either way (Server is a singleton in
|
|
18
|
+
* practice), so hoisting to module scope here is equivalent and avoids
|
|
19
|
+
* threading a mutable holder through `deps`.
|
|
20
|
+
*
|
|
21
|
+
* Mechanical lift: handler bodies are byte-identical save for
|
|
22
|
+
* `deps.<fn>` replacing `this.<fn>` and module-scoped cache vars
|
|
23
|
+
* replacing `this._templatesCache`. A few routes that previously used
|
|
24
|
+
* `await` directly in their async parent closure are wrapped in
|
|
25
|
+
* `void (async () => {...})()` so this module can return boolean
|
|
26
|
+
* synchronously — same micro-task tradeoff documented in
|
|
27
|
+
* connectorRoutes.ts.
|
|
28
|
+
*/
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { computeSummary as computeActivationSummary, loadMetrics as loadActivationMetrics, } from "./activationMetrics.js";
|
|
32
|
+
import { consumeToken, refillBucket, } from "./fp/tokenBucket.js";
|
|
33
|
+
import { validateSafeUrl } from "./ssrfGuard.js";
|
|
34
|
+
// 5-minute cache of the public template registry from the patchworkos/recipes
|
|
35
|
+
// GitHub repo. Process-wide; hoisted out of Server class state.
|
|
36
|
+
let templatesCache = null;
|
|
37
|
+
let templatesCacheTs = 0;
|
|
38
|
+
/**
|
|
39
|
+
* Per-process token bucket guarding `/recipes/generate`. Every call to the
|
|
40
|
+
* route enqueues a Claude subprocess via the orchestrator — without a cap a
|
|
41
|
+
* scripted attacker holding a bridge token can DoS the queue or run up
|
|
42
|
+
* subscription costs. The bridge's existing `--tool-rate-limit` token bucket
|
|
43
|
+
* is per-session and gates MCP tool calls, not HTTP routes; this is a
|
|
44
|
+
* separate, route-scoped cap.
|
|
45
|
+
*
|
|
46
|
+
* Default: 10 req/min — generous for a feature that takes 5–10s per call.
|
|
47
|
+
* Process-wide because the bridge HTTP transport doesn't expose a stable
|
|
48
|
+
* per-caller identity beyond "valid bearer token", and the Claude
|
|
49
|
+
* subprocess queue is the actual bottleneck regardless of caller.
|
|
50
|
+
*
|
|
51
|
+
* Exported `_resetGenerateRateLimitForTests` lets tests start each case
|
|
52
|
+
* with a full bucket.
|
|
53
|
+
*/
|
|
54
|
+
const RECIPE_GENERATE_LIMIT_PER_MIN = 10;
|
|
55
|
+
let recipeGenerateBucket = {
|
|
56
|
+
tokens: RECIPE_GENERATE_LIMIT_PER_MIN,
|
|
57
|
+
lastRefill: 0,
|
|
58
|
+
};
|
|
59
|
+
export function _resetGenerateRateLimitForTests() {
|
|
60
|
+
recipeGenerateBucket = {
|
|
61
|
+
tokens: RECIPE_GENERATE_LIMIT_PER_MIN,
|
|
62
|
+
lastRefill: 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// G-security R2 C-3 / I-3 / F-02: HTTP `vars` validation.
|
|
66
|
+
//
|
|
67
|
+
// The post-render path jail in `src/recipes/resolveRecipePath.ts` is the
|
|
68
|
+
// actual defense against template-driven traversal — but rejecting bad
|
|
69
|
+
// vars at the HTTP layer is cheaper and gives the caller a precise 400
|
|
70
|
+
// instead of a generic 500 from the runner. Validation rules:
|
|
71
|
+
//
|
|
72
|
+
// - keys — `/^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/` (identifier-ish, ≤64)
|
|
73
|
+
// - values — `/^[\w\-. :+@,]+$/u` (no `/`, no `..`, no
|
|
74
|
+
// `~`, no control chars)
|
|
75
|
+
// - type — strings only; numbers/objects/arrays → 400 (type-strict
|
|
76
|
+
// per R3 amendment 4 / I-3, prevents JSON.stringify smuggling
|
|
77
|
+
// a `..` segment into a coerced value at render time).
|
|
78
|
+
const VARS_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
79
|
+
const VARS_VALUE_RE = /^[\w\-. :+@,]+$/u;
|
|
80
|
+
/** Validate the HTTP-supplied `vars` object. Returns null on success. */
|
|
81
|
+
export function validateRecipeVars(vars) {
|
|
82
|
+
if (vars == null)
|
|
83
|
+
return null;
|
|
84
|
+
if (typeof vars !== "object" || Array.isArray(vars)) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "vars must be a plain object of string→string entries",
|
|
88
|
+
field: "type",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
92
|
+
if (!VARS_KEY_RE.test(key)) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: `vars key "${key}" must match /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/`,
|
|
96
|
+
field: "key",
|
|
97
|
+
offendingKey: key,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (typeof value !== "string") {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
error: `vars["${key}"] must be a string (got ${Array.isArray(value) ? "array" : typeof value})`,
|
|
104
|
+
field: "type",
|
|
105
|
+
offendingKey: key,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (value.length === 0 || value.length > 1024) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: `vars["${key}"] must be a non-empty string ≤ 1024 chars`,
|
|
112
|
+
field: "value",
|
|
113
|
+
offendingKey: key,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!VARS_VALUE_RE.test(value)) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: `vars["${key}"] contains disallowed characters (no "/", "..", "~", or control chars)`,
|
|
120
|
+
field: "value",
|
|
121
|
+
offendingKey: key,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------
|
|
128
|
+
// BEGIN A-PR2 EDIT BLOCK — body-cap helpers (dogfood R2 M-1 / F-08)
|
|
129
|
+
//
|
|
130
|
+
// Per-route caps; install is the strictest because the request only carries
|
|
131
|
+
// a single `source` field. See PR description for sizing rationale.
|
|
132
|
+
// Coordination note: A-PR1 also touches `recipeRoutes.ts` for `vars`
|
|
133
|
+
// validation; the helper APIs here are exclusively A-PR2's.
|
|
134
|
+
// ---------------------------------------------------------------------
|
|
135
|
+
export const RECIPE_ROUTE_BODY_CAPS = {
|
|
136
|
+
/** /recipes/install — `{ source: string }` body. */
|
|
137
|
+
install: 4 * 1024,
|
|
138
|
+
/** /recipes/generate — NL prompt. */
|
|
139
|
+
generate: 4 * 1024,
|
|
140
|
+
/** /recipes/:name/run + /recipes/run — vars envelope. */
|
|
141
|
+
run: 32 * 1024,
|
|
142
|
+
/** /recipes (POST), PUT/PATCH /recipes/:name, /recipes/lint — yaml content. */
|
|
143
|
+
content: 256 * 1024,
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Read an HTTP request body up to `max` bytes. Returns the raw string or
|
|
147
|
+
* a `too_large` code. Used directly by routes whose handlers parse the
|
|
148
|
+
* body themselves (e.g. connector token-paste handlers); also used as
|
|
149
|
+
* the byte-collection layer for `readJsonBody`.
|
|
150
|
+
*
|
|
151
|
+
* Bytes are accumulated into a single Buffer so the helper can enforce
|
|
152
|
+
* the cap incrementally — a 1 GB upload is rejected after the first
|
|
153
|
+
* overflowing chunk rather than after the full body lands in memory.
|
|
154
|
+
* On overflow the stream is drained (not destroyed) so the route can
|
|
155
|
+
* write 413 cleanly.
|
|
156
|
+
*/
|
|
157
|
+
export function readBodyWithCap(req, max) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let total = 0;
|
|
161
|
+
let aborted = false;
|
|
162
|
+
const onData = (chunk) => {
|
|
163
|
+
if (aborted)
|
|
164
|
+
return;
|
|
165
|
+
total += chunk.byteLength;
|
|
166
|
+
if (total > max) {
|
|
167
|
+
aborted = true;
|
|
168
|
+
// Resolve immediately so the route can write 413; do NOT destroy the
|
|
169
|
+
// socket here — destroying mid-upload races with the response write
|
|
170
|
+
// and the client sees EPIPE/ECONNRESET before reading the body.
|
|
171
|
+
// Subsequent chunks land in `onData` again but the `aborted` guard
|
|
172
|
+
// discards them, draining the upload until the client emits `end`.
|
|
173
|
+
resolve({ ok: false, code: "too_large" });
|
|
174
|
+
// Force the underlying stream to keep flowing so buffered upload
|
|
175
|
+
// data drains naturally. Without this Node may pause the stream
|
|
176
|
+
// when nothing is consuming chunks, leaving the socket half-open.
|
|
177
|
+
try {
|
|
178
|
+
req.resume();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// best-effort
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
chunks.push(chunk);
|
|
186
|
+
};
|
|
187
|
+
const onEnd = () => {
|
|
188
|
+
if (aborted)
|
|
189
|
+
return;
|
|
190
|
+
resolve({ ok: true, body: Buffer.concat(chunks).toString("utf-8") });
|
|
191
|
+
};
|
|
192
|
+
const onError = () => {
|
|
193
|
+
if (aborted)
|
|
194
|
+
return;
|
|
195
|
+
aborted = true;
|
|
196
|
+
// Treat aborted/error mid-stream as a malformed read. Callers that
|
|
197
|
+
// care about the distinction can check the `code` on the
|
|
198
|
+
// readJsonBody result; here we collapse to "too_large" to keep
|
|
199
|
+
// the type narrow — in practice the network-error path is rare
|
|
200
|
+
// and either response is 4xx.
|
|
201
|
+
resolve({ ok: false, code: "too_large" });
|
|
202
|
+
};
|
|
203
|
+
req.on("data", onData);
|
|
204
|
+
req.once("end", onEnd);
|
|
205
|
+
req.once("error", onError);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Read an HTTP request body up to `max` bytes, parse as JSON, return result.
|
|
210
|
+
*
|
|
211
|
+
* Returns one of three discriminated shapes:
|
|
212
|
+
* - `{ ok: true, value }` — body parsed successfully.
|
|
213
|
+
* - `{ ok: false, code: "too_large" }` — body exceeded `max`; request was
|
|
214
|
+
* destroyed eagerly and the response should be 413.
|
|
215
|
+
* - `{ ok: false, code: "invalid_json" }` — body was valid bytes but failed
|
|
216
|
+
* `JSON.parse`; response should be 400.
|
|
217
|
+
*/
|
|
218
|
+
export async function readJsonBody(req, max) {
|
|
219
|
+
const read = await readBodyWithCap(req, max);
|
|
220
|
+
if (!read.ok)
|
|
221
|
+
return { ok: false, code: "too_large" };
|
|
222
|
+
if (read.body.length === 0) {
|
|
223
|
+
// Empty bodies are passed through as `undefined`; callers decide
|
|
224
|
+
// whether that's an error (most parse `{...}` immediately).
|
|
225
|
+
return { ok: true, value: undefined };
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
return { ok: true, value: JSON.parse(read.body) };
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return { ok: false, code: "invalid_json" };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Standard 413 helper used by the routes when `readJsonBody` overflows.
|
|
236
|
+
*
|
|
237
|
+
* Note: we do NOT destroy the underlying socket — `res.end()` is sufficient.
|
|
238
|
+
* Destroying mid-upload is fragile across platforms (macOS races
|
|
239
|
+
* EPIPE/ECONNRESET to the client before the 413 body is delivered).
|
|
240
|
+
* The matching `readJsonBody` no-op-data drain keeps the upload flowing
|
|
241
|
+
* until the client emits `end`, so the server returns the response cleanly.
|
|
242
|
+
*/
|
|
243
|
+
export function respond413(res, max) {
|
|
244
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify({
|
|
246
|
+
ok: false,
|
|
247
|
+
error: `Request body exceeds ${max}-byte limit`,
|
|
248
|
+
code: "body_too_large",
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
/** Standard 400 helper for malformed JSON. */
|
|
252
|
+
function respondInvalidJson(res) {
|
|
253
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
254
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Try to handle a recipe / run-audit / template route. Returns true if
|
|
258
|
+
* the route was dispatched (caller should `return` from the request
|
|
259
|
+
* handler), false if no route matched.
|
|
260
|
+
*
|
|
261
|
+
* Must be called AFTER bearer-auth — none of these routes are public.
|
|
262
|
+
*/
|
|
263
|
+
export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
264
|
+
const recipeNameRunMatch = req.method === "POST"
|
|
265
|
+
? /^\/recipes\/([^/]+)\/run$/.exec(parsedUrl.pathname)
|
|
266
|
+
: null;
|
|
267
|
+
if (recipeNameRunMatch) {
|
|
268
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.run (32 KB).
|
|
269
|
+
const nameFromPath = decodeURIComponent(recipeNameRunMatch[1] ?? "");
|
|
270
|
+
void (async () => {
|
|
271
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.run);
|
|
272
|
+
if (!parsedBody.ok) {
|
|
273
|
+
if (parsedBody.code === "too_large") {
|
|
274
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.run);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
respondInvalidJson(res);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const parsed = parsedBody.value ?? {};
|
|
283
|
+
const varsRaw = parsed.vars ?? parsed.inputs;
|
|
284
|
+
const varsErr = validateRecipeVars(varsRaw);
|
|
285
|
+
if (varsErr) {
|
|
286
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
287
|
+
res.end(JSON.stringify(varsErr));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const vars = varsRaw && typeof varsRaw === "object" && !Array.isArray(varsRaw)
|
|
291
|
+
? varsRaw
|
|
292
|
+
: undefined;
|
|
293
|
+
if (!deps.runRecipeFn) {
|
|
294
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
295
|
+
res.end(JSON.stringify({
|
|
296
|
+
ok: false,
|
|
297
|
+
error: "Recipe execution unavailable — requires --claude-driver subprocess",
|
|
298
|
+
}));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const result = await deps.runRecipeFn(nameFromPath, vars);
|
|
302
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
303
|
+
"Content-Type": "application/json",
|
|
304
|
+
});
|
|
305
|
+
res.end(JSON.stringify(result));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
respondInvalidJson(res);
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (parsedUrl.pathname === "/recipes/run" && req.method === "POST") {
|
|
314
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.run (32 KB).
|
|
315
|
+
void (async () => {
|
|
316
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.run);
|
|
317
|
+
if (!parsedBody.ok) {
|
|
318
|
+
if (parsedBody.code === "too_large") {
|
|
319
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.run);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
respondInvalidJson(res);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const parsed = parsedBody.value ?? {};
|
|
328
|
+
const name = parsed.name;
|
|
329
|
+
const varsErr = validateRecipeVars(parsed.vars);
|
|
330
|
+
if (varsErr) {
|
|
331
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
332
|
+
res.end(JSON.stringify(varsErr));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const vars = parsed.vars &&
|
|
336
|
+
typeof parsed.vars === "object" &&
|
|
337
|
+
!Array.isArray(parsed.vars)
|
|
338
|
+
? parsed.vars
|
|
339
|
+
: undefined;
|
|
340
|
+
if (typeof name !== "string" || !name) {
|
|
341
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
342
|
+
res.end(JSON.stringify({ ok: false, error: "name required" }));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!deps.runRecipeFn) {
|
|
346
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
347
|
+
res.end(JSON.stringify({
|
|
348
|
+
ok: false,
|
|
349
|
+
error: "Recipe execution unavailable — requires --claude-driver subprocess",
|
|
350
|
+
}));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const result = await deps.runRecipeFn(name, vars);
|
|
354
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
355
|
+
"Content-Type": "application/json",
|
|
356
|
+
});
|
|
357
|
+
res.end(JSON.stringify(result));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
respondInvalidJson(res);
|
|
361
|
+
}
|
|
362
|
+
})();
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (parsedUrl.pathname === "/activation-metrics" && req.method === "GET") {
|
|
366
|
+
try {
|
|
367
|
+
const metrics = loadActivationMetrics();
|
|
368
|
+
const summary = computeActivationSummary(metrics);
|
|
369
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
370
|
+
res.end(JSON.stringify({ metrics, summary }));
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
374
|
+
res.end(JSON.stringify({
|
|
375
|
+
error: err instanceof Error ? err.message : String(err),
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
if (parsedUrl.pathname === "/runs" && req.method === "GET") {
|
|
381
|
+
try {
|
|
382
|
+
const sp = parsedUrl.searchParams;
|
|
383
|
+
const limitRaw = sp.get("limit");
|
|
384
|
+
const afterRaw = sp.get("after");
|
|
385
|
+
const trigger = sp.get("trigger");
|
|
386
|
+
const status = sp.get("status");
|
|
387
|
+
const recipe = sp.get("recipe");
|
|
388
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
389
|
+
const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
|
|
390
|
+
const runs = deps.runsFn?.({
|
|
391
|
+
...(Number.isFinite(limit) && { limit }),
|
|
392
|
+
...(trigger && { trigger }),
|
|
393
|
+
...(status && { status }),
|
|
394
|
+
...(recipe && { recipe }),
|
|
395
|
+
...(Number.isFinite(after) && { after }),
|
|
396
|
+
}) ?? [];
|
|
397
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
398
|
+
res.end(JSON.stringify({ runs }));
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
402
|
+
res.end(JSON.stringify({
|
|
403
|
+
error: err instanceof Error ? err.message : String(err),
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
// GET /runs/:seq — single run detail (includes stepResults if present)
|
|
409
|
+
const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
|
|
410
|
+
if (runDetailMatch?.[1]) {
|
|
411
|
+
const seq = Number.parseInt(runDetailMatch[1], 10);
|
|
412
|
+
try {
|
|
413
|
+
const run = deps.runDetailFn?.(seq) ?? null;
|
|
414
|
+
if (!run) {
|
|
415
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
416
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
420
|
+
res.end(JSON.stringify({ run }));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
425
|
+
res.end(JSON.stringify({
|
|
426
|
+
error: err instanceof Error ? err.message : String(err),
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
// POST /runs/:seq/replay — VD-4 mocked replay. Re-runs the recipe with all
|
|
432
|
+
// tool/agent execution intercepted to return captured outputs from the
|
|
433
|
+
// original run. No external IO, no side effects. Real-mode replay is not
|
|
434
|
+
// exposed here yet — must ship separately with confirmation UX +
|
|
435
|
+
// kill-switch interaction.
|
|
436
|
+
const runReplayMatch = req.method === "POST"
|
|
437
|
+
? /^\/runs\/(\d+)\/replay$/.exec(parsedUrl.pathname)
|
|
438
|
+
: null;
|
|
439
|
+
if (runReplayMatch?.[1]) {
|
|
440
|
+
const seq = Number.parseInt(runReplayMatch[1], 10);
|
|
441
|
+
void (async () => {
|
|
442
|
+
try {
|
|
443
|
+
if (!deps.runReplayFn) {
|
|
444
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
445
|
+
res.end(JSON.stringify({ error: "replay_unavailable" }));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const result = await deps.runReplayFn(seq);
|
|
449
|
+
if (result.error === "run_not_found") {
|
|
450
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
451
|
+
}
|
|
452
|
+
else if (!result.ok) {
|
|
453
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
457
|
+
}
|
|
458
|
+
res.end(JSON.stringify(result));
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
462
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
463
|
+
res.end(JSON.stringify({ error: msg }));
|
|
464
|
+
}
|
|
465
|
+
})();
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
// GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
|
|
469
|
+
const runPlanMatch = req.method === "GET"
|
|
470
|
+
? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
|
|
471
|
+
: null;
|
|
472
|
+
if (runPlanMatch?.[1]) {
|
|
473
|
+
const seq = Number.parseInt(runPlanMatch[1], 10);
|
|
474
|
+
void (async () => {
|
|
475
|
+
try {
|
|
476
|
+
const run = deps.runDetailFn?.(seq) ?? null;
|
|
477
|
+
if (!run) {
|
|
478
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
479
|
+
res.end(JSON.stringify({ error: "run_not_found" }));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (!deps.runPlanFn) {
|
|
483
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
484
|
+
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// triggerSource appends ":agent" suffix — strip before file lookup
|
|
488
|
+
const recipeName = run.recipeName.replace(/:agent$/, "");
|
|
489
|
+
const plan = await deps.runPlanFn(recipeName);
|
|
490
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
491
|
+
res.end(JSON.stringify({ plan }));
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
495
|
+
const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
|
|
496
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
497
|
+
res.end(JSON.stringify({ error: msg }));
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
if (parsedUrl.pathname === "/recipes/generate" && req.method === "POST") {
|
|
503
|
+
void (async () => {
|
|
504
|
+
// Refill + try-consume one token. 429 if bucket is empty — `Retry-After`
|
|
505
|
+
// in seconds rounds up to the next refill of one whole token.
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
const refilled = refillBucket(recipeGenerateBucket, now, RECIPE_GENERATE_LIMIT_PER_MIN);
|
|
508
|
+
const consumed = consumeToken(refilled);
|
|
509
|
+
recipeGenerateBucket = consumed.nextState;
|
|
510
|
+
if (!consumed.allowed) {
|
|
511
|
+
const secondsToOneToken = Math.ceil(((1 - consumed.nextState.tokens) / RECIPE_GENERATE_LIMIT_PER_MIN) *
|
|
512
|
+
60);
|
|
513
|
+
res.writeHead(429, {
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
"Retry-After": String(Math.max(1, secondsToOneToken)),
|
|
516
|
+
});
|
|
517
|
+
res.end(JSON.stringify({
|
|
518
|
+
ok: false,
|
|
519
|
+
error: `Rate limit exceeded — max ${RECIPE_GENERATE_LIMIT_PER_MIN} requests per minute`,
|
|
520
|
+
retryAfterSeconds: Math.max(1, secondsToOneToken),
|
|
521
|
+
}));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.generate);
|
|
525
|
+
if (!parsedBody.ok) {
|
|
526
|
+
if (parsedBody.code === "too_large") {
|
|
527
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.generate);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
respondInvalidJson(res);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const prompt = parsedBody.value
|
|
535
|
+
?.prompt;
|
|
536
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
537
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
538
|
+
res.end(JSON.stringify({
|
|
539
|
+
ok: false,
|
|
540
|
+
error: "prompt must be a non-empty string",
|
|
541
|
+
}));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!deps.generateRecipeFn) {
|
|
545
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
546
|
+
res.end(JSON.stringify({
|
|
547
|
+
ok: false,
|
|
548
|
+
error: "Recipe generation unavailable — requires --claude-driver subprocess",
|
|
549
|
+
unavailable: true,
|
|
550
|
+
}));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const result = await deps.generateRecipeFn(prompt.trim());
|
|
555
|
+
const status = result.ok ? 200 : result.unavailable ? 503 : 422;
|
|
556
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
557
|
+
res.end(JSON.stringify(result));
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
561
|
+
res.end(JSON.stringify({
|
|
562
|
+
ok: false,
|
|
563
|
+
error: err instanceof Error ? err.message : String(err),
|
|
564
|
+
}));
|
|
565
|
+
}
|
|
566
|
+
})();
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
if (req.url === "/recipes" && req.method === "POST") {
|
|
570
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
571
|
+
void (async () => {
|
|
572
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
573
|
+
if (!parsedBody.ok) {
|
|
574
|
+
if (parsedBody.code === "too_large") {
|
|
575
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
respondInvalidJson(res);
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const draft = (parsedBody.value ?? {});
|
|
584
|
+
if (typeof draft.name !== "string" || !draft.name) {
|
|
585
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
586
|
+
res.end(JSON.stringify({ ok: false, error: "name required" }));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (!deps.saveRecipeFn) {
|
|
590
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
591
|
+
res.end(JSON.stringify({
|
|
592
|
+
ok: false,
|
|
593
|
+
error: "Recipe saving unavailable",
|
|
594
|
+
}));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const result = deps.saveRecipeFn(draft);
|
|
598
|
+
res.writeHead(result.ok ? 201 : 400, {
|
|
599
|
+
"Content-Type": "application/json",
|
|
600
|
+
});
|
|
601
|
+
res.end(JSON.stringify(result));
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
respondInvalidJson(res);
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
// PATCH /recipes/:name/trust — update trust level for a recipe.
|
|
610
|
+
const recipeTrustMatch = req.method === "PATCH"
|
|
611
|
+
? /^\/recipes\/([^/]+)\/trust$/.exec(parsedUrl.pathname)
|
|
612
|
+
: null;
|
|
613
|
+
if (recipeTrustMatch?.[1]) {
|
|
614
|
+
const name = decodeURIComponent(recipeTrustMatch[1]);
|
|
615
|
+
void (async () => {
|
|
616
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
|
|
617
|
+
if (!parsedBody.ok) {
|
|
618
|
+
if (parsedBody.code === "too_large") {
|
|
619
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.install);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
respondInvalidJson(res);
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const level = parsedBody.value
|
|
627
|
+
?.level;
|
|
628
|
+
if (typeof level !== "string" || !level) {
|
|
629
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
630
|
+
res.end(JSON.stringify({ ok: false, error: "level (string) required" }));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (!deps.setRecipeTrustFn) {
|
|
634
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
635
|
+
res.end(JSON.stringify({ ok: false, error: "Trust management unavailable" }));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const result = deps.setRecipeTrustFn(name, level);
|
|
639
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
640
|
+
"Content-Type": "application/json",
|
|
641
|
+
});
|
|
642
|
+
res.end(JSON.stringify(result));
|
|
643
|
+
})();
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
647
|
+
if (recipePatchMatch && req.method === "PATCH") {
|
|
648
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
649
|
+
const name = decodeURIComponent(recipePatchMatch[1] ?? "");
|
|
650
|
+
void (async () => {
|
|
651
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
652
|
+
if (!parsedBody.ok) {
|
|
653
|
+
if (parsedBody.code === "too_large") {
|
|
654
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
respondInvalidJson(res);
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
const body = parsedBody.value ?? {};
|
|
663
|
+
if (typeof body.enabled !== "boolean") {
|
|
664
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
665
|
+
res.end(JSON.stringify({
|
|
666
|
+
ok: false,
|
|
667
|
+
error: "enabled (boolean) required",
|
|
668
|
+
}));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (!deps.setRecipeEnabledFn) {
|
|
672
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
673
|
+
res.end(JSON.stringify({ ok: false, error: "Not available" }));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const result = deps.setRecipeEnabledFn(name, body.enabled);
|
|
677
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
678
|
+
"Content-Type": "application/json",
|
|
679
|
+
});
|
|
680
|
+
res.end(JSON.stringify(result));
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
respondInvalidJson(res);
|
|
684
|
+
}
|
|
685
|
+
})();
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
if (parsedUrl.pathname === "/recipes/lint" && req.method === "POST") {
|
|
689
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
690
|
+
void (async () => {
|
|
691
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
692
|
+
if (!parsedBody.ok) {
|
|
693
|
+
if (parsedBody.code === "too_large") {
|
|
694
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
respondInvalidJson(res);
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
const body = parsedBody.value ?? {};
|
|
703
|
+
if (typeof body?.content !== "string") {
|
|
704
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
705
|
+
res.end(JSON.stringify({
|
|
706
|
+
ok: false,
|
|
707
|
+
error: "content (string) required",
|
|
708
|
+
}));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!deps.lintRecipeContentFn) {
|
|
712
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
713
|
+
res.end(JSON.stringify({
|
|
714
|
+
ok: false,
|
|
715
|
+
error: "Recipe lint unavailable",
|
|
716
|
+
}));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const result = deps.lintRecipeContentFn(body.content);
|
|
720
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
721
|
+
res.end(JSON.stringify(result));
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
respondInvalidJson(res);
|
|
725
|
+
}
|
|
726
|
+
})();
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
// GET /recipes/:name/plan — dry-run plan for a recipe by name. Returns the
|
|
730
|
+
// same RecipeDryRunPlan shape as GET /runs/:seq/plan but without needing a
|
|
731
|
+
// past run seq — useful for pre-flight review before a first run.
|
|
732
|
+
const recipePlanMatch = req.method === "GET"
|
|
733
|
+
? /^\/recipes\/([^/]+)\/plan$/.exec(parsedUrl.pathname)
|
|
734
|
+
: null;
|
|
735
|
+
if (recipePlanMatch?.[1]) {
|
|
736
|
+
const name = decodeURIComponent(recipePlanMatch[1]);
|
|
737
|
+
void (async () => {
|
|
738
|
+
try {
|
|
739
|
+
if (!deps.runPlanFn) {
|
|
740
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
741
|
+
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const plan = await deps.runPlanFn(name);
|
|
745
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
746
|
+
res.end(JSON.stringify({ plan }));
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
750
|
+
const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
|
|
751
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
752
|
+
res.end(JSON.stringify({ error: msg }));
|
|
753
|
+
}
|
|
754
|
+
})();
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
const recipeContentMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
758
|
+
if (recipeContentMatch && req.method === "GET") {
|
|
759
|
+
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
760
|
+
if (!deps.loadRecipeContentFn) {
|
|
761
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
762
|
+
res.end(JSON.stringify({ ok: false, error: "Recipe content unavailable" }));
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
const result = deps.loadRecipeContentFn(name);
|
|
766
|
+
if (!result) {
|
|
767
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
768
|
+
res.end(JSON.stringify({ ok: false, error: "Recipe not found" }));
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
772
|
+
res.end(JSON.stringify(result));
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
if (recipeContentMatch && req.method === "PUT") {
|
|
776
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
777
|
+
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
778
|
+
void (async () => {
|
|
779
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
780
|
+
if (!parsedBody.ok) {
|
|
781
|
+
if (parsedBody.code === "too_large") {
|
|
782
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
respondInvalidJson(res);
|
|
786
|
+
}
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const body = parsedBody.value ?? {};
|
|
791
|
+
if (typeof body.content !== "string") {
|
|
792
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
793
|
+
res.end(JSON.stringify({
|
|
794
|
+
ok: false,
|
|
795
|
+
error: "content (string) required",
|
|
796
|
+
}));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (!deps.saveRecipeContentFn) {
|
|
800
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
801
|
+
res.end(JSON.stringify({
|
|
802
|
+
ok: false,
|
|
803
|
+
error: "Recipe content saving unavailable",
|
|
804
|
+
}));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const result = deps.saveRecipeContentFn(name, body.content);
|
|
808
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
809
|
+
"Content-Type": "application/json",
|
|
810
|
+
});
|
|
811
|
+
res.end(JSON.stringify(result));
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
respondInvalidJson(res);
|
|
815
|
+
}
|
|
816
|
+
})();
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
if (recipeContentMatch && req.method === "DELETE") {
|
|
820
|
+
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
821
|
+
if (!deps.deleteRecipeContentFn) {
|
|
822
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
823
|
+
res.end(JSON.stringify({
|
|
824
|
+
ok: false,
|
|
825
|
+
error: "Recipe deletion unavailable",
|
|
826
|
+
}));
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
const result = deps.deleteRecipeContentFn(name);
|
|
830
|
+
const status = result.ok
|
|
831
|
+
? 200
|
|
832
|
+
: result.error === "Recipe not found"
|
|
833
|
+
? 404
|
|
834
|
+
: 400;
|
|
835
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
836
|
+
res.end(JSON.stringify(result));
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
// POST /recipes/:name/duplicate — copy recipe as next available variant name
|
|
840
|
+
const duplicateMatch = /^\/recipes\/([^/]+)\/duplicate$/.exec(parsedUrl.pathname);
|
|
841
|
+
if (duplicateMatch && req.method === "POST") {
|
|
842
|
+
const name = decodeURIComponent(duplicateMatch[1] ?? "");
|
|
843
|
+
if (!deps.duplicateRecipeFn) {
|
|
844
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
845
|
+
res.end(JSON.stringify({ ok: false, error: "Duplicate unavailable" }));
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
const result = deps.duplicateRecipeFn(name);
|
|
849
|
+
const status = result.ok
|
|
850
|
+
? 201
|
|
851
|
+
: result.error === "Recipe not found"
|
|
852
|
+
? 404
|
|
853
|
+
: 400;
|
|
854
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
855
|
+
res.end(JSON.stringify(result));
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
// POST /recipes/:name/promote — promote variant to canonical name.
|
|
859
|
+
// Body: { targetName: string }
|
|
860
|
+
const promoteMatch = /^\/recipes\/([^/]+)\/promote$/.exec(parsedUrl.pathname);
|
|
861
|
+
if (promoteMatch && req.method === "POST") {
|
|
862
|
+
const variantName = decodeURIComponent(promoteMatch[1] ?? "");
|
|
863
|
+
void (async () => {
|
|
864
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
865
|
+
if (!parsedBody.ok) {
|
|
866
|
+
respondInvalidJson(res);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const { targetName, force } = parsedBody.value ?? {};
|
|
870
|
+
if (typeof targetName !== "string" || !targetName.trim()) {
|
|
871
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
872
|
+
res.end(JSON.stringify({ ok: false, error: "targetName required" }));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (!deps.promoteRecipeVariantFn) {
|
|
876
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
877
|
+
res.end(JSON.stringify({ ok: false, error: "Promote unavailable" }));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
try {
|
|
881
|
+
const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
|
|
882
|
+
force: force === true,
|
|
883
|
+
});
|
|
884
|
+
const httpStatus = result.ok
|
|
885
|
+
? 200
|
|
886
|
+
: result.targetExists
|
|
887
|
+
? 409
|
|
888
|
+
: result.error?.includes("not found")
|
|
889
|
+
? 404
|
|
890
|
+
: 400;
|
|
891
|
+
res.writeHead(httpStatus, { "Content-Type": "application/json" });
|
|
892
|
+
res.end(JSON.stringify(result));
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
896
|
+
res.end(JSON.stringify({
|
|
897
|
+
ok: false,
|
|
898
|
+
error: err instanceof Error ? err.message : String(err),
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
901
|
+
})();
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
if (req.url === "/recipes" && req.method === "GET") {
|
|
905
|
+
try {
|
|
906
|
+
const data = deps.recipesFn?.() ?? { recipesDir: null, recipes: [] };
|
|
907
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
908
|
+
res.end(JSON.stringify(data));
|
|
909
|
+
}
|
|
910
|
+
catch (err) {
|
|
911
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
912
|
+
res.end(JSON.stringify({
|
|
913
|
+
error: err instanceof Error ? err.message : String(err),
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
if (parsedUrl.pathname === "/templates" && req.method === "GET") {
|
|
919
|
+
void (async () => {
|
|
920
|
+
try {
|
|
921
|
+
const now = Date.now();
|
|
922
|
+
if (!templatesCache || now - templatesCacheTs > 5 * 60 * 1000) {
|
|
923
|
+
const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
|
|
924
|
+
if (!ghRes.ok) {
|
|
925
|
+
throw new Error(`GitHub returned ${ghRes.status}`);
|
|
926
|
+
}
|
|
927
|
+
templatesCache = (await ghRes.json());
|
|
928
|
+
templatesCacheTs = now;
|
|
929
|
+
}
|
|
930
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
931
|
+
res.end(JSON.stringify(templatesCache));
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
935
|
+
res.end(JSON.stringify({
|
|
936
|
+
ok: false,
|
|
937
|
+
error: err instanceof Error ? err.message : String(err),
|
|
938
|
+
}));
|
|
939
|
+
}
|
|
940
|
+
})();
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
if (parsedUrl.pathname === "/recipes/install" && req.method === "POST") {
|
|
944
|
+
// ---------------------------------------------------------------------
|
|
945
|
+
// BEGIN A-PR2 EDIT BLOCK — `/recipes/install` rework.
|
|
946
|
+
//
|
|
947
|
+
// Replaces the previous let-body-string accumulator with `readJsonBody`
|
|
948
|
+
// (4 KB cap), default-denies non-github sources via
|
|
949
|
+
// `CLAUDE_IDE_BRIDGE_INSTALL_ALLOWED_HOSTS`, and translates fetch errors
|
|
950
|
+
// into proper 4xx status codes (R2 H-routes Bug 2 — was always 500).
|
|
951
|
+
//
|
|
952
|
+
// SSRF guard runs AFTER allowlist match per R3 DP-2 sub-issue: this means
|
|
953
|
+
// an explicitly-allowlisted hostname STILL has to clear the SSRF check
|
|
954
|
+
// (so an admin can't accidentally allowlist `localhost`).
|
|
955
|
+
// ---------------------------------------------------------------------
|
|
956
|
+
void (async () => {
|
|
957
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
|
|
958
|
+
if (!parsedBody.ok) {
|
|
959
|
+
if (parsedBody.code === "too_large") {
|
|
960
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.install);
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
respondInvalidJson(res);
|
|
964
|
+
}
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
const source = parsedBody.value?.source;
|
|
969
|
+
if (!source || typeof source !== "string") {
|
|
970
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
971
|
+
res.end(JSON.stringify({ ok: false, error: "Missing source field" }));
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// -----------------------------------------------------------------
|
|
975
|
+
// BUNDLE INSTALL DISPATCH (#130 PR A).
|
|
976
|
+
//
|
|
977
|
+
// `github:patchworkos/recipes/bundles/<name>` installs every recipe
|
|
978
|
+
// listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
|
|
979
|
+
// and policy template (`policy_template`) declared in the manifest
|
|
980
|
+
// are surfaced as advisory-only — wiring those needs separate
|
|
981
|
+
// decisions (npm-install surface, policy application UX) tracked
|
|
982
|
+
// outside this PR. See the #130 scoping comment.
|
|
983
|
+
// -----------------------------------------------------------------
|
|
984
|
+
const bundlePrefix = "github:patchworkos/recipes/bundles/";
|
|
985
|
+
if (source.startsWith(bundlePrefix)) {
|
|
986
|
+
const bundleName = source.slice(bundlePrefix.length);
|
|
987
|
+
const { isSafeBasename } = await import("./commands/recipeInstall.js");
|
|
988
|
+
if (!isSafeBasename(bundleName)) {
|
|
989
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
990
|
+
res.end(JSON.stringify({
|
|
991
|
+
ok: false,
|
|
992
|
+
error: "Invalid bundle name in source",
|
|
993
|
+
code: "invalid_bundle_name",
|
|
994
|
+
}));
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const manifestUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/bundles/${bundleName}/patchwork-bundle.json`;
|
|
998
|
+
const ctl = new AbortController();
|
|
999
|
+
const timeout = setTimeout(() => ctl.abort(), 30_000);
|
|
1000
|
+
let manifestRes;
|
|
1001
|
+
try {
|
|
1002
|
+
manifestRes = await fetch(manifestUrl, {
|
|
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
|
+
}
|
|
1017
|
+
clearTimeout(timeout);
|
|
1018
|
+
if (!manifestRes.ok) {
|
|
1019
|
+
const outStatus = manifestRes.status === 404 ? 404 : 502;
|
|
1020
|
+
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1021
|
+
res.end(JSON.stringify({
|
|
1022
|
+
ok: false,
|
|
1023
|
+
error: `Bundle manifest at ${manifestUrl} returned ${manifestRes.status}`,
|
|
1024
|
+
code: "bundle_fetch_upstream_error",
|
|
1025
|
+
upstreamStatus: manifestRes.status,
|
|
1026
|
+
}));
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
// 64 KB hard cap on manifest body — real `patchwork-bundle.json`
|
|
1030
|
+
// is single-digit KB; anything past 64 KB is hostile or malformed.
|
|
1031
|
+
const manifestBuf = await manifestRes.arrayBuffer();
|
|
1032
|
+
if (manifestBuf.byteLength > 64 * 1024) {
|
|
1033
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1034
|
+
res.end(JSON.stringify({
|
|
1035
|
+
ok: false,
|
|
1036
|
+
error: "Bundle manifest exceeds 64 KB cap",
|
|
1037
|
+
code: "bundle_manifest_too_large",
|
|
1038
|
+
}));
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
let manifest;
|
|
1042
|
+
try {
|
|
1043
|
+
manifest = JSON.parse(Buffer.from(manifestBuf).toString("utf-8"));
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1047
|
+
res.end(JSON.stringify({
|
|
1048
|
+
ok: false,
|
|
1049
|
+
error: `Bundle manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
1050
|
+
code: "bundle_manifest_invalid_json",
|
|
1051
|
+
}));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (!Array.isArray(manifest.recipes) ||
|
|
1055
|
+
manifest.recipes.length === 0 ||
|
|
1056
|
+
!manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
|
|
1057
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1058
|
+
res.end(JSON.stringify({
|
|
1059
|
+
ok: false,
|
|
1060
|
+
error: "Bundle manifest must declare a non-empty `recipes` array of safe recipe names",
|
|
1061
|
+
code: "bundle_manifest_invalid_recipes",
|
|
1062
|
+
}));
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
// Install each declared recipe. Errors are collected but don't
|
|
1066
|
+
// abort the loop — partial bundle install is more useful than
|
|
1067
|
+
// all-or-nothing when one of N recipes is broken.
|
|
1068
|
+
const installed = [];
|
|
1069
|
+
const failures = [];
|
|
1070
|
+
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1071
|
+
const { installRecipeFromFile } = await import("./recipes/installer.js");
|
|
1072
|
+
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1073
|
+
mkdirSync(recipesDir, { recursive: true });
|
|
1074
|
+
for (const r of manifest.recipes) {
|
|
1075
|
+
const recipeUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${r}/${r}.yaml`;
|
|
1076
|
+
const recipeCtl = new AbortController();
|
|
1077
|
+
const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
|
|
1078
|
+
try {
|
|
1079
|
+
const recipeRes = await fetch(recipeUrl, {
|
|
1080
|
+
signal: recipeCtl.signal,
|
|
1081
|
+
redirect: "follow",
|
|
1082
|
+
});
|
|
1083
|
+
clearTimeout(recipeTimeout);
|
|
1084
|
+
if (!recipeRes.ok) {
|
|
1085
|
+
failures.push({
|
|
1086
|
+
name: r,
|
|
1087
|
+
error: `Upstream returned ${recipeRes.status}`,
|
|
1088
|
+
});
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const recipeBuf = await recipeRes.arrayBuffer();
|
|
1092
|
+
if (recipeBuf.byteLength > 1024 * 1024) {
|
|
1093
|
+
failures.push({
|
|
1094
|
+
name: r,
|
|
1095
|
+
error: "Recipe body exceeded 1 MB cap",
|
|
1096
|
+
});
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
const yamlText = Buffer.from(recipeBuf).toString("utf-8");
|
|
1100
|
+
const tmpFile = path.join(os.tmpdir(), `patchwork-bundle-install-${Date.now()}-${r}.yaml`);
|
|
1101
|
+
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1102
|
+
try {
|
|
1103
|
+
const installResult = installRecipeFromFile(tmpFile, {
|
|
1104
|
+
recipesDir,
|
|
1105
|
+
});
|
|
1106
|
+
installed.push({ name: r, action: installResult.action });
|
|
1107
|
+
}
|
|
1108
|
+
finally {
|
|
1109
|
+
try {
|
|
1110
|
+
unlinkSync(tmpFile);
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
// best-effort cleanup
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
catch (err) {
|
|
1118
|
+
clearTimeout(recipeTimeout);
|
|
1119
|
+
failures.push({
|
|
1120
|
+
name: r,
|
|
1121
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// Plugin / policy_template surfaced advisory-only.
|
|
1126
|
+
const advisory = {};
|
|
1127
|
+
if (typeof manifest.plugin === "string") {
|
|
1128
|
+
advisory.plugin = `Bundle declares plugin "${manifest.plugin}" — not installed; run \`npm install -g ${manifest.plugin}\` separately.`;
|
|
1129
|
+
}
|
|
1130
|
+
if (typeof manifest.policy_template === "string") {
|
|
1131
|
+
advisory.policy_template = `Bundle declares policy template "${manifest.policy_template}" — not applied; review and apply manually.`;
|
|
1132
|
+
}
|
|
1133
|
+
// 200 if any recipe installed; 502 otherwise. Always include both
|
|
1134
|
+
// arrays so callers (CLI + dashboard) can render partial-success.
|
|
1135
|
+
const status = installed.length > 0 ? 200 : 502;
|
|
1136
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1137
|
+
res.end(JSON.stringify({
|
|
1138
|
+
ok: installed.length > 0,
|
|
1139
|
+
kind: "bundle",
|
|
1140
|
+
bundleName,
|
|
1141
|
+
installed,
|
|
1142
|
+
failures,
|
|
1143
|
+
...(Object.keys(advisory).length > 0 && { advisory }),
|
|
1144
|
+
}));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const githubPrefix = "github:patchworkos/recipes/recipes/";
|
|
1148
|
+
let fetchUrl;
|
|
1149
|
+
let recipeName;
|
|
1150
|
+
if (source.startsWith(githubPrefix)) {
|
|
1151
|
+
recipeName = source.slice(githubPrefix.length);
|
|
1152
|
+
// The constructed URL is internal — recipeName must be a safe
|
|
1153
|
+
// single-segment so we don't end up encoding `../etc/passwd` into
|
|
1154
|
+
// the path. Reuse the strict basename predicate from `recipeInstall`.
|
|
1155
|
+
const { isSafeBasename } = await import("./commands/recipeInstall.js");
|
|
1156
|
+
if (!isSafeBasename(recipeName)) {
|
|
1157
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1158
|
+
res.end(JSON.stringify({
|
|
1159
|
+
ok: false,
|
|
1160
|
+
error: "Invalid recipe name in source",
|
|
1161
|
+
}));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
|
|
1165
|
+
}
|
|
1166
|
+
else if (source.startsWith("https://")) {
|
|
1167
|
+
// Non-github source: must clear the env-var allowlist AND the SSRF
|
|
1168
|
+
// guard. Default-deny when env var unset (R3 DP-2 confirmed).
|
|
1169
|
+
let parsedSource;
|
|
1170
|
+
try {
|
|
1171
|
+
parsedSource = new URL(source);
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1175
|
+
res.end(JSON.stringify({
|
|
1176
|
+
ok: false,
|
|
1177
|
+
error: "Invalid source URL",
|
|
1178
|
+
code: "invalid_source_url",
|
|
1179
|
+
}));
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
// Built-in github/raw.githubusercontent hosts are always permitted
|
|
1183
|
+
// — they match the github: shorthand surface above.
|
|
1184
|
+
const ALWAYS_ALLOWED = new Set([
|
|
1185
|
+
"github.com",
|
|
1186
|
+
"www.github.com",
|
|
1187
|
+
"raw.githubusercontent.com",
|
|
1188
|
+
]);
|
|
1189
|
+
const envAllowed = (process.env.CLAUDE_IDE_BRIDGE_INSTALL_ALLOWED_HOSTS ?? "")
|
|
1190
|
+
.split(",")
|
|
1191
|
+
.map((h) => h.trim().toLowerCase())
|
|
1192
|
+
.filter(Boolean);
|
|
1193
|
+
const hostLower = parsedSource.hostname.toLowerCase();
|
|
1194
|
+
const inAllowlist = ALWAYS_ALLOWED.has(hostLower) || envAllowed.includes(hostLower);
|
|
1195
|
+
if (!inAllowlist) {
|
|
1196
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1197
|
+
res.end(JSON.stringify({
|
|
1198
|
+
ok: false,
|
|
1199
|
+
error: `Host "${parsedSource.hostname}" is not in the install allowlist. Set CLAUDE_IDE_BRIDGE_INSTALL_ALLOWED_HOSTS to opt in.`,
|
|
1200
|
+
code: "host_not_allowlisted",
|
|
1201
|
+
}));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
// SSRF guard runs AFTER allowlist — defends against operator-misuse
|
|
1205
|
+
// (allowlisting localhost or an internal mirror).
|
|
1206
|
+
const ssrf = await validateSafeUrl(source);
|
|
1207
|
+
if (!ssrf.ok) {
|
|
1208
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1209
|
+
res.end(JSON.stringify({
|
|
1210
|
+
ok: false,
|
|
1211
|
+
error: `Host blocked by SSRF guard: ${ssrf.detail ?? ssrf.reason ?? "unknown"}`,
|
|
1212
|
+
code: "ssrf_blocked",
|
|
1213
|
+
}));
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
fetchUrl = source;
|
|
1217
|
+
const urlParts = fetchUrl.split("/");
|
|
1218
|
+
recipeName = (urlParts[urlParts.length - 1] ?? "recipe").replace(/\.ya?ml$/i, "");
|
|
1219
|
+
}
|
|
1220
|
+
else {
|
|
1221
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1222
|
+
res.end(JSON.stringify({
|
|
1223
|
+
ok: false,
|
|
1224
|
+
error: "Unsupported source format",
|
|
1225
|
+
code: "unsupported_source",
|
|
1226
|
+
}));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
// Bounded fetch — 1 MB hard cap on the response body so a malicious
|
|
1230
|
+
// host can't pin the install request open with a 1 GB stream.
|
|
1231
|
+
const fetchCtl = new AbortController();
|
|
1232
|
+
const fetchTimeout = setTimeout(() => fetchCtl.abort(), 30_000);
|
|
1233
|
+
let yamlRes;
|
|
1234
|
+
try {
|
|
1235
|
+
yamlRes = await fetch(fetchUrl, {
|
|
1236
|
+
signal: fetchCtl.signal,
|
|
1237
|
+
redirect: "follow",
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
catch (err) {
|
|
1241
|
+
clearTimeout(fetchTimeout);
|
|
1242
|
+
// Network-level error → 502 (upstream unreachable), not 500.
|
|
1243
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1244
|
+
res.end(JSON.stringify({
|
|
1245
|
+
ok: false,
|
|
1246
|
+
error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1247
|
+
code: "fetch_network_error",
|
|
1248
|
+
}));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
clearTimeout(fetchTimeout);
|
|
1252
|
+
if (!yamlRes.ok) {
|
|
1253
|
+
// Translate upstream HTTP into proper status — 404→404, 403→403,
|
|
1254
|
+
// 5xx→502 (don't leak the upstream 500 as our 500). R2 H-routes Bug 2.
|
|
1255
|
+
let outStatus = 502;
|
|
1256
|
+
if (yamlRes.status === 404)
|
|
1257
|
+
outStatus = 404;
|
|
1258
|
+
else if (yamlRes.status === 403)
|
|
1259
|
+
outStatus = 403;
|
|
1260
|
+
else if (yamlRes.status >= 400 && yamlRes.status < 500)
|
|
1261
|
+
outStatus = 400;
|
|
1262
|
+
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1263
|
+
res.end(JSON.stringify({
|
|
1264
|
+
ok: false,
|
|
1265
|
+
error: `Upstream returned ${yamlRes.status} ${yamlRes.statusText}`,
|
|
1266
|
+
code: "fetch_upstream_error",
|
|
1267
|
+
upstreamStatus: yamlRes.status,
|
|
1268
|
+
}));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
// Streamed read with 1 MB cap (mirrors `httpClient` pattern).
|
|
1272
|
+
const MAX_RECIPE_BYTES = 1024 * 1024;
|
|
1273
|
+
const reader = yamlRes.body?.getReader();
|
|
1274
|
+
const chunks = [];
|
|
1275
|
+
let totalBytes = 0;
|
|
1276
|
+
let truncated = false;
|
|
1277
|
+
if (reader) {
|
|
1278
|
+
try {
|
|
1279
|
+
while (true) {
|
|
1280
|
+
const { done, value } = await reader.read();
|
|
1281
|
+
if (done || value === undefined)
|
|
1282
|
+
break;
|
|
1283
|
+
if (totalBytes + value.byteLength > MAX_RECIPE_BYTES) {
|
|
1284
|
+
truncated = true;
|
|
1285
|
+
await reader.cancel();
|
|
1286
|
+
break;
|
|
1287
|
+
}
|
|
1288
|
+
chunks.push(value);
|
|
1289
|
+
totalBytes += value.byteLength;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
finally {
|
|
1293
|
+
reader.releaseLock();
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (truncated) {
|
|
1297
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1298
|
+
res.end(JSON.stringify({
|
|
1299
|
+
ok: false,
|
|
1300
|
+
error: `Recipe body exceeded ${MAX_RECIPE_BYTES}-byte limit`,
|
|
1301
|
+
code: "recipe_too_large",
|
|
1302
|
+
}));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const yamlText = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
|
|
1306
|
+
const tmpFile = path.join(os.tmpdir(), `patchwork-install-${Date.now()}-${recipeName}.yaml`);
|
|
1307
|
+
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1308
|
+
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1309
|
+
let result;
|
|
1310
|
+
try {
|
|
1311
|
+
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1312
|
+
mkdirSync(recipesDir, { recursive: true });
|
|
1313
|
+
const { installRecipeFromFile } = await import("./recipes/installer.js");
|
|
1314
|
+
const installResult = installRecipeFromFile(tmpFile, {
|
|
1315
|
+
recipesDir,
|
|
1316
|
+
});
|
|
1317
|
+
result = { action: installResult.action, name: recipeName };
|
|
1318
|
+
}
|
|
1319
|
+
finally {
|
|
1320
|
+
try {
|
|
1321
|
+
unlinkSync(tmpFile);
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
// best-effort cleanup
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1328
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1329
|
+
}
|
|
1330
|
+
catch (err) {
|
|
1331
|
+
// Truly unexpected — installer crash, manifest validation throw, etc.
|
|
1332
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1333
|
+
res.end(JSON.stringify({
|
|
1334
|
+
ok: false,
|
|
1335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1336
|
+
code: "install_internal_error",
|
|
1337
|
+
}));
|
|
1338
|
+
}
|
|
1339
|
+
})();
|
|
1340
|
+
// END A-PR2 EDIT BLOCK
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
//# sourceMappingURL=recipeRoutes.js.map
|