patchwork-os 0.2.0-beta.9.canary.92 → 1.1.0-beta.1
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 +15 -16
- package/README.md +46 -458
- package/dist/activationMetrics.js +2 -3
- package/dist/activationMetrics.js.map +1 -1
- package/dist/activityLog.d.ts +1 -0
- package/dist/activityLog.js +8 -3
- package/dist/activityLog.js.map +1 -1
- package/dist/adapters/gemini.js +2 -2
- package/dist/adapters/gemini.js.map +1 -1
- package/dist/adapters/grok.js +6 -1
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/openai.js +4 -0
- package/dist/adapters/openai.js.map +1 -1
- package/dist/approvalHttp.d.ts +9 -0
- package/dist/approvalHttp.js +210 -171
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalKpi.d.ts +102 -0
- package/dist/approvalKpi.js +282 -0
- package/dist/approvalKpi.js.map +1 -0
- package/dist/approvalQueue.d.ts +1 -1
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.d.ts +45 -4
- package/dist/automation.js +123 -15
- package/dist/automation.js.map +1 -1
- package/dist/bridge.d.ts +10 -0
- package/dist/bridge.js +225 -46
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +16 -1
- package/dist/bridgeLockDiscovery.js +38 -4
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/bridgeToolsRules.js +3 -18
- package/dist/bridgeToolsRules.js.map +1 -1
- package/dist/claudeAuthHttp.d.ts +25 -0
- package/dist/claudeAuthHttp.js +219 -0
- package/dist/claudeAuthHttp.js.map +1 -0
- package/dist/claudeDriver.js +4 -1
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeOrchestrator.d.ts +15 -0
- package/dist/claudeOrchestrator.js +44 -0
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/connect.d.ts +47 -0
- package/dist/commands/connect.js +419 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/install.js +3 -10
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/launchd.d.ts +7 -0
- package/dist/commands/launchd.js +20 -2
- package/dist/commands/launchd.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +7 -0
- package/dist/commands/patchworkInit.js +26 -0
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +59 -4
- package/dist/commands/recipe.js +206 -5
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.d.ts +9 -0
- package/dist/commands/recipeInstall.js +56 -3
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/task.d.ts +25 -0
- package/dist/commands/task.js +61 -41
- package/dist/commands/task.js.map +1 -1
- package/dist/commands/tokenEfficiency.d.ts +4 -0
- package/dist/commands/tokenEfficiency.js +23 -26
- package/dist/commands/tokenEfficiency.js.map +1 -1
- package/dist/commands/tracesExport.d.ts +15 -1
- package/dist/commands/tracesExport.js +39 -5
- package/dist/commands/tracesExport.js.map +1 -1
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +613 -62
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/asana.js +9 -3
- package/dist/connectors/asana.js.map +1 -1
- package/dist/connectors/baseConnector.d.ts +16 -0
- package/dist/connectors/baseConnector.js +2 -0
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/caldiy.js +34 -0
- package/dist/connectors/caldiy.js.map +1 -1
- package/dist/connectors/confluence.js +9 -2
- package/dist/connectors/confluence.js.map +1 -1
- package/dist/connectors/connectorActivity.d.ts +24 -0
- package/dist/connectors/connectorActivity.js +32 -0
- package/dist/connectors/connectorActivity.js.map +1 -0
- package/dist/connectors/connectorRedirectUri.js +22 -2
- package/dist/connectors/connectorRedirectUri.js.map +1 -1
- package/dist/connectors/connectorRegistry.d.ts +0 -3
- package/dist/connectors/connectorRegistry.js +8 -8
- package/dist/connectors/connectorRegistry.js.map +1 -1
- package/dist/connectors/datadog.js +8 -1
- package/dist/connectors/datadog.js.map +1 -1
- package/dist/connectors/discord.js +4 -3
- package/dist/connectors/discord.js.map +1 -1
- package/dist/connectors/elasticsearch.js +22 -0
- package/dist/connectors/elasticsearch.js.map +1 -1
- package/dist/connectors/github.d.ts +38 -0
- package/dist/connectors/github.js +91 -5
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.js +19 -16
- package/dist/connectors/gitlab.js.map +1 -1
- package/dist/connectors/gmail.d.ts +5 -0
- package/dist/connectors/gmail.js +53 -11
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.js +5 -4
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/connectors/googleDocs.js +7 -6
- package/dist/connectors/googleDocs.js.map +1 -1
- package/dist/connectors/googleDrive.js +15 -8
- package/dist/connectors/googleDrive.js.map +1 -1
- package/dist/connectors/grafana.js +39 -3
- package/dist/connectors/grafana.js.map +1 -1
- package/dist/connectors/jira.d.ts +1 -1
- package/dist/connectors/jira.js +19 -4
- package/dist/connectors/jira.js.map +1 -1
- package/dist/connectors/linear.js +2 -2
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpClient.d.ts +29 -1
- package/dist/connectors/mcpClient.js +114 -44
- package/dist/connectors/mcpClient.js.map +1 -1
- package/dist/connectors/mcpOAuth.d.ts +1 -0
- package/dist/connectors/mcpOAuth.js +49 -4
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/monday.js +5 -4
- package/dist/connectors/monday.js.map +1 -1
- package/dist/connectors/mongodb.js +32 -5
- package/dist/connectors/mongodb.js.map +1 -1
- package/dist/connectors/notion.d.ts +1 -1
- package/dist/connectors/notion.js +22 -6
- package/dist/connectors/notion.js.map +1 -1
- package/dist/connectors/oauthError.d.ts +17 -0
- package/dist/connectors/oauthError.js +50 -0
- package/dist/connectors/oauthError.js.map +1 -0
- package/dist/connectors/postgres.d.ts +7 -0
- package/dist/connectors/postgres.js +32 -2
- package/dist/connectors/postgres.js.map +1 -1
- package/dist/connectors/posthog.js +26 -0
- package/dist/connectors/posthog.js.map +1 -1
- package/dist/connectors/redis.d.ts +1 -0
- package/dist/connectors/redis.js +47 -20
- package/dist/connectors/redis.js.map +1 -1
- package/dist/connectors/salesforce.js +66 -5
- package/dist/connectors/salesforce.js.map +1 -1
- package/dist/connectors/sentry.js +2 -2
- package/dist/connectors/sentry.js.map +1 -1
- package/dist/connectors/slack.js +37 -14
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/snowflake.js +24 -0
- package/dist/connectors/snowflake.js.map +1 -1
- package/dist/connectors/stripe.js +9 -2
- package/dist/connectors/stripe.js.map +1 -1
- package/dist/connectors/supabase.js +34 -0
- package/dist/connectors/supabase.js.map +1 -1
- package/dist/connectors/tokenStorage.d.ts +20 -0
- package/dist/connectors/tokenStorage.js +196 -64
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/connectors/woocommerce.js +34 -0
- package/dist/connectors/woocommerce.js.map +1 -1
- package/dist/copilot/parseIntent.d.ts +58 -0
- package/dist/copilot/parseIntent.js +149 -0
- package/dist/copilot/parseIntent.js.map +1 -0
- package/dist/decisionReplay.d.ts +8 -6
- package/dist/decisionReplay.js +35 -11
- package/dist/decisionReplay.js.map +1 -1
- package/dist/decisionTraceLog.d.ts +9 -0
- package/dist/decisionTraceLog.js +6 -0
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/drivers/claude/api.js +49 -7
- package/dist/drivers/claude/api.js.map +1 -1
- package/dist/drivers/claude/envSanitizer.d.ts +9 -1
- package/dist/drivers/claude/envSanitizer.js +41 -3
- package/dist/drivers/claude/envSanitizer.js.map +1 -1
- package/dist/drivers/claude/streamParser.d.ts +13 -0
- package/dist/drivers/claude/streamParser.js.map +1 -1
- package/dist/drivers/claude/subprocess.js +131 -16
- package/dist/drivers/claude/subprocess.js.map +1 -1
- package/dist/drivers/claude/subprocessSettings.d.ts +1 -1
- package/dist/drivers/claude/subprocessSettings.js +18 -4
- package/dist/drivers/claude/subprocessSettings.js.map +1 -1
- package/dist/drivers/gemini/index.d.ts +13 -0
- package/dist/drivers/gemini/index.js +211 -67
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/drivers/local/index.d.ts +2 -17
- package/dist/drivers/local/index.js +5 -92
- package/dist/drivers/local/index.js.map +1 -1
- package/dist/drivers/openai/index.js +69 -9
- package/dist/drivers/openai/index.js.map +1 -1
- package/dist/drivers/outputCap.d.ts +27 -0
- package/dist/drivers/outputCap.js +50 -0
- package/dist/drivers/outputCap.js.map +1 -0
- package/dist/embeddings/cosine.d.ts +23 -0
- package/dist/embeddings/cosine.js +59 -0
- package/dist/embeddings/cosine.js.map +1 -0
- package/dist/embeddings/index.d.ts +19 -0
- package/dist/embeddings/index.js +35 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/localEmbeddings.d.ts +29 -0
- package/dist/embeddings/localEmbeddings.js +103 -0
- package/dist/embeddings/localEmbeddings.js.map +1 -0
- package/dist/embeddings/localEndpoints.d.ts +24 -0
- package/dist/embeddings/localEndpoints.js +37 -0
- package/dist/embeddings/localEndpoints.js.map +1 -0
- package/dist/embeddings/types.d.ts +32 -0
- package/dist/embeddings/types.js +12 -0
- package/dist/embeddings/types.js.map +1 -0
- package/dist/featureFlags.d.ts +18 -0
- package/dist/featureFlags.js +32 -0
- package/dist/featureFlags.js.map +1 -1
- package/dist/fp/automationInterpreter.js +118 -39
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationProgram.d.ts +10 -0
- package/dist/fp/automationProgram.js.map +1 -1
- package/dist/fp/automationState.d.ts +6 -0
- package/dist/fp/automationState.js +38 -0
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/commandDescription.d.ts +6 -0
- package/dist/fp/commandDescription.js +18 -5
- package/dist/fp/commandDescription.js.map +1 -1
- package/dist/fp/interpreterContext.d.ts +16 -5
- package/dist/fp/interpreterContext.js +59 -3
- package/dist/fp/interpreterContext.js.map +1 -1
- package/dist/fp/policyParser.js +18 -8
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/haltPushDispatch.d.ts +4 -0
- package/dist/haltPushDispatch.js +7 -22
- package/dist/haltPushDispatch.js.map +1 -1
- package/dist/inboxRoutes.js +2 -2
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.js +672 -135
- package/dist/index.js.map +1 -1
- package/dist/installGuard.js +6 -0
- package/dist/installGuard.js.map +1 -1
- package/dist/localEndpointGuard.d.ts +25 -0
- package/dist/localEndpointGuard.js +101 -0
- package/dist/localEndpointGuard.js.map +1 -0
- package/dist/mcpRoutes.js +1 -1
- package/dist/mcpRoutes.js.map +1 -1
- package/dist/oauth.d.ts +13 -0
- package/dist/oauth.js +53 -9
- package/dist/oauth.js.map +1 -1
- package/dist/orchestrator/childBridgeClient.d.ts +7 -0
- package/dist/orchestrator/childBridgeClient.js +42 -1
- package/dist/orchestrator/childBridgeClient.js.map +1 -1
- package/dist/orchestrator/orchestratorBridge.d.ts +33 -0
- package/dist/orchestrator/orchestratorBridge.js +75 -9
- package/dist/orchestrator/orchestratorBridge.js.map +1 -1
- package/dist/patchworkConfig.d.ts +4 -0
- package/dist/patchworkConfig.js +33 -4
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/probe.js +39 -5
- package/dist/probe.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +59 -0
- package/dist/recipeOrchestration.js +314 -60
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +93 -0
- package/dist/recipeRoutes.js +664 -27
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/RecipeOrchestrator.d.ts +14 -0
- package/dist/recipes/RecipeOrchestrator.js +42 -2
- package/dist/recipes/RecipeOrchestrator.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +51 -2
- package/dist/recipes/agentExecutor.js +86 -13
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +111 -3
- package/dist/recipes/chainedRunner.js +440 -45
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/compiler.js +42 -29
- package/dist/recipes/compiler.js.map +1 -1
- package/dist/recipes/connectorPreflight.js +31 -0
- package/dist/recipes/connectorPreflight.js.map +1 -1
- package/dist/recipes/dependencyGraph.d.ts +16 -0
- package/dist/recipes/dependencyGraph.js +37 -2
- package/dist/recipes/dependencyGraph.js.map +1 -1
- package/dist/recipes/eventTriggerPrograms.d.ts +36 -0
- package/dist/recipes/eventTriggerPrograms.js +158 -0
- package/dist/recipes/eventTriggerPrograms.js.map +1 -0
- package/dist/recipes/githubInstallSource.d.ts +9 -2
- package/dist/recipes/githubInstallSource.js +36 -6
- package/dist/recipes/githubInstallSource.js.map +1 -1
- package/dist/recipes/haltCategory.d.ts +10 -0
- package/dist/recipes/haltCategory.js +11 -1
- package/dist/recipes/haltCategory.js.map +1 -1
- package/dist/recipes/idempotencyKey.d.ts +30 -3
- package/dist/recipes/idempotencyKey.js +89 -5
- package/dist/recipes/idempotencyKey.js.map +1 -1
- package/dist/recipes/installer.js +12 -35
- package/dist/recipes/installer.js.map +1 -1
- package/dist/recipes/judgeVerdict.d.ts +11 -1
- package/dist/recipes/judgeVerdict.js +47 -7
- package/dist/recipes/judgeVerdict.js.map +1 -1
- package/dist/recipes/names.d.ts +5 -0
- package/dist/recipes/names.js +10 -5
- package/dist/recipes/names.js.map +1 -1
- package/dist/recipes/parser.js +74 -10
- package/dist/recipes/parser.js.map +1 -1
- package/dist/recipes/pricing/costRouter.d.ts +43 -0
- package/dist/recipes/pricing/costRouter.js +44 -0
- package/dist/recipes/pricing/costRouter.js.map +1 -0
- package/dist/recipes/pricing/priceTable.d.ts +76 -0
- package/dist/recipes/pricing/priceTable.js +144 -0
- package/dist/recipes/pricing/priceTable.js.map +1 -0
- package/dist/recipes/replayRun.js +5 -2
- package/dist/recipes/replayRun.js.map +1 -1
- package/dist/recipes/resolveRecipePath.js +15 -1
- package/dist/recipes/resolveRecipePath.js.map +1 -1
- package/dist/recipes/runBudget.d.ts +93 -32
- package/dist/recipes/runBudget.js +213 -49
- package/dist/recipes/runBudget.js.map +1 -1
- package/dist/recipes/runRegistry.d.ts +32 -0
- package/dist/recipes/runRegistry.js +54 -0
- package/dist/recipes/runRegistry.js.map +1 -0
- package/dist/recipes/scheduler.js +6 -2
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +59 -6
- package/dist/recipes/schemaGenerator.d.ts +9 -0
- package/dist/recipes/schemaGenerator.js +446 -77
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/simulation/aggregateRunRisk.d.ts +46 -0
- package/dist/recipes/simulation/aggregateRunRisk.js +120 -0
- package/dist/recipes/simulation/aggregateRunRisk.js.map +1 -0
- package/dist/recipes/simulation/costProjector.d.ts +32 -0
- package/dist/recipes/simulation/costProjector.js +194 -0
- package/dist/recipes/simulation/costProjector.js.map +1 -0
- package/dist/recipes/simulation/sideEffects.d.ts +32 -0
- package/dist/recipes/simulation/sideEffects.js +62 -0
- package/dist/recipes/simulation/sideEffects.js.map +1 -0
- package/dist/recipes/simulation/simulate.d.ts +36 -0
- package/dist/recipes/simulation/simulate.js +264 -0
- package/dist/recipes/simulation/simulate.js.map +1 -0
- package/dist/recipes/simulation/simulateMockedRun.d.ts +52 -0
- package/dist/recipes/simulation/simulateMockedRun.js +84 -0
- package/dist/recipes/simulation/simulateMockedRun.js.map +1 -0
- package/dist/recipes/simulation/synthesizeMockedOutputs.d.ts +31 -0
- package/dist/recipes/simulation/synthesizeMockedOutputs.js +50 -0
- package/dist/recipes/simulation/synthesizeMockedOutputs.js.map +1 -0
- package/dist/recipes/simulation/types.d.ts +198 -0
- package/dist/recipes/simulation/types.js +30 -0
- package/dist/recipes/simulation/types.js.map +1 -0
- package/dist/recipes/stepObservation.d.ts +12 -0
- package/dist/recipes/stepObservation.js +21 -1
- package/dist/recipes/stepObservation.js.map +1 -1
- package/dist/recipes/toolRegistry.js +61 -24
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/airtable.d.ts +15 -0
- package/dist/recipes/tools/airtable.js +240 -0
- package/dist/recipes/tools/airtable.js.map +1 -0
- package/dist/recipes/tools/caldiy.d.ts +13 -0
- package/dist/recipes/tools/caldiy.js +215 -0
- package/dist/recipes/tools/caldiy.js.map +1 -0
- package/dist/recipes/tools/circleci.d.ts +10 -0
- package/dist/recipes/tools/circleci.js +204 -0
- package/dist/recipes/tools/circleci.js.map +1 -0
- package/dist/recipes/tools/cloudflare.d.ts +13 -0
- package/dist/recipes/tools/cloudflare.js +211 -0
- package/dist/recipes/tools/cloudflare.js.map +1 -0
- package/dist/recipes/tools/confluence.js +11 -10
- package/dist/recipes/tools/confluence.js.map +1 -1
- package/dist/recipes/tools/datadog.js +13 -12
- package/dist/recipes/tools/datadog.js.map +1 -1
- package/dist/recipes/tools/docs.d.ts +18 -0
- package/dist/recipes/tools/docs.js +95 -0
- package/dist/recipes/tools/docs.js.map +1 -0
- package/dist/recipes/tools/elasticsearch.d.ts +11 -0
- package/dist/recipes/tools/elasticsearch.js +157 -0
- package/dist/recipes/tools/elasticsearch.js.map +1 -0
- package/dist/recipes/tools/fanOut.js +4 -4
- package/dist/recipes/tools/fanOut.js.map +1 -1
- package/dist/recipes/tools/figma.d.ts +12 -0
- package/dist/recipes/tools/figma.js +195 -0
- package/dist/recipes/tools/figma.js.map +1 -0
- package/dist/recipes/tools/file.js +43 -22
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/github.js +162 -5
- package/dist/recipes/tools/github.js.map +1 -1
- package/dist/recipes/tools/gmail.js +45 -33
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/grafana.d.ts +11 -0
- package/dist/recipes/tools/grafana.js +216 -0
- package/dist/recipes/tools/grafana.js.map +1 -0
- package/dist/recipes/tools/http.d.ts +1 -1
- package/dist/recipes/tools/http.js +10 -28
- package/dist/recipes/tools/http.js.map +1 -1
- package/dist/recipes/tools/hubspot.js +13 -12
- package/dist/recipes/tools/hubspot.js.map +1 -1
- package/dist/recipes/tools/index.d.ts +28 -0
- package/dist/recipes/tools/index.js +28 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/intercom.js +11 -10
- package/dist/recipes/tools/intercom.js.map +1 -1
- package/dist/recipes/tools/jira.js +13 -0
- package/dist/recipes/tools/jira.js.map +1 -1
- package/dist/recipes/tools/meetingNotes.js +2 -2
- package/dist/recipes/tools/monday.d.ts +22 -0
- package/dist/recipes/tools/monday.js +242 -0
- package/dist/recipes/tools/monday.js.map +1 -0
- package/dist/recipes/tools/mongodb.d.ts +19 -0
- package/dist/recipes/tools/mongodb.js +194 -0
- package/dist/recipes/tools/mongodb.js.map +1 -0
- package/dist/recipes/tools/notion.js +11 -10
- package/dist/recipes/tools/notion.js.map +1 -1
- package/dist/recipes/tools/obsidian.d.ts +15 -0
- package/dist/recipes/tools/obsidian.js +173 -0
- package/dist/recipes/tools/obsidian.js.map +1 -0
- package/dist/recipes/tools/outcomes.d.ts +15 -0
- package/dist/recipes/tools/outcomes.js +111 -0
- package/dist/recipes/tools/outcomes.js.map +1 -0
- package/dist/recipes/tools/paystack.d.ts +11 -0
- package/dist/recipes/tools/paystack.js +212 -0
- package/dist/recipes/tools/paystack.js.map +1 -0
- package/dist/recipes/tools/pipedrive.d.ts +16 -0
- package/dist/recipes/tools/pipedrive.js +234 -0
- package/dist/recipes/tools/pipedrive.js.map +1 -0
- package/dist/recipes/tools/postgres.d.ts +15 -0
- package/dist/recipes/tools/postgres.js +186 -0
- package/dist/recipes/tools/postgres.js.map +1 -0
- package/dist/recipes/tools/posthog.d.ts +16 -0
- package/dist/recipes/tools/posthog.js +219 -0
- package/dist/recipes/tools/posthog.js.map +1 -0
- package/dist/recipes/tools/redis.d.ts +9 -0
- package/dist/recipes/tools/redis.js +141 -0
- package/dist/recipes/tools/redis.js.map +1 -0
- package/dist/recipes/tools/resend.d.ts +8 -0
- package/dist/recipes/tools/resend.js +153 -0
- package/dist/recipes/tools/resend.js.map +1 -0
- package/dist/recipes/tools/salesforce.d.ts +16 -0
- package/dist/recipes/tools/salesforce.js +184 -0
- package/dist/recipes/tools/salesforce.js.map +1 -0
- package/dist/recipes/tools/sendgrid.d.ts +9 -0
- package/dist/recipes/tools/sendgrid.js +175 -0
- package/dist/recipes/tools/sendgrid.js.map +1 -0
- package/dist/recipes/tools/shopify.d.ts +16 -0
- package/dist/recipes/tools/shopify.js +266 -0
- package/dist/recipes/tools/shopify.js.map +1 -0
- package/dist/recipes/tools/snowflake.d.ts +16 -0
- package/dist/recipes/tools/snowflake.js +173 -0
- package/dist/recipes/tools/snowflake.js.map +1 -0
- package/dist/recipes/tools/stripe.js +13 -12
- package/dist/recipes/tools/stripe.js.map +1 -1
- package/dist/recipes/tools/supabase.d.ts +9 -0
- package/dist/recipes/tools/supabase.js +133 -0
- package/dist/recipes/tools/supabase.js.map +1 -0
- package/dist/recipes/tools/todoist.d.ts +15 -0
- package/dist/recipes/tools/todoist.js +228 -0
- package/dist/recipes/tools/todoist.js.map +1 -0
- package/dist/recipes/tools/twilio.d.ts +11 -0
- package/dist/recipes/tools/twilio.js +180 -0
- package/dist/recipes/tools/twilio.js.map +1 -0
- package/dist/recipes/tools/vercel.d.ts +9 -0
- package/dist/recipes/tools/vercel.js +146 -0
- package/dist/recipes/tools/vercel.js.map +1 -0
- package/dist/recipes/tools/webflow.d.ts +11 -0
- package/dist/recipes/tools/webflow.js +244 -0
- package/dist/recipes/tools/webflow.js.map +1 -0
- package/dist/recipes/tools/woocommerce.d.ts +18 -0
- package/dist/recipes/tools/woocommerce.js +260 -0
- package/dist/recipes/tools/woocommerce.js.map +1 -0
- package/dist/recipes/tools/wrapConnectorExecute.d.ts +25 -0
- package/dist/recipes/tools/wrapConnectorExecute.js +37 -0
- package/dist/recipes/tools/wrapConnectorExecute.js.map +1 -0
- package/dist/recipes/tools/zendesk.js +11 -10
- package/dist/recipes/tools/zendesk.js.map +1 -1
- package/dist/recipes/triggerVars.d.ts +15 -0
- package/dist/recipes/triggerVars.js +57 -0
- package/dist/recipes/triggerVars.js.map +1 -0
- package/dist/recipes/validation.d.ts +1 -1
- package/dist/recipes/validation.js +536 -12
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlPositions.d.ts +1 -1
- package/dist/recipes/yamlPositions.js +7 -2
- package/dist/recipes/yamlPositions.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +216 -4
- package/dist/recipes/yamlRunner.js +1030 -96
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +5 -0
- package/dist/recipesHttp.js +68 -6
- package/dist/recipesHttp.js.map +1 -1
- package/dist/resources.js +14 -1
- package/dist/resources.js.map +1 -1
- package/dist/riskSignals.d.ts +42 -0
- package/dist/riskSignals.js +197 -0
- package/dist/riskSignals.js.map +1 -0
- package/dist/riskTier.d.ts +9 -0
- package/dist/riskTier.js +48 -0
- package/dist/riskTier.js.map +1 -1
- package/dist/runLog.d.ts +53 -0
- package/dist/runLog.js +75 -16
- package/dist/runLog.js.map +1 -1
- package/dist/schemas/recipe.v1.json +360 -16
- package/dist/server.d.ts +60 -0
- package/dist/server.js +192 -31
- package/dist/server.js.map +1 -1
- package/dist/sessionCheckpoint.js +4 -1
- package/dist/sessionCheckpoint.js.map +1 -1
- package/dist/sessionDetail.d.ts +43 -0
- package/dist/sessionDetail.js +59 -0
- package/dist/sessionDetail.js.map +1 -0
- package/dist/settingsEnv.d.ts +47 -0
- package/dist/settingsEnv.js +88 -0
- package/dist/settingsEnv.js.map +1 -0
- package/dist/streamableHttp.js +44 -10
- package/dist/streamableHttp.js.map +1 -1
- package/dist/ta/backtest/abnday.d.ts +53 -0
- package/dist/ta/backtest/abnday.js +123 -0
- package/dist/ta/backtest/abnday.js.map +1 -0
- package/dist/ta/backtest/directional.d.ts +48 -0
- package/dist/ta/backtest/directional.js +103 -0
- package/dist/ta/backtest/directional.js.map +1 -0
- package/dist/ta/backtest/fundx.d.ts +67 -0
- package/dist/ta/backtest/fundx.js +264 -0
- package/dist/ta/backtest/fundx.js.map +1 -0
- package/dist/ta/backtest/fvg.d.ts +62 -0
- package/dist/ta/backtest/fvg.js +236 -0
- package/dist/ta/backtest/fvg.js.map +1 -0
- package/dist/ta/backtest/ichimoku.d.ts +41 -0
- package/dist/ta/backtest/ichimoku.js +146 -0
- package/dist/ta/backtest/ichimoku.js.map +1 -0
- package/dist/ta/backtest/ingest.d.ts +20 -0
- package/dist/ta/backtest/ingest.js +104 -0
- package/dist/ta/backtest/ingest.js.map +1 -0
- package/dist/ta/backtest/msb.d.ts +49 -0
- package/dist/ta/backtest/msb.js +162 -0
- package/dist/ta/backtest/msb.js.map +1 -0
- package/dist/ta/backtest/scoring.d.ts +33 -0
- package/dist/ta/backtest/scoring.js +82 -0
- package/dist/ta/backtest/scoring.js.map +1 -0
- package/dist/ta/backtest/summary.d.ts +20 -0
- package/dist/ta/backtest/summary.js +42 -0
- package/dist/ta/backtest/summary.js.map +1 -0
- package/dist/ta/backtest/tsmom.d.ts +72 -0
- package/dist/ta/backtest/tsmom.js +202 -0
- package/dist/ta/backtest/tsmom.js.map +1 -0
- package/dist/ta/backtest/volsq.d.ts +34 -0
- package/dist/ta/backtest/volsq.js +154 -0
- package/dist/ta/backtest/volsq.js.map +1 -0
- package/dist/ta/backtest/walkForward.d.ts +45 -0
- package/dist/ta/backtest/walkForward.js +136 -0
- package/dist/ta/backtest/walkForward.js.map +1 -0
- package/dist/ta/cycles.d.ts +15 -0
- package/dist/ta/cycles.js +31 -0
- package/dist/ta/cycles.js.map +1 -0
- package/dist/ta/desk/accrualEmitter.d.ts +59 -0
- package/dist/ta/desk/accrualEmitter.js +136 -0
- package/dist/ta/desk/accrualEmitter.js.map +1 -0
- package/dist/ta/desk/cellBacktest.d.ts +179 -0
- package/dist/ta/desk/cellBacktest.js +514 -0
- package/dist/ta/desk/cellBacktest.js.map +1 -0
- package/dist/ta/desk/cells/wpMaRejection.d.ts +49 -0
- package/dist/ta/desk/cells/wpMaRejection.js +141 -0
- package/dist/ta/desk/cells/wpMaRejection.js.map +1 -0
- package/dist/ta/desk/cells/wpVolumeClimax.d.ts +57 -0
- package/dist/ta/desk/cells/wpVolumeClimax.js +193 -0
- package/dist/ta/desk/cells/wpVolumeClimax.js.map +1 -0
- package/dist/ta/desk/collectors.d.ts +18 -0
- package/dist/ta/desk/collectors.js +450 -0
- package/dist/ta/desk/collectors.js.map +1 -0
- package/dist/ta/desk/contract.d.ts +51 -0
- package/dist/ta/desk/contract.js +261 -0
- package/dist/ta/desk/contract.js.map +1 -0
- package/dist/ta/desk/deskLedger.d.ts +36 -0
- package/dist/ta/desk/deskLedger.js +239 -0
- package/dist/ta/desk/deskLedger.js.map +1 -0
- package/dist/ta/desk/liqTape.d.ts +37 -0
- package/dist/ta/desk/liqTape.js +99 -0
- package/dist/ta/desk/liqTape.js.map +1 -0
- package/dist/ta/desk/post.d.ts +20 -0
- package/dist/ta/desk/post.js +65 -0
- package/dist/ta/desk/post.js.map +1 -0
- package/dist/ta/desk/render.d.ts +25 -0
- package/dist/ta/desk/render.js +398 -0
- package/dist/ta/desk/render.js.map +1 -0
- package/dist/ta/desk/surfaces.d.ts +216 -0
- package/dist/ta/desk/surfaces.js +472 -0
- package/dist/ta/desk/surfaces.js.map +1 -0
- package/dist/ta/desk/types.d.ts +174 -0
- package/dist/ta/desk/types.js +104 -0
- package/dist/ta/desk/types.js.map +1 -0
- package/dist/ta/index.d.ts +23 -0
- package/dist/ta/index.js +54 -0
- package/dist/ta/index.js.map +1 -0
- package/dist/ta/ledger.d.ts +44 -0
- package/dist/ta/ledger.js +95 -0
- package/dist/ta/ledger.js.map +1 -0
- package/dist/ta/levels.d.ts +21 -0
- package/dist/ta/levels.js +41 -0
- package/dist/ta/levels.js.map +1 -0
- package/dist/ta/swings.d.ts +30 -0
- package/dist/ta/swings.js +83 -0
- package/dist/ta/swings.js.map +1 -0
- package/dist/ta/types.d.ts +81 -0
- package/dist/ta/types.js +20 -0
- package/dist/ta/types.js.map +1 -0
- package/dist/telemetry.js +20 -9
- package/dist/telemetry.js.map +1 -1
- package/dist/tokenUsageTracker.d.ts +2 -1
- package/dist/tokenUsageTracker.js +38 -20
- package/dist/tokenUsageTracker.js.map +1 -1
- package/dist/tools/contextBundle.js +13 -9
- package/dist/tools/contextBundle.js.map +1 -1
- package/dist/tools/ctxGetTaskContext.js +8 -3
- package/dist/tools/ctxGetTaskContext.js.map +1 -1
- package/dist/tools/ctxQueryTraces.d.ts +18 -1
- package/dist/tools/ctxQueryTraces.js +176 -27
- package/dist/tools/ctxQueryTraces.js.map +1 -1
- package/dist/tools/enrichCommit.js +5 -2
- package/dist/tools/enrichCommit.js.map +1 -1
- package/dist/tools/getDependencyTree.js +2 -2
- package/dist/tools/getDependencyTree.js.map +1 -1
- package/dist/tools/getDiagnostics.js +3 -0
- package/dist/tools/getDiagnostics.js.map +1 -1
- package/dist/tools/getDocumentSymbols.js +5 -1
- package/dist/tools/getDocumentSymbols.js.map +1 -1
- package/dist/tools/getGitHotspots.js +12 -1
- package/dist/tools/getGitHotspots.js.map +1 -1
- package/dist/tools/getGitLog.js +5 -2
- package/dist/tools/getGitLog.js.map +1 -1
- package/dist/tools/getGitStatus.js +6 -1
- package/dist/tools/getGitStatus.js.map +1 -1
- package/dist/tools/getProjectContext.js +3 -1
- package/dist/tools/getProjectContext.js.map +1 -1
- package/dist/tools/getToolCapabilities.js +39 -4
- package/dist/tools/getToolCapabilities.js.map +1 -1
- package/dist/tools/gitWrite.js +12 -3
- package/dist/tools/gitWrite.js.map +1 -1
- package/dist/tools/github/composite.d.ts +1 -1
- package/dist/tools/github/composite.js +7 -7
- package/dist/tools/github/composite.js.map +1 -1
- package/dist/tools/github/pr.d.ts +6 -6
- package/dist/tools/github/pr.js +18 -11
- package/dist/tools/github/pr.js.map +1 -1
- package/dist/tools/httpClient.js +55 -3
- package/dist/tools/httpClient.js.map +1 -1
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +19 -12
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/lsp.d.ts +6 -0
- package/dist/tools/lsp.js +81 -12
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/previewEdit.js +16 -1
- package/dist/tools/previewEdit.js.map +1 -1
- package/dist/tools/resumeClaudeTask.js +17 -0
- package/dist/tools/resumeClaudeTask.js.map +1 -1
- package/dist/tools/searchAndReplace.js +9 -5
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/searchTools.d.ts +26 -0
- package/dist/tools/searchTools.js +42 -2
- package/dist/tools/searchTools.js.map +1 -1
- package/dist/tools/terminal.js +23 -51
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/transaction.d.ts +9 -1
- package/dist/tools/transaction.js +59 -4
- package/dist/tools/transaction.js.map +1 -1
- package/dist/tools/utils.d.ts +16 -0
- package/dist/tools/utils.js +77 -7
- package/dist/tools/utils.js.map +1 -1
- package/dist/transport.d.ts +31 -0
- package/dist/transport.js +117 -14
- package/dist/transport.js.map +1 -1
- package/dist/wireHaltPushDispatch.js +12 -0
- package/dist/wireHaltPushDispatch.js.map +1 -1
- package/dist/workerGateDecisionLog.d.ts +141 -0
- package/dist/workerGateDecisionLog.js +361 -0
- package/dist/workerGateDecisionLog.js.map +1 -0
- package/dist/workers/actionClass.d.ts +38 -0
- package/dist/workers/actionClass.js +155 -0
- package/dist/workers/actionClass.js.map +1 -0
- package/dist/workers/backtest.d.ts +68 -0
- package/dist/workers/backtest.js +113 -0
- package/dist/workers/backtest.js.map +1 -0
- package/dist/workers/contextRisk.d.ts +35 -0
- package/dist/workers/contextRisk.js +20 -0
- package/dist/workers/contextRisk.js.map +1 -0
- package/dist/workers/contextRiskScorer.d.ts +48 -0
- package/dist/workers/contextRiskScorer.js +94 -0
- package/dist/workers/contextRiskScorer.js.map +1 -0
- package/dist/workers/graduation.d.ts +61 -0
- package/dist/workers/graduation.js +112 -0
- package/dist/workers/graduation.js.map +1 -0
- package/dist/workers/outcomeStore.d.ts +98 -0
- package/dist/workers/outcomeStore.js +146 -0
- package/dist/workers/outcomeStore.js.map +1 -0
- package/dist/workers/outcomesCli.d.ts +34 -0
- package/dist/workers/outcomesCli.js +91 -0
- package/dist/workers/outcomesCli.js.map +1 -0
- package/dist/workers/runWorkerShadow.d.ts +90 -0
- package/dist/workers/runWorkerShadow.js +323 -0
- package/dist/workers/runWorkerShadow.js.map +1 -0
- package/dist/workers/shadowGate.d.ts +30 -0
- package/dist/workers/shadowGate.js +58 -0
- package/dist/workers/shadowGate.js.map +1 -0
- package/dist/workers/shadowObserver.d.ts +165 -0
- package/dist/workers/shadowObserver.js +203 -0
- package/dist/workers/shadowObserver.js.map +1 -0
- package/dist/workers/shadowReport.d.ts +16 -0
- package/dist/workers/shadowReport.js +61 -0
- package/dist/workers/shadowReport.js.map +1 -0
- package/dist/workers/shadowRun.d.ts +38 -0
- package/dist/workers/shadowRun.js +44 -0
- package/dist/workers/shadowRun.js.map +1 -0
- package/dist/workers/trustLevel.d.ts +82 -0
- package/dist/workers/trustLevel.js +100 -0
- package/dist/workers/trustLevel.js.map +1 -0
- package/dist/workers/worker.d.ts +49 -0
- package/dist/workers/worker.js +88 -0
- package/dist/workers/worker.js.map +1 -0
- package/dist/workers/workerGate.d.ts +101 -0
- package/dist/workers/workerGate.js +210 -0
- package/dist/workers/workerGate.js.map +1 -0
- package/dist/workers/workerLevelStore.d.ts +33 -0
- package/dist/workers/workerLevelStore.js +87 -0
- package/dist/workers/workerLevelStore.js.map +1 -0
- package/dist/workers/workerLoader.d.ts +7 -0
- package/dist/workers/workerLoader.js +31 -0
- package/dist/workers/workerLoader.js.map +1 -0
- package/dist/writeFileAtomic.js +40 -13
- package/dist/writeFileAtomic.js.map +1 -1
- package/package.json +14 -6
- package/scripts/mcp-stdio-shim.cjs +247 -87
- package/templates/CLAUDE.bridge.md +1 -1
- package/templates/recipes/dependency-bump.yaml +33 -0
- package/templates/recipes/incident-to-pr.yaml +187 -0
- package/templates/recipes/outcome-ingester.yaml +53 -0
- package/templates/recipes/release-notes.yaml +30 -0
- package/templates/recipes/triage-failing-tests-autofile.yaml +152 -0
- package/templates/recipes/triage-failing-tests.yaml +38 -0
- package/templates/workers/dependency-upkeep.worker.yaml +26 -0
- package/templates/workers/release-notes.worker.yaml +17 -0
- package/templates/workers/test-guardian.worker.yaml +24 -0
- package/deploy/README.md +0 -172
- package/deploy/bootstrap-new-vps.sh +0 -364
- package/deploy/bootstrap-vps.sh +0 -192
- package/deploy/claude-ide-bridge.service.template +0 -67
- package/deploy/claude-ide-bridge@.service +0 -31
- package/deploy/deploy-dashboard.sh +0 -198
- package/deploy/deploy-landing.sh +0 -136
- package/deploy/ecosystem.config.js.example +0 -36
- package/deploy/install-vps-service.sh +0 -240
- package/deploy/macos/README.md +0 -153
- package/deploy/macos/com.patchwork.bridge.plist.template +0 -54
- package/deploy/macos/com.patchwork.tunnel.plist.template +0 -76
- package/deploy/macos/install-mac-bridge.sh +0 -244
- package/deploy/macos/uninstall-mac-bridge.sh +0 -22
- package/deploy/nginx-claude-bridge.conf.template +0 -129
package/dist/recipeRoutes.js
CHANGED
|
@@ -29,11 +29,32 @@
|
|
|
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 { getApprovalQueue } from "./approvalQueue.js";
|
|
33
|
+
import { buildCopilotReply, parseCopilotIntent, } from "./copilot/parseIntent.js";
|
|
32
34
|
import { isWriteKillSwitchActive } from "./featureFlags.js";
|
|
33
35
|
import { consumeToken, refillBucket, } from "./fp/tokenBucket.js";
|
|
34
36
|
import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
|
|
35
37
|
import { respond500 } from "./httpErrorResponse.js";
|
|
38
|
+
import { cancelRun } from "./recipes/runRegistry.js";
|
|
39
|
+
import { invalidateRecipesCache } from "./recipesHttp.js";
|
|
36
40
|
import { validateSafeUrl } from "./ssrfGuard.js";
|
|
41
|
+
/**
|
|
42
|
+
* Sanitize error messages from storage operations before including them in
|
|
43
|
+
* HTTP responses. Node.js filesystem errors (ENOENT, EACCES, etc.) often
|
|
44
|
+
* contain absolute paths (e.g. "ENOENT: no such file or directory, open
|
|
45
|
+
* '/home/user/.patchwork/recipes/foo.yaml'"). These must not be returned
|
|
46
|
+
* verbatim to clients. Returns "Storage error" for any message that looks
|
|
47
|
+
* like a filesystem error (identified by Node.js errno code prefix);
|
|
48
|
+
* otherwise returns the message unchanged.
|
|
49
|
+
*/
|
|
50
|
+
function sanitizeStorageError(msg) {
|
|
51
|
+
// Node.js filesystem errors always start with an errno code such as
|
|
52
|
+
// "ENOENT:", "EACCES:", "EPERM:", "EBUSY:", "EISDIR:", etc.
|
|
53
|
+
if (/^E[A-Z]+:/.test(msg)) {
|
|
54
|
+
return "Storage error";
|
|
55
|
+
}
|
|
56
|
+
return msg;
|
|
57
|
+
}
|
|
37
58
|
// 5-minute cache of the public template registry from the patchworkos/recipes
|
|
38
59
|
// GitHub repo. Process-wide; hoisted out of Server class state.
|
|
39
60
|
// Sentinel `false` marks a negative-cache entry (fetch failed) — distinct from
|
|
@@ -41,20 +62,86 @@ import { validateSafeUrl } from "./ssrfGuard.js";
|
|
|
41
62
|
let templatesCache = null;
|
|
42
63
|
let templatesCacheTs = 0;
|
|
43
64
|
/**
|
|
44
|
-
*
|
|
65
|
+
* Canonical trust enums the dashboard understands. Anything outside these
|
|
66
|
+
* sets must NOT survive into the cached registry — an out-of-enum
|
|
67
|
+
* `risk_level` evaluates falsy downstream (silently reading as "safe") and
|
|
68
|
+
* `RISK_PILL_CLASS[risk_level]` resolves to `undefined`.
|
|
69
|
+
*/
|
|
70
|
+
const TEMPLATE_RISK_LEVELS = new Set(["low", "medium", "high"]);
|
|
71
|
+
const TEMPLATE_APPROVAL_BEHAVIORS = new Set([
|
|
72
|
+
"always_ask",
|
|
73
|
+
"ask_on_novel",
|
|
74
|
+
"auto_approve",
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* #605 / B3-A: shape-validate AND sanitize the upstream templates payload
|
|
78
|
+
* before caching.
|
|
79
|
+
*
|
|
45
80
|
* The minimal contract used by the dashboard marketplace is `{recipes:
|
|
46
|
-
* Array}` (other fields are optional).
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* dashboard client.
|
|
81
|
+
* Array}` (other fields are optional). A bare array is accepted too (legacy).
|
|
82
|
+
* Anything that fails that contract (an error page JSON, a tampered file with
|
|
83
|
+
* a flipped top-level key, a future GitHub schema change) is rejected so we
|
|
84
|
+
* don't serve garbage for 5 minutes to every dashboard client.
|
|
85
|
+
*
|
|
86
|
+
* B3-A SECURITY change: rather than reject the WHOLE payload when a single
|
|
87
|
+
* entry carries a malformed trust field, we SANITIZE each entry IN PLACE so
|
|
88
|
+
* one bad entry can't poison the registry for every client. For each entry:
|
|
89
|
+
*
|
|
90
|
+
* - `risk_level` not in {low,medium,high} → stripped (set undefined)
|
|
91
|
+
* - `approval_behavior` not in the canonical set → stripped (set undefined)
|
|
92
|
+
* - `downloads` not a finite number → coerced if it parses to a
|
|
93
|
+
* finite number, else dropped
|
|
94
|
+
*
|
|
95
|
+
* Conservative by design: stripping an out-of-enum `risk_level` means the
|
|
96
|
+
* dashboard treats trust as UNKNOWN (which the default-deny gate renders as
|
|
97
|
+
* elevated), never silently as "low"/"safe".
|
|
50
98
|
*/
|
|
51
|
-
function isWellFormedTemplatesPayload(raw) {
|
|
99
|
+
export function isWellFormedTemplatesPayload(raw) {
|
|
52
100
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
53
|
-
// Some legacy callers used a bare array; accept that too.
|
|
54
|
-
|
|
101
|
+
// Some legacy callers used a bare array; accept (and sanitize) that too.
|
|
102
|
+
if (Array.isArray(raw)) {
|
|
103
|
+
for (const entry of raw)
|
|
104
|
+
sanitizeTemplateEntry(entry);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
55
108
|
}
|
|
56
109
|
const r = raw;
|
|
57
|
-
|
|
110
|
+
if (!Array.isArray(r.recipes))
|
|
111
|
+
return false;
|
|
112
|
+
for (const entry of r.recipes)
|
|
113
|
+
sanitizeTemplateEntry(entry);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Strip/coerce untrusted trust fields on a single registry entry, mutating it
|
|
118
|
+
* in place. Non-object entries are left untouched (the dashboard tolerates
|
|
119
|
+
* them; they simply won't render trust pills). Kept conservative: we only
|
|
120
|
+
* touch the three fields with a downstream safety impact.
|
|
121
|
+
*/
|
|
122
|
+
function sanitizeTemplateEntry(entry) {
|
|
123
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const e = entry;
|
|
127
|
+
// risk_level: must be one of the canonical levels, else undefined.
|
|
128
|
+
if ("risk_level" in e && !TEMPLATE_RISK_LEVELS.has(e.risk_level)) {
|
|
129
|
+
e.risk_level = undefined;
|
|
130
|
+
}
|
|
131
|
+
// approval_behavior: must be one of the canonical behaviors, else undefined.
|
|
132
|
+
if ("approval_behavior" in e &&
|
|
133
|
+
!TEMPLATE_APPROVAL_BEHAVIORS.has(e.approval_behavior)) {
|
|
134
|
+
e.approval_behavior = undefined;
|
|
135
|
+
}
|
|
136
|
+
// downloads: must be a finite number. Coerce a numeric string; otherwise
|
|
137
|
+
// drop. A non-numeric value (e.g. "abc") must never pass through as-is.
|
|
138
|
+
if ("downloads" in e && typeof e.downloads !== "number") {
|
|
139
|
+
const n = Number(e.downloads);
|
|
140
|
+
e.downloads = Number.isFinite(n) ? n : undefined;
|
|
141
|
+
}
|
|
142
|
+
else if (typeof e.downloads === "number" && !Number.isFinite(e.downloads)) {
|
|
143
|
+
e.downloads = undefined;
|
|
144
|
+
}
|
|
58
145
|
}
|
|
59
146
|
/**
|
|
60
147
|
* Per-process token bucket guarding `/recipes/generate`. Every call to the
|
|
@@ -117,7 +204,7 @@ export function _resetRepairRateLimitForTests() {
|
|
|
117
204
|
// per R3 amendment 4 / I-3, prevents JSON.stringify smuggling
|
|
118
205
|
// a `..` segment into a coerced value at render time).
|
|
119
206
|
const VARS_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
120
|
-
const VARS_VALUE_RE = /^[\w\-. :+@,]
|
|
207
|
+
const VARS_VALUE_RE = /^(?!.*\.\.)([\w\-. :+@,]+)$/u;
|
|
121
208
|
/** Validate the HTTP-supplied `vars` object. Returns null on success. */
|
|
122
209
|
export function validateRecipeVars(vars) {
|
|
123
210
|
if (vars == null)
|
|
@@ -188,6 +275,28 @@ export const RECIPE_ROUTE_BODY_CAPS = {
|
|
|
188
275
|
* (256 KB matches the existing PUT/POST/lint caps).
|
|
189
276
|
*/
|
|
190
277
|
repair: 256 * 1024,
|
|
278
|
+
/**
|
|
279
|
+
* PATCH /recipes/:name — only `{ "enabled": true/false }` (≈ 15 bytes).
|
|
280
|
+
* 1 KB is 66× that maximum; 256 KB was a trivial DoS vector.
|
|
281
|
+
*/
|
|
282
|
+
toggle: 1 * 1024,
|
|
283
|
+
/**
|
|
284
|
+
* POST /outcomes — `{ issueUrl, disposition, recipeName?, workerClass? }`.
|
|
285
|
+
* A URL + short enum + two audit strings; 2 KB is generous.
|
|
286
|
+
*/
|
|
287
|
+
outcomes: 2 * 1024,
|
|
288
|
+
/**
|
|
289
|
+
* POST /traces/decision — `{ ref, problem, solution, workspace?, tags?,
|
|
290
|
+
* sessionId?, source? }`. problem/solution are each capped at 500 chars
|
|
291
|
+
* by DecisionTraceLog.record(); 8 KB covers that plus tags/metadata with
|
|
292
|
+
* headroom, matching the shape of the ctxSaveTrace MCP tool's inputs.
|
|
293
|
+
*/
|
|
294
|
+
decisionTrace: 8 * 1024,
|
|
295
|
+
/**
|
|
296
|
+
* POST /copilot/message — `{ text: string }`, a single chat-input line.
|
|
297
|
+
* 4 KB is generous for free text while blocking a pathological paste.
|
|
298
|
+
*/
|
|
299
|
+
copilotMessage: 4 * 1024,
|
|
191
300
|
};
|
|
192
301
|
/**
|
|
193
302
|
* Read an HTTP request body up to `max` bytes. Returns the raw string or
|
|
@@ -212,11 +321,14 @@ export function readBodyWithCap(req, max) {
|
|
|
212
321
|
total += chunk.byteLength;
|
|
213
322
|
if (total > max) {
|
|
214
323
|
aborted = true;
|
|
324
|
+
// Remove the data listener immediately so no further chunks accumulate
|
|
325
|
+
// in `chunks` after the promise resolves. The `aborted` guard already
|
|
326
|
+
// prevents adding to `chunks`, but leaving the listener attached causes
|
|
327
|
+
// unnecessary listener accumulation when the same socket is reused.
|
|
328
|
+
req.removeListener("data", onData);
|
|
215
329
|
// Resolve immediately so the route can write 413; do NOT destroy the
|
|
216
330
|
// socket here — destroying mid-upload races with the response write
|
|
217
331
|
// and the client sees EPIPE/ECONNRESET before reading the body.
|
|
218
|
-
// Subsequent chunks land in `onData` again but the `aborted` guard
|
|
219
|
-
// discards them, draining the upload until the client emits `end`.
|
|
220
332
|
resolve({ ok: false, code: "too_large" });
|
|
221
333
|
// Force the underlying stream to keep flowing so buffered upload
|
|
222
334
|
// data drains naturally. Without this Node may pause the stream
|
|
@@ -234,6 +346,7 @@ export function readBodyWithCap(req, max) {
|
|
|
234
346
|
const onEnd = () => {
|
|
235
347
|
if (aborted)
|
|
236
348
|
return;
|
|
349
|
+
req.removeListener("data", onData);
|
|
237
350
|
const bytes = Buffer.concat(chunks);
|
|
238
351
|
// `bytes` is the raw on-the-wire body; `body` is the utf-8 decode used
|
|
239
352
|
// by JSON parsers. HMAC consumers must use `bytes` to avoid the
|
|
@@ -244,6 +357,7 @@ export function readBodyWithCap(req, max) {
|
|
|
244
357
|
if (aborted)
|
|
245
358
|
return;
|
|
246
359
|
aborted = true;
|
|
360
|
+
req.removeListener("data", onData);
|
|
247
361
|
// Treat aborted/error mid-stream as a malformed read. Callers that
|
|
248
362
|
// care about the distinction can check the `code` on the
|
|
249
363
|
// readJsonBody result; here we collapse to "too_large" to keep
|
|
@@ -304,6 +418,73 @@ function respondInvalidJson(res) {
|
|
|
304
418
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
305
419
|
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
306
420
|
}
|
|
421
|
+
// END A-PR2 EDIT BLOCK
|
|
422
|
+
// ---------------------------------------------------------------------
|
|
423
|
+
// B3-A — post-redirect SSRF re-check for the non-github HTTPS install path.
|
|
424
|
+
//
|
|
425
|
+
// `validateSafeUrl` only validates the FIRST hop and explicitly does NOT pin
|
|
426
|
+
// the resolved IP across redirects (see src/ssrfGuard.ts header comment). A
|
|
427
|
+
// bare `fetch(url, { redirect: "follow" })` therefore lets a 302 → private
|
|
428
|
+
// IP / IMDS slip past the upfront guard. This helper replays the manual
|
|
429
|
+
// redirect-loop pattern from the CLI `httpsGet` in
|
|
430
|
+
// src/commands/recipeInstall.ts: every hop's host is re-validated with the
|
|
431
|
+
// canonical async guard (`validateSafeUrl`, which re-resolves DNS), the chain
|
|
432
|
+
// is capped, and relative `Location` values are resolved against the current
|
|
433
|
+
// URL before re-validation.
|
|
434
|
+
// ---------------------------------------------------------------------
|
|
435
|
+
const INSTALL_FETCH_MAX_REDIRECTS = 5;
|
|
436
|
+
/**
|
|
437
|
+
* Fetch `startUrl` following HTTP redirects manually, re-validating every hop
|
|
438
|
+
* against the SSRF guard. Throws on a blocked host (private/loopback),
|
|
439
|
+
* unsupported protocol, malformed `Location`, or exceeding the hop cap.
|
|
440
|
+
* Returns the terminal (non-redirect) `Response`.
|
|
441
|
+
*
|
|
442
|
+
* `fetchImpl` is injectable for tests; defaults to the global `fetch`.
|
|
443
|
+
* `signal` is forwarded to every hop so the install timeout still applies.
|
|
444
|
+
*/
|
|
445
|
+
export async function fetchFollowingRedirectsSafely(startUrl, opts = {}) {
|
|
446
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
447
|
+
let currentUrl = startUrl;
|
|
448
|
+
for (let hop = 0; hop <= INSTALL_FETCH_MAX_REDIRECTS; hop++) {
|
|
449
|
+
// Re-validate the host of THIS hop before issuing the request. The first
|
|
450
|
+
// hop was already validated by the route, but re-checking is cheap and
|
|
451
|
+
// keeps this helper self-contained / correct in isolation.
|
|
452
|
+
const ssrf = await validateSafeUrl(currentUrl);
|
|
453
|
+
if (!ssrf.ok) {
|
|
454
|
+
throw new Error(`SSRF guard blocked redirect target: ${ssrf.detail ?? ssrf.reason ?? "unknown"}`);
|
|
455
|
+
}
|
|
456
|
+
const res = await doFetch(currentUrl, {
|
|
457
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
458
|
+
redirect: "manual",
|
|
459
|
+
});
|
|
460
|
+
const isRedirect = res.status >= 300 && res.status < 400;
|
|
461
|
+
// Defensive: a non-redirect response with no `Location` is terminal even
|
|
462
|
+
// if a stub omits the `headers` object entirely.
|
|
463
|
+
const location = typeof res.headers?.get === "function"
|
|
464
|
+
? res.headers.get("location")
|
|
465
|
+
: null;
|
|
466
|
+
if (!isRedirect || !location) {
|
|
467
|
+
// Terminal response — host already validated above.
|
|
468
|
+
return res;
|
|
469
|
+
}
|
|
470
|
+
if (hop >= INSTALL_FETCH_MAX_REDIRECTS) {
|
|
471
|
+
throw new Error(`Too many redirects (>${INSTALL_FETCH_MAX_REDIRECTS}) installing recipe`);
|
|
472
|
+
}
|
|
473
|
+
// Resolve relative redirects against the current URL so a relative
|
|
474
|
+
// `Location: /foo` isn't treated as an empty hostname.
|
|
475
|
+
let nextUrl;
|
|
476
|
+
try {
|
|
477
|
+
nextUrl = new URL(location, currentUrl);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw new Error(`Invalid redirect location: "${location}"`);
|
|
481
|
+
}
|
|
482
|
+
currentUrl = nextUrl.toString();
|
|
483
|
+
// Loop re-validates currentUrl at the top before fetching it.
|
|
484
|
+
}
|
|
485
|
+
// Unreachable: the loop either returns a terminal response or throws.
|
|
486
|
+
throw new Error("Too many redirects installing recipe");
|
|
487
|
+
}
|
|
307
488
|
/**
|
|
308
489
|
* Best-effort fire of the recipe-changed notification. Wraps the
|
|
309
490
|
* callback in try/catch + console.error so a misbehaving notifier
|
|
@@ -313,6 +494,7 @@ function respondInvalidJson(res) {
|
|
|
313
494
|
* routes after their respective success paths.
|
|
314
495
|
*/
|
|
315
496
|
function fireOnRecipesChanged(deps) {
|
|
497
|
+
invalidateRecipesCache();
|
|
316
498
|
if (!deps.onRecipesChangedFn)
|
|
317
499
|
return;
|
|
318
500
|
try {
|
|
@@ -521,6 +703,270 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
521
703
|
}
|
|
522
704
|
return true;
|
|
523
705
|
}
|
|
706
|
+
// GET /workers/shadow — read-only worker trust dial + ramp-vs-gate report.
|
|
707
|
+
// Replays the run log + gate decision log through the (worker × action-class)
|
|
708
|
+
// ramp; changes nothing. Backs the dashboard /workers page.
|
|
709
|
+
if (parsedUrl.pathname === "/workers/shadow" && req.method === "GET") {
|
|
710
|
+
void (async () => {
|
|
711
|
+
try {
|
|
712
|
+
const data = (await deps.workerShadowFn?.()) ?? {
|
|
713
|
+
workers: [],
|
|
714
|
+
runsScanned: 0,
|
|
715
|
+
decisionsScanned: 0,
|
|
716
|
+
};
|
|
717
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
718
|
+
res.end(JSON.stringify({ ...data, generatedAt: new Date().toISOString() }));
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
respond500(res, err);
|
|
722
|
+
}
|
|
723
|
+
})();
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
// GET /gate/decisions — read-only query over the persisted Decision Record
|
|
727
|
+
// (worker_gate_decisions.jsonl). Backs `patchwork gate explain`. Requires
|
|
728
|
+
// BOTH workerId and classKey (there is no useful "explain everything"
|
|
729
|
+
// default; this is the one-decision/one-history lookup, not a dashboard
|
|
730
|
+
// feed like /workers/shadow).
|
|
731
|
+
if (parsedUrl.pathname === "/gate/decisions" && req.method === "GET") {
|
|
732
|
+
try {
|
|
733
|
+
const sp = parsedUrl.searchParams;
|
|
734
|
+
const workerId = sp.get("workerId") ?? undefined;
|
|
735
|
+
const classKey = sp.get("classKey") ?? undefined;
|
|
736
|
+
const limitRaw = sp.get("limit");
|
|
737
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
|
|
738
|
+
const decisions = deps.gateDecisionsFn?.({
|
|
739
|
+
...(workerId && { workerId }),
|
|
740
|
+
...(classKey && { classKey }),
|
|
741
|
+
...(Number.isFinite(limit) && { limit }),
|
|
742
|
+
}) ?? [];
|
|
743
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
744
|
+
res.end(JSON.stringify({ decisions }));
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
respond500(res, err);
|
|
748
|
+
}
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
// POST /traces/decision — record a Decision Record trace over HTTP. The
|
|
752
|
+
// HTTP twin of the `ctxSaveTrace` MCP tool: same `DecisionTraceLog.record()`
|
|
753
|
+
// writer, same JSONL file, same validation (ref/problem/solution required,
|
|
754
|
+
// 500-char caps). Exists so future non-MCP callers (dashboard copilot, CI,
|
|
755
|
+
// scripts) can write decision traces without an MCP client. Bearer-gated —
|
|
756
|
+
// all recipe routes run after the auth gate in server.ts.
|
|
757
|
+
if (parsedUrl.pathname === "/traces/decision" && req.method === "POST") {
|
|
758
|
+
void (async () => {
|
|
759
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.decisionTrace);
|
|
760
|
+
if (!parsedBody.ok) {
|
|
761
|
+
if (parsedBody.code === "too_large") {
|
|
762
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.decisionTrace);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
respondInvalidJson(res);
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const respond400 = (error) => {
|
|
770
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
771
|
+
res.end(JSON.stringify({ ok: false, error }));
|
|
772
|
+
};
|
|
773
|
+
try {
|
|
774
|
+
const parsed = parsedBody.value ?? {};
|
|
775
|
+
if (respondIfUnknownBodyKeys(res, parsed, [
|
|
776
|
+
"ref",
|
|
777
|
+
"problem",
|
|
778
|
+
"solution",
|
|
779
|
+
"workspace",
|
|
780
|
+
"tags",
|
|
781
|
+
"sessionId",
|
|
782
|
+
"source",
|
|
783
|
+
]))
|
|
784
|
+
return;
|
|
785
|
+
const { ref, problem, solution, workspace, tags, sessionId, source } = parsed;
|
|
786
|
+
if (typeof ref !== "string" || !ref.trim()) {
|
|
787
|
+
respond400("`ref` is required (issue ref, PR ref, commit SHA, or free text).");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (typeof problem !== "string" || !problem.trim()) {
|
|
791
|
+
respond400("`problem` is required.");
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (typeof solution !== "string" || !solution.trim()) {
|
|
795
|
+
respond400("`solution` is required.");
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (workspace !== undefined && typeof workspace !== "string") {
|
|
799
|
+
respond400("`workspace` must be a string when present.");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (tags !== undefined &&
|
|
803
|
+
(!Array.isArray(tags) || !tags.every((t) => typeof t === "string"))) {
|
|
804
|
+
respond400("`tags` must be an array of strings when present.");
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (sessionId !== undefined && typeof sessionId !== "string") {
|
|
808
|
+
respond400("`sessionId` must be a string when present.");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (source !== undefined && typeof source !== "string") {
|
|
812
|
+
respond400("`source` must be a string when present.");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!deps.saveDecisionTraceFn) {
|
|
816
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
817
|
+
res.end(JSON.stringify({
|
|
818
|
+
ok: false,
|
|
819
|
+
error: "Decision Record unavailable on this bridge.",
|
|
820
|
+
}));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const trace = deps.saveDecisionTraceFn({
|
|
824
|
+
ref,
|
|
825
|
+
problem,
|
|
826
|
+
solution,
|
|
827
|
+
...(workspace && { workspace }),
|
|
828
|
+
...(tags && { tags }),
|
|
829
|
+
...(sessionId && { sessionId }),
|
|
830
|
+
...(source && { source }),
|
|
831
|
+
});
|
|
832
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
833
|
+
res.end(JSON.stringify({ ok: true, trace }));
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
respond400(err instanceof Error ? err.message : "Invalid decision trace input.");
|
|
837
|
+
}
|
|
838
|
+
})();
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
// GET /outcomes — read-only list of operator outcome dispositions
|
|
842
|
+
// (~/.patchwork/outcome-log.jsonl, deduped last-writer-wins). Backs the
|
|
843
|
+
// dashboard "Filed outcomes" card + mirrors `patchwork outcomes list`.
|
|
844
|
+
// Bearer-gated (all recipe routes run after the auth gate in server.ts).
|
|
845
|
+
if (parsedUrl.pathname === "/outcomes" && req.method === "GET") {
|
|
846
|
+
try {
|
|
847
|
+
const outcomes = deps.outcomeStoreFn?.().readAll() ?? [];
|
|
848
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
849
|
+
res.end(JSON.stringify({ outcomes }));
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
respond500(res, err);
|
|
853
|
+
}
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
// GET /outcomes/pending — the confirm queue: worker filings awaiting an
|
|
857
|
+
// operator disposition. Read-only join over the run log + dispositions;
|
|
858
|
+
// backs the dashboard "awaiting confirmation" badge + mirrors
|
|
859
|
+
// `patchwork outcomes pending`. (Exact-path match, so it never shadows the
|
|
860
|
+
// bare /outcomes routes above.)
|
|
861
|
+
if (parsedUrl.pathname === "/outcomes/pending" && req.method === "GET") {
|
|
862
|
+
try {
|
|
863
|
+
const pending = deps.pendingConfirmationsFn?.() ?? [];
|
|
864
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
865
|
+
res.end(JSON.stringify({ pending }));
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
respond500(res, err);
|
|
869
|
+
}
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
// POST /outcomes — operator positive-act: record a `confirmed`|`junk`
|
|
873
|
+
// disposition for a worker-filed issue/PR URL. The HTTP twin of `patchwork
|
|
874
|
+
// outcomes confirm|reject` (src/workers/outcomesCli.ts) and the only write
|
|
875
|
+
// path the dashboard uses. `unknown` is ingester-only and rejected here.
|
|
876
|
+
// Same URL validation as the CLI. SELF-CONFIRM PROHIBITION: exposed over
|
|
877
|
+
// Bearer-gated HTTP + CLI only — never as a recipe step or MCP tool — so a
|
|
878
|
+
// worker cannot confirm its own filing. Append-only supersede makes a
|
|
879
|
+
// mis-click recoverable by a corrected write.
|
|
880
|
+
if (parsedUrl.pathname === "/outcomes" && req.method === "POST") {
|
|
881
|
+
void (async () => {
|
|
882
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.outcomes);
|
|
883
|
+
if (!parsedBody.ok) {
|
|
884
|
+
if (parsedBody.code === "too_large") {
|
|
885
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.outcomes);
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
respondInvalidJson(res);
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const parsed = parsedBody.value ?? {};
|
|
894
|
+
if (respondIfUnknownBodyKeys(res, parsed, [
|
|
895
|
+
"issueUrl",
|
|
896
|
+
"disposition",
|
|
897
|
+
"recipeName",
|
|
898
|
+
"workerClass",
|
|
899
|
+
]))
|
|
900
|
+
return;
|
|
901
|
+
const respond400 = (error) => {
|
|
902
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
903
|
+
res.end(JSON.stringify({ ok: false, error }));
|
|
904
|
+
};
|
|
905
|
+
const { issueUrl, disposition, recipeName, workerClass } = parsed;
|
|
906
|
+
if (typeof issueUrl !== "string" || !/^https?:\/\//.test(issueUrl)) {
|
|
907
|
+
respond400("`issueUrl` must be an http(s) URL (the filed issue/PR URL).");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// `unknown` is ingester-only — an operator either confirms or rejects.
|
|
911
|
+
if (disposition !== "confirmed" && disposition !== "junk") {
|
|
912
|
+
respond400('`disposition` must be "confirmed" or "junk" ("unknown" is ingester-only).');
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (recipeName !== undefined && typeof recipeName !== "string") {
|
|
916
|
+
respond400("`recipeName` must be a string when present.");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (workerClass !== undefined && typeof workerClass !== "string") {
|
|
920
|
+
respond400("`workerClass` must be a string when present.");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const store = deps.outcomeStoreFn?.();
|
|
924
|
+
if (!store) {
|
|
925
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
926
|
+
res.end(JSON.stringify({
|
|
927
|
+
ok: false,
|
|
928
|
+
error: "Outcome store unavailable on this bridge.",
|
|
929
|
+
}));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
store.upsert({
|
|
933
|
+
issueUrl,
|
|
934
|
+
disposition,
|
|
935
|
+
checkedAt: Date.now(),
|
|
936
|
+
...(recipeName ? { recipeName } : {}),
|
|
937
|
+
...(workerClass ? { workerClass } : {}),
|
|
938
|
+
});
|
|
939
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
940
|
+
res.end(JSON.stringify({ ok: true, issueUrl, disposition }));
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
respond500(res, err);
|
|
944
|
+
}
|
|
945
|
+
})();
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
// GET /approvals/kpi — considered-approval KPI (reject rate, latency-to-
|
|
949
|
+
// decision, abandoned, dashboard-vs-phone). Read-only over the persisted
|
|
950
|
+
// approval-decision log; the sibling of /workers/shadow that answers "was the
|
|
951
|
+
// trust climbing the dial EARNED or rubber-stamped". Backs the dashboard
|
|
952
|
+
// approval panel. Optional ?sinceMs=<epoch-ms> windows it. Fail-soft.
|
|
953
|
+
if (parsedUrl.pathname === "/approvals/kpi" && req.method === "GET") {
|
|
954
|
+
void (async () => {
|
|
955
|
+
try {
|
|
956
|
+
const sinceRaw = parsedUrl.searchParams.get("sinceMs");
|
|
957
|
+
const sinceMs = sinceRaw ? Number.parseInt(sinceRaw, 10) : Number.NaN;
|
|
958
|
+
const { readConsideredDecisions, computeConsideredApprovalKpi } = await import("./approvalKpi.js");
|
|
959
|
+
const decisions = readConsideredDecisions(Number.isFinite(sinceMs) ? { sinceMs } : {});
|
|
960
|
+
const kpi = computeConsideredApprovalKpi(decisions);
|
|
961
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
962
|
+
res.end(JSON.stringify({ ...kpi, generatedAt: new Date().toISOString() }));
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
respond500(res, err);
|
|
966
|
+
}
|
|
967
|
+
})();
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
524
970
|
// GET /recipes/doctor?recipe=<name> — one-call recipe health diagnosis.
|
|
525
971
|
// Server-side home for the `recipe doctor` CLI: composes the static
|
|
526
972
|
// preflight check (lint + write-policy + plan) with the recipe-scoped
|
|
@@ -639,6 +1085,28 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
639
1085
|
})();
|
|
640
1086
|
return true;
|
|
641
1087
|
}
|
|
1088
|
+
// POST /runs/:seq/cancel — cancel an in-flight recipe run. Aborts the run's
|
|
1089
|
+
// registered AbortController so not-yet-started steps are skipped (and agent
|
|
1090
|
+
// steps that honor the signal abort). 200 {cancelled:true} when a live run
|
|
1091
|
+
// was found, 404 {cancelled:false} when the seq isn't currently running
|
|
1092
|
+
// (already finished, unknown, or ran on a different process). (run-cancel)
|
|
1093
|
+
const runCancelMatch = req.method === "POST"
|
|
1094
|
+
? /^\/runs\/(\d+)\/cancel$/.exec(parsedUrl.pathname)
|
|
1095
|
+
: null;
|
|
1096
|
+
if (runCancelMatch?.[1]) {
|
|
1097
|
+
const seq = Number.parseInt(runCancelMatch[1], 10);
|
|
1098
|
+
try {
|
|
1099
|
+
const cancelled = cancelRun(seq);
|
|
1100
|
+
res.writeHead(cancelled ? 200 : 404, {
|
|
1101
|
+
"Content-Type": "application/json",
|
|
1102
|
+
});
|
|
1103
|
+
res.end(JSON.stringify({ cancelled, seq }));
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
respond500(res, err, "runs/:seq/cancel");
|
|
1107
|
+
}
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
642
1110
|
// GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
|
|
643
1111
|
const runPlanMatch = req.method === "GET"
|
|
644
1112
|
? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
|
|
@@ -658,6 +1126,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
658
1126
|
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
659
1127
|
return;
|
|
660
1128
|
}
|
|
1129
|
+
// Guard: recipeName may be absent on runs created before the field was
|
|
1130
|
+
// added (audit LOW #36). An unsafe cast + immediate .replace() would
|
|
1131
|
+
// throw TypeError — return 404 instead.
|
|
1132
|
+
if (!run.recipeName) {
|
|
1133
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1134
|
+
res.end(JSON.stringify({ error: "recipe_name_missing" }));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
661
1137
|
// triggerSource appends ":agent" suffix — strip before file lookup
|
|
662
1138
|
const recipeName = run.recipeName.replace(/:agent$/, "");
|
|
663
1139
|
const plan = await deps.runPlanFn(recipeName);
|
|
@@ -887,7 +1363,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
887
1363
|
})();
|
|
888
1364
|
return true;
|
|
889
1365
|
}
|
|
890
|
-
if (
|
|
1366
|
+
if (parsedUrl.pathname === "/recipes" && req.method === "POST") {
|
|
891
1367
|
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
892
1368
|
void (async () => {
|
|
893
1369
|
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
@@ -970,13 +1446,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
970
1446
|
}
|
|
971
1447
|
const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
972
1448
|
if (recipePatchMatch && req.method === "PATCH") {
|
|
973
|
-
// A-PR2:
|
|
1449
|
+
// A-PR2: tight 1 KB cap — the only valid payload is `{"enabled":bool}`.
|
|
1450
|
+
// Using the 256 KB content cap here was a trivial DoS vector (audit LOW #34).
|
|
974
1451
|
const name = decodeURIComponent(recipePatchMatch[1] ?? "");
|
|
975
1452
|
void (async () => {
|
|
976
|
-
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.
|
|
1453
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.toggle);
|
|
977
1454
|
if (!parsedBody.ok) {
|
|
978
1455
|
if (parsedBody.code === "too_large") {
|
|
979
|
-
respond413(res, RECIPE_ROUTE_BODY_CAPS.
|
|
1456
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.toggle);
|
|
980
1457
|
}
|
|
981
1458
|
else {
|
|
982
1459
|
respondInvalidJson(res);
|
|
@@ -1089,6 +1566,41 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1089
1566
|
})();
|
|
1090
1567
|
return true;
|
|
1091
1568
|
}
|
|
1569
|
+
// GET /recipes/:name/simulate — What-If Preview: static counterfactual
|
|
1570
|
+
// simulation of a recipe by name (projected actions, side-effect taxonomy,
|
|
1571
|
+
// blast-radius risk, tier-only approval projection honestly flagged as NOT
|
|
1572
|
+
// gated on recipe steps today, low-confidence cost, undetermined branches).
|
|
1573
|
+
// Executes nothing; superset of the dry-run plan. See runRecipeSimulate.
|
|
1574
|
+
const recipeSimulateMatch = req.method === "GET"
|
|
1575
|
+
? /^\/recipes\/([^/]+)\/simulate$/.exec(parsedUrl.pathname)
|
|
1576
|
+
: null;
|
|
1577
|
+
if (recipeSimulateMatch?.[1]) {
|
|
1578
|
+
const name = decodeURIComponent(recipeSimulateMatch[1]);
|
|
1579
|
+
void (async () => {
|
|
1580
|
+
try {
|
|
1581
|
+
if (!deps.simulateFn) {
|
|
1582
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1583
|
+
res.end(JSON.stringify({ error: "simulate_unavailable" }));
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
const report = await deps.simulateFn(name);
|
|
1587
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1588
|
+
res.end(JSON.stringify({ report }));
|
|
1589
|
+
}
|
|
1590
|
+
catch (err) {
|
|
1591
|
+
// #605: classify by code, not substring (see /recipes/:name/plan).
|
|
1592
|
+
const code = err?.code;
|
|
1593
|
+
if (code === "ENOENT" || code === "RECIPE_NOT_FOUND") {
|
|
1594
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1595
|
+
res.end(JSON.stringify({ error: "Recipe not found" }));
|
|
1596
|
+
}
|
|
1597
|
+
else {
|
|
1598
|
+
respond500(res, err, "recipes simulate");
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
})();
|
|
1602
|
+
return true;
|
|
1603
|
+
}
|
|
1092
1604
|
const recipeContentMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
1093
1605
|
if (recipeContentMatch && req.method === "GET") {
|
|
1094
1606
|
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
@@ -1148,7 +1660,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1148
1660
|
res.writeHead(result.ok ? 200 : 400, {
|
|
1149
1661
|
"Content-Type": "application/json",
|
|
1150
1662
|
});
|
|
1151
|
-
|
|
1663
|
+
const safeResult = result.error
|
|
1664
|
+
? { ...result, error: sanitizeStorageError(result.error) }
|
|
1665
|
+
: result;
|
|
1666
|
+
res.end(JSON.stringify(safeResult));
|
|
1152
1667
|
}
|
|
1153
1668
|
catch {
|
|
1154
1669
|
respondInvalidJson(res);
|
|
@@ -1177,7 +1692,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1177
1692
|
? 404
|
|
1178
1693
|
: 400;
|
|
1179
1694
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1180
|
-
|
|
1695
|
+
const safeDeleteResult = result.error
|
|
1696
|
+
? { ...result, error: sanitizeStorageError(result.error) }
|
|
1697
|
+
: result;
|
|
1698
|
+
res.end(JSON.stringify(safeDeleteResult));
|
|
1181
1699
|
return true;
|
|
1182
1700
|
}
|
|
1183
1701
|
// POST /recipes/:name/archive — move recipe (+ sidecar) into <recipesDir>/.archive/
|
|
@@ -1200,7 +1718,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1200
1718
|
? 404
|
|
1201
1719
|
: 400;
|
|
1202
1720
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1203
|
-
|
|
1721
|
+
const safeArchiveResult = result.error
|
|
1722
|
+
? { ...result, error: sanitizeStorageError(result.error) }
|
|
1723
|
+
: result;
|
|
1724
|
+
res.end(JSON.stringify(safeArchiveResult));
|
|
1204
1725
|
return true;
|
|
1205
1726
|
}
|
|
1206
1727
|
// POST /recipes/:name/duplicate — copy recipe as next available variant name
|
|
@@ -1223,7 +1744,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1223
1744
|
? 404
|
|
1224
1745
|
: 400;
|
|
1225
1746
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1226
|
-
|
|
1747
|
+
const safeDuplicateResult = result.error
|
|
1748
|
+
? { ...result, error: sanitizeStorageError(result.error) }
|
|
1749
|
+
: result;
|
|
1750
|
+
res.end(JSON.stringify(safeDuplicateResult));
|
|
1227
1751
|
return true;
|
|
1228
1752
|
}
|
|
1229
1753
|
// POST /recipes/:name/promote — promote variant to canonical name.
|
|
@@ -1288,7 +1812,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1288
1812
|
})();
|
|
1289
1813
|
return true;
|
|
1290
1814
|
}
|
|
1291
|
-
if (
|
|
1815
|
+
if (parsedUrl.pathname === "/recipes" && req.method === "GET") {
|
|
1292
1816
|
try {
|
|
1293
1817
|
const data = deps.recipesFn?.() ?? { recipesDir: null, recipes: [] };
|
|
1294
1818
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -1299,12 +1823,83 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1299
1823
|
}
|
|
1300
1824
|
return true;
|
|
1301
1825
|
}
|
|
1826
|
+
// POST /copilot/message — Tier 1 lever-action copilot (Overview deck's
|
|
1827
|
+
// 7:copilot pane). Deterministic intent matching only, NEVER an LLM
|
|
1828
|
+
// call and NEVER an executed action — this route only proposes
|
|
1829
|
+
// {reply, action?}; the dashboard's action-card Confirm button is what
|
|
1830
|
+
// actually calls the (separately gated) pause/enable/run endpoints.
|
|
1831
|
+
// "Chat proposes, buttons dispose" — see parseIntent.ts's module doc.
|
|
1832
|
+
if (parsedUrl.pathname === "/copilot/message" && req.method === "POST") {
|
|
1833
|
+
void (async () => {
|
|
1834
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.copilotMessage);
|
|
1835
|
+
if (!parsedBody.ok) {
|
|
1836
|
+
if (parsedBody.code === "too_large") {
|
|
1837
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.copilotMessage);
|
|
1838
|
+
}
|
|
1839
|
+
else {
|
|
1840
|
+
respondInvalidJson(res);
|
|
1841
|
+
}
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
try {
|
|
1845
|
+
const parsed = parsedBody.value ?? {};
|
|
1846
|
+
if (respondIfUnknownBodyKeys(res, parsed, ["text"]))
|
|
1847
|
+
return;
|
|
1848
|
+
const text = typeof parsed.text === "string" ? parsed.text : "";
|
|
1849
|
+
const recipesData = deps.recipesFn?.() ?? { recipes: [] };
|
|
1850
|
+
const recipeList = Array.isArray(recipesData.recipes)
|
|
1851
|
+
? recipesData.recipes
|
|
1852
|
+
: [];
|
|
1853
|
+
const intent = parseCopilotIntent(text, recipeList);
|
|
1854
|
+
let haltReason = null;
|
|
1855
|
+
if (intent.kind === "explain_halt") {
|
|
1856
|
+
const runs = deps.runsFn?.({
|
|
1857
|
+
recipe: intent.recipe?.name,
|
|
1858
|
+
limit: 20,
|
|
1859
|
+
}) ?? [];
|
|
1860
|
+
const lastHalt = runs.find((r) => typeof r.haltReason === "string");
|
|
1861
|
+
haltReason = lastHalt?.haltReason ?? null;
|
|
1862
|
+
}
|
|
1863
|
+
// Read-only status Q&A — same singletons the /approvals and
|
|
1864
|
+
// /kill-switch routes already read from, so the copilot's answer
|
|
1865
|
+
// can never drift from what those pages show.
|
|
1866
|
+
const result = buildCopilotReply(intent, {
|
|
1867
|
+
haltReason,
|
|
1868
|
+
approvalsPending: intent.kind === "approvals_status"
|
|
1869
|
+
? getApprovalQueue().list().length
|
|
1870
|
+
: undefined,
|
|
1871
|
+
killSwitchEngaged: intent.kind === "kill_switch_status"
|
|
1872
|
+
? isWriteKillSwitchActive()
|
|
1873
|
+
: undefined,
|
|
1874
|
+
});
|
|
1875
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1876
|
+
res.end(JSON.stringify(result));
|
|
1877
|
+
}
|
|
1878
|
+
catch (err) {
|
|
1879
|
+
respond500(res, err);
|
|
1880
|
+
}
|
|
1881
|
+
})();
|
|
1882
|
+
return true;
|
|
1883
|
+
}
|
|
1302
1884
|
if (parsedUrl.pathname === "/templates" && req.method === "GET") {
|
|
1303
1885
|
void (async () => {
|
|
1304
1886
|
try {
|
|
1305
1887
|
const now = Date.now();
|
|
1306
1888
|
if (templatesCache === null || now - templatesCacheTs > 5 * 60 * 1000) {
|
|
1307
|
-
|
|
1889
|
+
// Abort a stalled CDN connection — without this a hung socket
|
|
1890
|
+
// parks the handler forever (the negative-cache sentinel only
|
|
1891
|
+
// fires on rejection, which a hung connection never produces).
|
|
1892
|
+
// Matches the AbortController idiom used by the other fetch
|
|
1893
|
+
// sites in this file.
|
|
1894
|
+
const ctl = new AbortController();
|
|
1895
|
+
const timeout = setTimeout(() => ctl.abort(), 30_000);
|
|
1896
|
+
let ghRes;
|
|
1897
|
+
try {
|
|
1898
|
+
ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json", { signal: ctl.signal });
|
|
1899
|
+
}
|
|
1900
|
+
finally {
|
|
1901
|
+
clearTimeout(timeout);
|
|
1902
|
+
}
|
|
1308
1903
|
if (!ghRes.ok) {
|
|
1309
1904
|
throw new Error(`GitHub returned ${ghRes.status}`);
|
|
1310
1905
|
}
|
|
@@ -1563,8 +2158,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1563
2158
|
// recipe inside one millisecond don't collide.
|
|
1564
2159
|
const { randomBytes: randomBytesFn } = await import("node:crypto");
|
|
1565
2160
|
const tmpFile = path.join(os.tmpdir(), `patchwork-bundle-install-${process.pid}-${Date.now()}-${randomBytesFn(6).toString("hex")}-${r}.yaml`);
|
|
1566
|
-
writeFileSync
|
|
2161
|
+
// Audit LOW #35: writeFileSync is inside the try so that the
|
|
2162
|
+
// finally block's unlinkSync runs even when the write throws.
|
|
1567
2163
|
try {
|
|
2164
|
+
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1568
2165
|
const installResult = installRecipeFromFile(tmpFile, {
|
|
1569
2166
|
recipesDir,
|
|
1570
2167
|
});
|
|
@@ -1745,7 +2342,22 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1745
2342
|
}
|
|
1746
2343
|
fetchUrl = source;
|
|
1747
2344
|
const urlParts = fetchUrl.split("/");
|
|
1748
|
-
|
|
2345
|
+
const rawRecipeName = (urlParts[urlParts.length - 1] ?? "").replace(/\.ya?ml$/i, "");
|
|
2346
|
+
// Audit 2026-06-03 MEDIUM #17: the derived recipe name was written
|
|
2347
|
+
// into the recipes directory without any sanitisation, opening a
|
|
2348
|
+
// path-injection vector (e.g. `bad%20name`, `..evil`, etc.).
|
|
2349
|
+
// Validate against the same SEGMENT_RE used for github: sources.
|
|
2350
|
+
recipeName = rawRecipeName.toLowerCase();
|
|
2351
|
+
const { SEGMENT_RE: installSegRE } = await import("./recipes/githubInstallSource.js");
|
|
2352
|
+
if (!recipeName || !installSegRE.test(recipeName)) {
|
|
2353
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2354
|
+
res.end(JSON.stringify({
|
|
2355
|
+
ok: false,
|
|
2356
|
+
error: "Invalid recipe name in source URL",
|
|
2357
|
+
code: "invalid_recipe_name",
|
|
2358
|
+
}));
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
1749
2361
|
}
|
|
1750
2362
|
else {
|
|
1751
2363
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -1806,13 +2418,34 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1806
2418
|
}
|
|
1807
2419
|
else {
|
|
1808
2420
|
try {
|
|
1809
|
-
|
|
2421
|
+
// B3-A: manual redirect loop with per-hop SSRF re-validation —
|
|
2422
|
+
// the upfront validateSafeUrl above only pins the first hop, so a
|
|
2423
|
+
// 302 → private IP / IMDS would otherwise bypass it under
|
|
2424
|
+
// `redirect: "follow"`.
|
|
2425
|
+
yamlRes = await fetchFollowingRedirectsSafely(fetchUrl, {
|
|
1810
2426
|
signal: fetchCtl.signal,
|
|
1811
|
-
redirect: "follow",
|
|
1812
2427
|
});
|
|
1813
2428
|
}
|
|
1814
2429
|
catch (err) {
|
|
1815
2430
|
clearTimeout(fetchTimeout);
|
|
2431
|
+
// A redirect to a blocked host is an SSRF attempt, not a generic
|
|
2432
|
+
// network failure — surface it as 403 ssrf_blocked so it isn't
|
|
2433
|
+
// confused with an unreachable upstream.
|
|
2434
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2435
|
+
if (/^SSRF guard blocked/.test(msg)) {
|
|
2436
|
+
// Log the full guard detail server-side, but return a STATIC
|
|
2437
|
+
// message — never echo the caught error text to the client (it is
|
|
2438
|
+
// error/stack-derived; CodeQL js/stack-trace-exposure). The
|
|
2439
|
+
// `code` carries the machine-readable signal.
|
|
2440
|
+
console.error(`[recipes/install] SSRF guard blocked redirect:`, msg);
|
|
2441
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2442
|
+
res.end(JSON.stringify({
|
|
2443
|
+
ok: false,
|
|
2444
|
+
error: "Install blocked: the source redirected to a disallowed host (SSRF guard).",
|
|
2445
|
+
code: "ssrf_blocked",
|
|
2446
|
+
}));
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
1816
2449
|
console.error(`[recipes/install] fetch failed:`, err);
|
|
1817
2450
|
// Network-level error → 502 (upstream unreachable), not 500.
|
|
1818
2451
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
@@ -1887,9 +2520,13 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
|
1887
2520
|
const { randomBytes } = await import("node:crypto");
|
|
1888
2521
|
const tmpFile = path.join(os.tmpdir(), `patchwork-install-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}-${recipeName}.yaml`);
|
|
1889
2522
|
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1890
|
-
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1891
2523
|
let result;
|
|
2524
|
+
// Audit LOW #35: writeFileSync is now inside the try so that the
|
|
2525
|
+
// finally block's unlinkSync runs even when the write itself throws
|
|
2526
|
+
// (e.g. ENOSPC, EROFS). Previously writeFileSync was called before
|
|
2527
|
+
// the try, orphaning the temp file on any write error.
|
|
1892
2528
|
try {
|
|
2529
|
+
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1893
2530
|
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1894
2531
|
mkdirSync(recipesDir, { recursive: true });
|
|
1895
2532
|
const { installRecipeFromFile } = await import("./recipes/installer.js");
|