patchwork-os 0.2.0-alpha.34 → 0.2.0-alpha.36
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.md +202 -93
- package/deploy/bootstrap-new-vps.sh +12 -12
- package/deploy/bootstrap-vps.sh +6 -3
- package/deploy/deploy-landing.sh +59 -2
- 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 +25 -0
- package/dist/approvalHttp.js +74 -18
- 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 +11 -0
- package/dist/approvalQueue.js +80 -1
- 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 +37 -0
- package/dist/automation.js +105 -61
- 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.js +78 -1
- package/dist/bridge.js.map +1 -1
- package/dist/ccPermissions.d.ts +15 -0
- package/dist/ccPermissions.js +15 -0
- package/dist/ccPermissions.js.map +1 -1
- package/dist/claudeDriver.js +74 -16
- package/dist/claudeDriver.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +8 -0
- package/dist/commands/patchworkInit.js +41 -5
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +20 -0
- package/dist/commands/recipe.js +212 -6
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.d.ts +79 -1
- package/dist/commands/recipeInstall.js +333 -16
- package/dist/commands/recipeInstall.js.map +1 -1
- 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 +8 -0
- package/dist/config.js +9 -1
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.d.ts +43 -0
- package/dist/connectorRoutes.js +1023 -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 +36 -0
- package/dist/connectors/baseConnector.js +151 -28
- package/dist/connectors/baseConnector.js.map +1 -1
- 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/github.js +11 -4
- 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.js +50 -10
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.js +36 -10
- 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/linear.js +23 -4
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +26 -2
- package/dist/connectors/mcpOAuth.js.map +1 -1
- 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/slack.d.ts +16 -1
- package/dist/connectors/slack.js +57 -5
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/tokenStorage.js +27 -2
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/connectors/zendesk.js +19 -1
- package/dist/connectors/zendesk.js.map +1 -1
- 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/featureFlags.d.ts +17 -11
- package/dist/featureFlags.js +52 -47
- package/dist/featureFlags.js.map +1 -1
- package/dist/fp/automationInterpreter.js +25 -21
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationState.js +4 -1
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/policyParser.js +4 -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 +734 -144
- package/dist/index.js.map +1 -1
- 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 +3 -0
- package/dist/oauth.js +151 -26
- 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 +7 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.d.ts +12 -0
- package/dist/pluginLoader.js +43 -4
- 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 +8 -0
- package/dist/recipeOrchestration.js +320 -39
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +154 -0
- package/dist/recipeRoutes.js +1098 -0
- package/dist/recipeRoutes.js.map +1 -0
- package/dist/recipes/captureForRunlog.d.ts +27 -0
- package/dist/recipes/captureForRunlog.js +128 -0
- package/dist/recipes/captureForRunlog.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +54 -3
- package/dist/recipes/chainedRunner.js +256 -36
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/compiler.js +3 -3
- package/dist/recipes/compiler.js.map +1 -1
- package/dist/recipes/detectSilentFail.d.ts +34 -0
- package/dist/recipes/detectSilentFail.js +105 -0
- package/dist/recipes/detectSilentFail.js.map +1 -0
- package/dist/recipes/installer.js +3 -3
- package/dist/recipes/installer.js.map +1 -1
- package/dist/recipes/manifest.js +21 -6
- package/dist/recipes/manifest.js.map +1 -1
- 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/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.js +102 -11
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schemaGenerator.js +3 -3
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/toolRegistry.d.ts +5 -0
- package/dist/recipes/toolRegistry.js +9 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- 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/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 +6 -0
- package/dist/recipes/tools/file.js +12 -8
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/github.js +29 -4
- package/dist/recipes/tools/github.js.map +1 -1
- 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 +1 -1
- package/dist/recipes/tools/gmail.js +230 -6
- package/dist/recipes/tools/gmail.js.map +1 -1
- 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/index.d.ts +8 -0
- package/dist/recipes/tools/index.js +8 -0
- package/dist/recipes/tools/index.js.map +1 -1
- 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 +2 -1
- package/dist/recipes/tools/linear.js +227 -3
- package/dist/recipes/tools/linear.js.map +1 -1
- 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/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.js +15 -5
- package/dist/recipes/tools/slack.js.map +1 -1
- package/dist/recipes/validation.js +83 -14
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +30 -2
- package/dist/recipes/yamlRunner.js +369 -70
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +76 -1
- package/dist/recipesHttp.js +474 -12
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +78 -2
- package/dist/runLog.js +204 -6
- 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 +79 -10
- package/dist/server.js +366 -1384
- 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 +126 -17
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/getDocumentSymbols.d.ts +24 -0
- package/dist/tools/getDocumentSymbols.js +74 -8
- package/dist/tools/getDocumentSymbols.js.map +1 -1
- package/dist/tools/getSecurityAdvisories.js +10 -1
- package/dist/tools/getSecurityAdvisories.js.map +1 -1
- package/dist/tools/getSessionUsage.d.ts +3 -0
- package/dist/tools/getSessionUsage.js +3 -0
- package/dist/tools/getSessionUsage.js.map +1 -1
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +32 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/slackPostMessage.js +1 -1
- package/dist/tools/slackPostMessage.js.map +1 -1
- package/dist/tools/transaction.d.ts +19 -0
- package/dist/tools/transaction.js +29 -0
- package/dist/tools/transaction.js.map +1 -1
- 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 +39 -0
- package/dist/transport.js +88 -8
- package/dist/transport.js.map +1 -1
- package/package.json +22 -5
- 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/project-health-check.yaml +1 -1
- 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
|
@@ -24,14 +24,33 @@ import { spawnSync } from "node:child_process";
|
|
|
24
24
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
27
28
|
import { parse as parseYaml } from "yaml";
|
|
28
29
|
import { captureFixture } from "../connectors/fixtureRecorder.js";
|
|
29
30
|
import { findYamlRecipePath } from "../recipesHttp.js";
|
|
30
31
|
import { executeAgent as _executeAgent, } from "./agentExecutor.js";
|
|
32
|
+
import { detectSilentFail } from "./detectSilentFail.js";
|
|
31
33
|
import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./legacyRecipeCompat.js";
|
|
34
|
+
import { resolveRecipePath } from "./resolveRecipePath.js";
|
|
32
35
|
// Import tool registry and trigger tool self-registration
|
|
33
36
|
import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
|
|
34
37
|
import "./tools/index.js";
|
|
38
|
+
/**
|
|
39
|
+
* Bundled-templates directory used as a third allowed root for nested-recipe
|
|
40
|
+
* lookups (`recipe:` references with explicit paths). Resolved once at module
|
|
41
|
+
* load — `__dirname` equivalent points at `dist/recipes/` in the npm tarball
|
|
42
|
+
* (or `src/recipes/` in dev) so the relative `../../templates/recipes` lifts
|
|
43
|
+
* out of the source tree to the package root regardless of build layout.
|
|
44
|
+
*
|
|
45
|
+
* See dogfood A-PR2 / R2 M-5 — the third jail root is captured here, not at
|
|
46
|
+
* call time, so a runtime CWD change cannot relocate it.
|
|
47
|
+
*/
|
|
48
|
+
const BUNDLED_TEMPLATES_DIR = (() => {
|
|
49
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
// dist/recipes/yamlRunner.js → ../../templates/recipes
|
|
51
|
+
// src/recipes/yamlRunner.ts → ../../templates/recipes
|
|
52
|
+
return path.resolve(here, "..", "..", "templates", "recipes");
|
|
53
|
+
})();
|
|
35
54
|
export function evaluateExpect(result, expect) {
|
|
36
55
|
const failures = [];
|
|
37
56
|
if (expect.stepsRun !== undefined && result.stepsRun !== expect.stepsRun) {
|
|
@@ -181,12 +200,52 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
181
200
|
await loadRecipeServers(recipe.servers);
|
|
182
201
|
}
|
|
183
202
|
const now = deps.now ? deps.now() : new Date();
|
|
203
|
+
// Resolve recipe-level context blocks (type: env) into seed context
|
|
204
|
+
const envCtx = {};
|
|
205
|
+
if (Array.isArray(recipe.context)) {
|
|
206
|
+
for (const block of recipe
|
|
207
|
+
.context ?? []) {
|
|
208
|
+
const b = block;
|
|
209
|
+
if (b.type === "env" && Array.isArray(b.keys)) {
|
|
210
|
+
for (const key of b.keys) {
|
|
211
|
+
const v = process.env[key];
|
|
212
|
+
if (v !== undefined)
|
|
213
|
+
envCtx[key] = v;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
184
218
|
const ctx = {
|
|
185
219
|
date: now.toISOString().slice(0, 10),
|
|
186
220
|
time: now.toTimeString().slice(0, 5),
|
|
221
|
+
...envCtx,
|
|
187
222
|
...seedContext,
|
|
188
223
|
};
|
|
189
224
|
const stepDeps = resolveStepDeps(deps);
|
|
225
|
+
// Open a `running`-state run-log entry so the dashboard sees the recipe
|
|
226
|
+
// as in flight. Only when a long-lived `runLog` is provided (bridge path);
|
|
227
|
+
// CLI runs fall back to `appendDirect` at end via the existing logDir
|
|
228
|
+
// path. Skip in test mode.
|
|
229
|
+
const recipeStartedAt = now.getTime();
|
|
230
|
+
const recipeTriggerKind = recipe.trigger?.type ?? "manual";
|
|
231
|
+
const yamlTriggerKind = (["cron", "webhook", "recipe"].includes(recipeTriggerKind)
|
|
232
|
+
? recipeTriggerKind
|
|
233
|
+
: "recipe");
|
|
234
|
+
let runSeq;
|
|
235
|
+
if (deps.runLog && !stepDeps.testMode) {
|
|
236
|
+
try {
|
|
237
|
+
runSeq = deps.runLog.startRun({
|
|
238
|
+
taskId: `yaml:${recipe.name}:${recipeStartedAt}`,
|
|
239
|
+
recipeName: recipe.name,
|
|
240
|
+
trigger: yamlTriggerKind,
|
|
241
|
+
createdAt: recipeStartedAt,
|
|
242
|
+
startedAt: recipeStartedAt,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Non-fatal — run-log failures must never break recipe execution.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
190
249
|
const outputs = [];
|
|
191
250
|
const stepResults = [];
|
|
192
251
|
let stepsRun = 0;
|
|
@@ -206,13 +265,22 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
206
265
|
driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
|
|
207
266
|
model: agentCfg.model,
|
|
208
267
|
}, buildAgentExecutorDeps(stepDeps, deps));
|
|
209
|
-
|
|
210
|
-
|
|
268
|
+
// Catch both `[agent step failed: ...]` (existing) and the
|
|
269
|
+
// silent-fail patterns `[agent step skipped: ...]` etc. via the
|
|
270
|
+
// shared detector. Per-step opt-out via `silentFailDetection: false`.
|
|
271
|
+
const agentSilentFail = step.silentFailDetection !== false
|
|
272
|
+
? detectSilentFail(agentResult)
|
|
273
|
+
: null;
|
|
274
|
+
if (agentResult.startsWith("[agent step failed:") || agentSilentFail) {
|
|
275
|
+
const reason = agentSilentFail
|
|
276
|
+
? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
|
|
277
|
+
: agentResult;
|
|
278
|
+
runError = runError ?? reason;
|
|
211
279
|
stepResults.push({
|
|
212
280
|
id: stepId,
|
|
213
281
|
tool: "agent",
|
|
214
282
|
status: "error",
|
|
215
|
-
error:
|
|
283
|
+
error: reason,
|
|
216
284
|
durationMs: Date.now() - stepStart,
|
|
217
285
|
});
|
|
218
286
|
}
|
|
@@ -230,7 +298,15 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
230
298
|
});
|
|
231
299
|
}
|
|
232
300
|
else {
|
|
233
|
-
|
|
301
|
+
// Try to parse as JSON so dot-notation ({{meeting.field}}) works
|
|
302
|
+
try {
|
|
303
|
+
const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
|
|
304
|
+
const parsed = JSON.parse((jsonMatch[1] ?? "").trim());
|
|
305
|
+
ctx[intoKey] = parsed;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
ctx[intoKey] = stripped;
|
|
309
|
+
}
|
|
234
310
|
outputs.push(intoKey);
|
|
235
311
|
stepResults.push({
|
|
236
312
|
id: stepId,
|
|
@@ -256,19 +332,21 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
256
332
|
continue;
|
|
257
333
|
}
|
|
258
334
|
const stepStart = Date.now();
|
|
259
|
-
const stepId = step.into ??
|
|
335
|
+
const stepId = step.into ?? `step_${stepsRun}`;
|
|
260
336
|
// Resolve retry policy: step-level overrides recipe-level.
|
|
261
337
|
const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
|
|
262
338
|
const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
|
|
263
339
|
let result = null;
|
|
264
340
|
let stepError;
|
|
265
341
|
let thrownError;
|
|
342
|
+
let thrownErrorCode;
|
|
266
343
|
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
267
344
|
if (attempt > 0) {
|
|
268
345
|
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
269
346
|
}
|
|
270
347
|
stepError = undefined;
|
|
271
348
|
thrownError = undefined;
|
|
349
|
+
thrownErrorCode = undefined;
|
|
272
350
|
try {
|
|
273
351
|
result = await executeStep(step, ctx, stepDeps);
|
|
274
352
|
// Detect tool-level errors reported as JSON {ok: false, error: ...}
|
|
@@ -283,9 +361,29 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
283
361
|
/* non-JSON result is fine */
|
|
284
362
|
}
|
|
285
363
|
}
|
|
364
|
+
// Silent-fail detection: tools that return string placeholders
|
|
365
|
+
// (`(git branches unavailable)`, `[agent step skipped: ...]`)
|
|
366
|
+
// or empty list-tool error shapes (`{count:0,error:"..."}`)
|
|
367
|
+
// succeed with bad data — flag them as `error` so the runner
|
|
368
|
+
// doesn't quietly hand garbage to a downstream agent. Per-step
|
|
369
|
+
// opt-out via `silentFailDetection: false`.
|
|
370
|
+
if (!stepError &&
|
|
371
|
+
result !== null &&
|
|
372
|
+
step.silentFailDetection !== false) {
|
|
373
|
+
const detected = detectSilentFail(result);
|
|
374
|
+
if (detected) {
|
|
375
|
+
stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
286
378
|
}
|
|
287
379
|
catch (err) {
|
|
288
380
|
thrownError = err instanceof Error ? err.message : String(err);
|
|
381
|
+
// Preserve structured error codes (e.g. recipe_path_jail_escape)
|
|
382
|
+
// so callers and tests can branch on `err.code` per R2 M-4
|
|
383
|
+
// without scraping the message string.
|
|
384
|
+
const code = err?.code;
|
|
385
|
+
if (typeof code === "string")
|
|
386
|
+
thrownErrorCode = code;
|
|
289
387
|
result = null;
|
|
290
388
|
}
|
|
291
389
|
if (!stepError && !thrownError)
|
|
@@ -302,6 +400,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
302
400
|
tool: step.tool,
|
|
303
401
|
status: "error",
|
|
304
402
|
error: thrownError,
|
|
403
|
+
...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
|
|
305
404
|
durationMs: Date.now() - stepStart,
|
|
306
405
|
});
|
|
307
406
|
if (!failOpen) {
|
|
@@ -347,7 +446,15 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
347
446
|
}
|
|
348
447
|
}
|
|
349
448
|
if (step.tool === "file.write" || step.tool === "file.append") {
|
|
350
|
-
|
|
449
|
+
// R2 C-1 / F-02: re-validate the rendered path against the jail so a
|
|
450
|
+
// template substitution that survived earlier checks (e.g. via a
|
|
451
|
+
// chained sub-recipe deps override) cannot smuggle an out-of-jail
|
|
452
|
+
// path into the run log / dashboard outputs list.
|
|
453
|
+
const renderedPath = render(step.path, ctx);
|
|
454
|
+
outputs.push(resolveRecipePath(renderedPath, {
|
|
455
|
+
workspace: stepDeps.workdir,
|
|
456
|
+
write: true,
|
|
457
|
+
}));
|
|
351
458
|
}
|
|
352
459
|
}
|
|
353
460
|
}
|
|
@@ -355,42 +462,54 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
355
462
|
const assertionFailures = recipe.expect
|
|
356
463
|
? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
|
|
357
464
|
: [];
|
|
358
|
-
// Write to RecipeRunLog so the dashboard Runs page shows this execution
|
|
465
|
+
// Write to RecipeRunLog so the dashboard Runs page shows this execution.
|
|
466
|
+
// Bridge path: completeRun on the running entry opened above (live-tail).
|
|
467
|
+
// CLI path: construct a local log + appendDirect (no live-tail).
|
|
359
468
|
if (!stepDeps.testMode) {
|
|
360
469
|
try {
|
|
361
|
-
const { RecipeRunLog } = await import("../runLog.js");
|
|
362
|
-
const { homedir } = await import("node:os");
|
|
363
|
-
const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
|
|
364
|
-
const log = new RecipeRunLog({ dir: resolvedLogDir });
|
|
365
|
-
const trigger = recipe.trigger?.type ?? "manual";
|
|
366
|
-
const createdAt = now.getTime();
|
|
367
470
|
const doneAt = Date.now();
|
|
368
471
|
const outputTail = stepResults
|
|
369
472
|
.map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
|
|
370
473
|
.join("\n")
|
|
371
474
|
.slice(0, 2000);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
|
|
475
|
+
const finalStepResults = stepResults.map((s) => ({
|
|
476
|
+
id: s.id,
|
|
477
|
+
tool: s.tool,
|
|
478
|
+
status: s.status,
|
|
479
|
+
error: s.error,
|
|
480
|
+
durationMs: s.durationMs,
|
|
481
|
+
}));
|
|
482
|
+
if (deps.runLog && runSeq !== undefined) {
|
|
483
|
+
deps.runLog.completeRun(runSeq, {
|
|
484
|
+
status: runError ? "error" : "done",
|
|
485
|
+
doneAt,
|
|
486
|
+
durationMs: doneAt - recipeStartedAt,
|
|
487
|
+
stepResults: finalStepResults,
|
|
488
|
+
outputTail,
|
|
489
|
+
...(runError !== undefined && { errorMessage: runError }),
|
|
490
|
+
...(assertionFailures.length > 0 ? { assertionFailures } : {}),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
const { RecipeRunLog } = await import("../runLog.js");
|
|
495
|
+
const { homedir } = await import("node:os");
|
|
496
|
+
const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
|
|
497
|
+
const log = new RecipeRunLog({ dir: resolvedLogDir });
|
|
498
|
+
log.appendDirect({
|
|
499
|
+
taskId: `yaml:${recipe.name}:${recipeStartedAt}`,
|
|
500
|
+
recipeName: recipe.name,
|
|
501
|
+
trigger: yamlTriggerKind,
|
|
502
|
+
status: runError ? "error" : "done",
|
|
503
|
+
createdAt: recipeStartedAt,
|
|
504
|
+
startedAt: recipeStartedAt,
|
|
505
|
+
doneAt,
|
|
506
|
+
durationMs: doneAt - recipeStartedAt,
|
|
507
|
+
outputTail,
|
|
508
|
+
errorMessage: runError,
|
|
509
|
+
stepResults: finalStepResults,
|
|
510
|
+
...(assertionFailures.length > 0 ? { assertionFailures } : {}),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
394
513
|
}
|
|
395
514
|
catch {
|
|
396
515
|
// Non-fatal — run log write failure should never break recipe execution
|
|
@@ -448,12 +567,7 @@ export async function executeStep(step, ctx, deps) {
|
|
|
448
567
|
for (const [key, value] of Object.entries(step)) {
|
|
449
568
|
if (key === "tool" || key === "agent" || key === "into")
|
|
450
569
|
continue;
|
|
451
|
-
|
|
452
|
-
params[key] = render(value, ctx);
|
|
453
|
-
}
|
|
454
|
-
else {
|
|
455
|
-
params[key] = value;
|
|
456
|
-
}
|
|
570
|
+
params[key] = deepRender(value, ctx);
|
|
457
571
|
}
|
|
458
572
|
// Check if mock connector is available for this tool
|
|
459
573
|
if (deps.mockConnectors?.[toolId]) {
|
|
@@ -471,17 +585,60 @@ export async function executeStep(step, ctx, deps) {
|
|
|
471
585
|
// Unknown tool — skip, don't throw (forward compat)
|
|
472
586
|
return null;
|
|
473
587
|
}
|
|
474
|
-
/** Minimal `{{ expr }}` renderer —
|
|
588
|
+
/** Minimal `{{ expr }}` renderer — flat keys and dot-notation paths. */
|
|
475
589
|
export function render(template, ctx) {
|
|
476
590
|
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
|
|
477
591
|
const key = expr.trim();
|
|
478
|
-
|
|
592
|
+
const coerce = (v) => {
|
|
593
|
+
if (v == null)
|
|
594
|
+
return "";
|
|
595
|
+
if (typeof v === "object")
|
|
596
|
+
return JSON.stringify(v);
|
|
597
|
+
return String(v);
|
|
598
|
+
};
|
|
599
|
+
// Fast path: flat key exists
|
|
600
|
+
if (Object.hasOwn(ctx, key))
|
|
601
|
+
return coerce(ctx[key]);
|
|
602
|
+
// Dot-notation: resolve nested path into ctx values (JSON-parse string intermediates)
|
|
603
|
+
const parts = key.split(".");
|
|
604
|
+
// biome-ignore lint/suspicious/noExplicitAny: resolved values are dynamic JSON shapes
|
|
605
|
+
let val = ctx;
|
|
606
|
+
for (const part of parts) {
|
|
607
|
+
if (val == null)
|
|
608
|
+
return "";
|
|
609
|
+
if (typeof val === "string") {
|
|
610
|
+
try {
|
|
611
|
+
val = JSON.parse(val);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return "";
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (typeof val !== "object")
|
|
618
|
+
return "";
|
|
619
|
+
val = val[part];
|
|
620
|
+
}
|
|
621
|
+
return val == null
|
|
622
|
+
? ""
|
|
623
|
+
: typeof val === "object"
|
|
624
|
+
? JSON.stringify(val)
|
|
625
|
+
: String(val);
|
|
479
626
|
});
|
|
480
627
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
628
|
+
/** Recursively render all string leaves in a value (for nested params like blocks). */
|
|
629
|
+
function deepRender(value, ctx) {
|
|
630
|
+
if (typeof value === "string")
|
|
631
|
+
return render(value, ctx);
|
|
632
|
+
if (Array.isArray(value))
|
|
633
|
+
return value.map((v) => deepRender(v, ctx));
|
|
634
|
+
if (value !== null && typeof value === "object") {
|
|
635
|
+
const out = {};
|
|
636
|
+
for (const [k, v] of Object.entries(value)) {
|
|
637
|
+
out[k] = deepRender(v, ctx);
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
return value;
|
|
485
642
|
}
|
|
486
643
|
function parseSinceToGitArg(since) {
|
|
487
644
|
const m = /^(\d+)(h|d)$/i.exec(since.trim());
|
|
@@ -490,7 +647,19 @@ function parseSinceToGitArg(since) {
|
|
|
490
647
|
const [, num, unit = "h"] = m;
|
|
491
648
|
return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
|
|
492
649
|
}
|
|
493
|
-
|
|
650
|
+
// Exported for test coverage of the regression fix (was returning the
|
|
651
|
+
// `(git log unavailable)` placeholder string on any failure, which
|
|
652
|
+
// silently looked like success to pre-#72 runners).
|
|
653
|
+
export function defaultGitLogSince(since, workdir) {
|
|
654
|
+
// Same antipattern that broke `defaultGitStaleBranches` (PR #70): on
|
|
655
|
+
// any error this used to return `(git log unavailable)`. The runner
|
|
656
|
+
// saw that as success-with-empty-data and downstream agents
|
|
657
|
+
// summarized "no recent commits" — false signal.
|
|
658
|
+
//
|
|
659
|
+
// Fix: return a JSON `{ok: false, error}` shape on failure so the
|
|
660
|
+
// runner's existing JSON-error detection (yamlRunner step-error
|
|
661
|
+
// block) flags the step as `error`. Successful runs still return
|
|
662
|
+
// bare git output text.
|
|
494
663
|
try {
|
|
495
664
|
const sinceArg = parseSinceToGitArg(since);
|
|
496
665
|
const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
|
|
@@ -498,25 +667,50 @@ function defaultGitLogSince(since, workdir) {
|
|
|
498
667
|
encoding: "utf-8",
|
|
499
668
|
timeout: 5000,
|
|
500
669
|
});
|
|
501
|
-
if (result.error
|
|
502
|
-
return
|
|
670
|
+
if (result.error) {
|
|
671
|
+
return JSON.stringify({
|
|
672
|
+
ok: false,
|
|
673
|
+
error: `git log failed: ${result.error.message}`,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (result.status !== 0) {
|
|
677
|
+
const stderr = (result.stderr ?? "").toString().trim().slice(0, 200);
|
|
678
|
+
return JSON.stringify({
|
|
679
|
+
ok: false,
|
|
680
|
+
error: `git log exited ${result.status}${stderr ? `: ${stderr}` : ""}`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
503
683
|
return (result.stdout ?? "").trim();
|
|
504
684
|
}
|
|
505
|
-
catch {
|
|
506
|
-
return
|
|
685
|
+
catch (err) {
|
|
686
|
+
return JSON.stringify({
|
|
687
|
+
ok: false,
|
|
688
|
+
error: `git log threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
689
|
+
});
|
|
507
690
|
}
|
|
508
691
|
}
|
|
509
|
-
|
|
692
|
+
// Exported for test coverage of the regression fix (was using `git branch
|
|
693
|
+
// --since=<date>` which isn't a real flag).
|
|
694
|
+
export function defaultGitStaleBranches(days, workdir) {
|
|
695
|
+
// Two bugs were caught dogfooding the `branch-health` recipe:
|
|
696
|
+
// 1) `git branch --since=<date>` is NOT a valid flag — git exits 129
|
|
697
|
+
// with "unknown option `since=...`". The function used to ALWAYS
|
|
698
|
+
// fall through to the "(git branches unavailable)" placeholder.
|
|
699
|
+
// 2) Even if `--since` had been a real flag, its semantics ("commits
|
|
700
|
+
// since") would have produced the OPPOSITE list of what
|
|
701
|
+
// "stale_branches" implies — branches with recent activity, not
|
|
702
|
+
// ones that have gone quiet.
|
|
703
|
+
//
|
|
704
|
+
// Fix: use `git for-each-ref` with a `committerdate` format, parse the
|
|
705
|
+
// ISO date in JS, and emit branches whose last commit is OLDER than
|
|
706
|
+
// the cutoff. Output is one per line: `<short-name> <YYYY-MM-DD>`.
|
|
510
707
|
try {
|
|
511
|
-
const
|
|
512
|
-
.toISOString()
|
|
513
|
-
.slice(0, 10);
|
|
708
|
+
const cutoffMs = Date.now() - days * 86_400_000;
|
|
514
709
|
const r = spawnSync("git", [
|
|
515
|
-
"
|
|
516
|
-
"--
|
|
517
|
-
"--
|
|
518
|
-
"
|
|
519
|
-
`--since=${cutoff}`,
|
|
710
|
+
"for-each-ref",
|
|
711
|
+
"--sort=committerdate",
|
|
712
|
+
"--format=%(refname:short)\t%(committerdate:iso-strict)",
|
|
713
|
+
"refs/heads/",
|
|
520
714
|
], {
|
|
521
715
|
cwd: workdir ?? process.cwd(),
|
|
522
716
|
encoding: "utf-8",
|
|
@@ -524,7 +718,25 @@ function defaultGitStaleBranches(days, workdir) {
|
|
|
524
718
|
});
|
|
525
719
|
if (r.error || r.status !== 0)
|
|
526
720
|
return "(git branches unavailable)";
|
|
527
|
-
|
|
721
|
+
const lines = (r.stdout ?? "").split("\n").filter(Boolean);
|
|
722
|
+
const stale = [];
|
|
723
|
+
for (const line of lines) {
|
|
724
|
+
const tab = line.indexOf("\t");
|
|
725
|
+
if (tab < 0)
|
|
726
|
+
continue;
|
|
727
|
+
const name = line.slice(0, tab);
|
|
728
|
+
const dateStr = line.slice(tab + 1);
|
|
729
|
+
const ts = Date.parse(dateStr);
|
|
730
|
+
if (Number.isNaN(ts))
|
|
731
|
+
continue;
|
|
732
|
+
if (ts < cutoffMs) {
|
|
733
|
+
stale.push(`${name}\t${dateStr.slice(0, 10)}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (stale.length === 0) {
|
|
737
|
+
return `(no branches inactive >${days}d)`;
|
|
738
|
+
}
|
|
739
|
+
return stale.join("\n");
|
|
528
740
|
}
|
|
529
741
|
catch {
|
|
530
742
|
return "(git branches unavailable)";
|
|
@@ -533,26 +745,43 @@ function defaultGitStaleBranches(days, workdir) {
|
|
|
533
745
|
/** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
|
|
534
746
|
function resolveStepDeps(deps) {
|
|
535
747
|
const workdir = deps.workdir ?? process.cwd();
|
|
748
|
+
// Defense-in-depth: even if a file.* tool somehow forgets to call
|
|
749
|
+
// resolveRecipePath in its execute(), the default StepDeps file ops will
|
|
750
|
+
// jail the path before touching the filesystem (G-security F-01 / R2 C-1
|
|
751
|
+
// chained-runner third-substitution-site coverage).
|
|
536
752
|
return {
|
|
537
|
-
readFile: deps.readFile ??
|
|
753
|
+
readFile: deps.readFile ??
|
|
754
|
+
((p) => readFileSync(resolveRecipePath(p, { workspace: workdir }), "utf-8")),
|
|
538
755
|
writeFile: deps.writeFile ??
|
|
539
756
|
((p, content) => {
|
|
540
|
-
const abs =
|
|
757
|
+
const abs = resolveRecipePath(p, { workspace: workdir, write: true });
|
|
541
758
|
mkdirSync(path.dirname(abs), { recursive: true });
|
|
542
759
|
writeFileSync(abs, content);
|
|
543
760
|
}),
|
|
544
761
|
appendFile: deps.appendFile ??
|
|
545
762
|
((p, content) => {
|
|
546
|
-
const abs =
|
|
763
|
+
const abs = resolveRecipePath(p, { workspace: workdir, write: true });
|
|
547
764
|
mkdirSync(path.dirname(abs), { recursive: true });
|
|
548
765
|
appendFileSync(abs, content);
|
|
549
766
|
}),
|
|
550
767
|
mkdir: deps.mkdir ??
|
|
551
|
-
((p) => mkdirSync(
|
|
768
|
+
((p) => mkdirSync(resolveRecipePath(p, { workspace: workdir, write: true }), {
|
|
769
|
+
recursive: true,
|
|
770
|
+
})),
|
|
552
771
|
workdir,
|
|
553
772
|
gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
|
|
554
773
|
gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
|
|
555
|
-
|
|
774
|
+
// The `diagnostics.get` recipe tool is registered (src/recipes/tools/
|
|
775
|
+
// diagnostics.ts) but only meaningful when the bridge wires a real
|
|
776
|
+
// `getDiagnostics` impl backed by the LSP / extension client. CLI runs
|
|
777
|
+
// and tests have no bridge to ask, so the default returns a JSON error
|
|
778
|
+
// shape that the step-error detector flags as `error` instead of the
|
|
779
|
+
// pre-fix empty string that silently passed as success.
|
|
780
|
+
getDiagnostics: deps.getDiagnostics ??
|
|
781
|
+
(() => JSON.stringify({
|
|
782
|
+
ok: false,
|
|
783
|
+
error: "diagnostics.get unavailable (no bridge / no `deps.getDiagnostics` injected)",
|
|
784
|
+
})),
|
|
556
785
|
fetchFn: deps.fetchFn ?? globalThis.fetch,
|
|
557
786
|
claudeFn: deps.claudeFn ?? defaultClaudeFn,
|
|
558
787
|
claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
|
|
@@ -565,6 +794,11 @@ function resolveStepDeps(deps) {
|
|
|
565
794
|
const { getValidAccessToken } = await import("../connectors/gmail.js");
|
|
566
795
|
return getValidAccessToken();
|
|
567
796
|
}),
|
|
797
|
+
getDriveToken: deps.getDriveToken ??
|
|
798
|
+
(async () => {
|
|
799
|
+
const { getValidAccessToken } = await import("../connectors/googleDrive.js");
|
|
800
|
+
return getValidAccessToken();
|
|
801
|
+
}),
|
|
568
802
|
logDir: deps.logDir,
|
|
569
803
|
testMode: deps.testMode ?? false,
|
|
570
804
|
};
|
|
@@ -746,6 +980,26 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
|
|
|
746
980
|
}
|
|
747
981
|
}
|
|
748
982
|
const executeTool = async (tool, params) => {
|
|
983
|
+
// R2 C-1 third-substitution-site coverage: the chained runner has its
|
|
984
|
+
// own template-resolution path (`chainedRunner.ts:194-205`). By the
|
|
985
|
+
// time we reach this dispatch point the params have been rendered
|
|
986
|
+
// *and* JSON-parsed, so a `path` field that survived the chained
|
|
987
|
+
// substitution may have just been promoted from inside-jail to
|
|
988
|
+
// outside-jail. Re-jail any `path` field on file.* tools here so that
|
|
989
|
+
// chained sub-recipes can't bypass the per-tool jail in `tools/file.ts`
|
|
990
|
+
// by injecting `..` segments via outer-recipe vars.
|
|
991
|
+
if ((tool === "file.read" ||
|
|
992
|
+
tool === "file.write" ||
|
|
993
|
+
tool === "file.append") &&
|
|
994
|
+
typeof params.path === "string") {
|
|
995
|
+
params = {
|
|
996
|
+
...params,
|
|
997
|
+
path: resolveRecipePath(params.path, {
|
|
998
|
+
workspace: stepDeps.workdir,
|
|
999
|
+
write: tool !== "file.read",
|
|
1000
|
+
}),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
749
1003
|
// Construct a YamlStep-compatible object so we can reuse executeStep.
|
|
750
1004
|
const step = { tool, ...params };
|
|
751
1005
|
// executeStep uses a RunContext for {{}} rendering — by the time executeTool
|
|
@@ -755,8 +1009,36 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
|
|
|
755
1009
|
return result ?? "";
|
|
756
1010
|
};
|
|
757
1011
|
const executeAgent = async (prompt, model, driver) => _executeAgent({ prompt, model, driver }, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride));
|
|
1012
|
+
// ---------------------------------------------------------------------
|
|
1013
|
+
// BEGIN A-PR2 EDIT BLOCK — `loadNestedRecipe` jail (dogfood F-04).
|
|
1014
|
+
//
|
|
1015
|
+
// Path-shaped recipe references (`recipe: ./inner.yaml`, `recipe: /abs.yaml`)
|
|
1016
|
+
// are restricted to three allowed roots:
|
|
1017
|
+
// 1. parent recipe's directory (`path.dirname(parentSourcePath)`)
|
|
1018
|
+
// 2. user recipes dir (`~/.patchwork/recipes/`)
|
|
1019
|
+
// 3. bundled templates dir (`BUNDLED_TEMPLATES_DIR`, captured at boot)
|
|
1020
|
+
//
|
|
1021
|
+
// Resolved candidates that escape all three (e.g. `/etc/passwd.yaml`) are
|
|
1022
|
+
// rejected with `null` — same shape as a not-found lookup so the chained
|
|
1023
|
+
// runner reports its existing "nested_recipe_not_found" error rather than
|
|
1024
|
+
// surfacing a security-implementation detail to the recipe author.
|
|
1025
|
+
//
|
|
1026
|
+
// Coordination note (A-PR1 may also touch this file): the helper
|
|
1027
|
+
// `pathIsWithin` below is local to this module — A-PR1 is changing
|
|
1028
|
+
// unrelated `vars` validation paths and should not collide here. If a merge
|
|
1029
|
+
// conflict surfaces, keep BOTH the jail AND the A-PR1 vars validation.
|
|
1030
|
+
// ---------------------------------------------------------------------
|
|
1031
|
+
const pathIsWithin = (candidate, base) => {
|
|
1032
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
1033
|
+
const resolvedBase = path.resolve(base);
|
|
1034
|
+
if (resolvedCandidate === resolvedBase)
|
|
1035
|
+
return true;
|
|
1036
|
+
return resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
|
|
1037
|
+
};
|
|
758
1038
|
const loadNestedRecipe = async (name, parentSourcePath) => {
|
|
759
1039
|
const lookupName = normalizeNestedRecipeLookupName(name);
|
|
1040
|
+
const { homedir: nestedHomedir } = await import("node:os");
|
|
1041
|
+
const userRecipesDir = path.join(nestedHomedir(), ".patchwork", "recipes");
|
|
760
1042
|
if (parentSourcePath) {
|
|
761
1043
|
const parentDir = path.dirname(parentSourcePath);
|
|
762
1044
|
const pathLike = path.isAbsolute(name) ||
|
|
@@ -771,15 +1053,24 @@ export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
|
|
|
771
1053
|
const candidates = /\.ya?ml$/i.test(resolvedBase)
|
|
772
1054
|
? [resolvedBase]
|
|
773
1055
|
: [`${resolvedBase}.yaml`, `${resolvedBase}.yml`, resolvedBase];
|
|
1056
|
+
// Jail: every candidate must live inside one of the three allowed
|
|
1057
|
+
// roots (parent dir, user recipes, bundled templates). Reject silently
|
|
1058
|
+
// — null mirrors the existing not-found path so error messages stay
|
|
1059
|
+
// generic and don't leak the jail boundaries.
|
|
1060
|
+
const allowedRoots = [parentDir, userRecipesDir, BUNDLED_TEMPLATES_DIR];
|
|
774
1061
|
for (const candidate of candidates) {
|
|
1062
|
+
const inJail = allowedRoots.some((root) => pathIsWithin(candidate, root));
|
|
1063
|
+
if (!inJail)
|
|
1064
|
+
continue;
|
|
775
1065
|
const loaded = tryLoadRecipeFile(candidate);
|
|
776
1066
|
if (loaded)
|
|
777
1067
|
return loaded;
|
|
778
1068
|
}
|
|
779
1069
|
}
|
|
780
1070
|
}
|
|
781
|
-
|
|
782
|
-
|
|
1071
|
+
// END A-PR2 EDIT BLOCK
|
|
1072
|
+
// Reuses `userRecipesDir` already resolved above for the jail check.
|
|
1073
|
+
const recipesDir = userRecipesDir;
|
|
783
1074
|
// Check for manifest-based package directory first.
|
|
784
1075
|
// Supports both plain names ("morning-brief") and scoped names ("@acme/morning-brief").
|
|
785
1076
|
const pkgDirCandidates = [
|
|
@@ -841,13 +1132,21 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
|
|
|
841
1132
|
onStepStart: deps.chainedOptions?.onStepStart,
|
|
842
1133
|
onStepComplete: deps.chainedOptions?.onStepComplete,
|
|
843
1134
|
runLogDir: deps.chainedOptions?.runLogDir,
|
|
1135
|
+
runLog: deps.chainedOptions?.runLog,
|
|
1136
|
+
activityLog: deps.chainedOptions?.activityLog,
|
|
1137
|
+
mockedOutputs: deps.chainedOptions?.mockedOutputs,
|
|
1138
|
+
taskIdPrefix: deps.chainedOptions?.taskIdPrefix,
|
|
844
1139
|
};
|
|
845
1140
|
if (!deps.chainedDeps) {
|
|
846
1141
|
throw new Error("chainedDeps required for chained recipes (provide executeTool, executeAgent, loadNestedRecipe)");
|
|
847
1142
|
}
|
|
848
1143
|
return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
|
|
849
1144
|
}
|
|
850
|
-
|
|
1145
|
+
// For non-chained recipes, lift `runLog` from chainedOptions onto the
|
|
1146
|
+
// RunnerDeps so runYamlRecipe gets the bridge's singleton too.
|
|
1147
|
+
return runYamlRecipe(recipe, deps.chainedOptions?.runLog
|
|
1148
|
+
? { ...deps, runLog: deps.chainedOptions.runLog }
|
|
1149
|
+
: deps, seedContext);
|
|
851
1150
|
}
|
|
852
1151
|
/** List all YAML recipes in a directory. Returns names. */
|
|
853
1152
|
export function listYamlRecipes(recipesDir) {
|