Skip to content

path and workdir for workflows#488

Merged
khaliqgant merged 7 commits intomainfrom
project-spec
Mar 10, 2026
Merged

path and workdir for workflows#488
khaliqgant merged 7 commits intomainfrom
project-spec

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Mar 5, 2026

Summary

Test Plan

  • Tests added/updated
  • Manual testing completed

Screenshots


Open with Devin

@khaliqgant khaliqgant requested a review from willwashburn as a code owner March 5, 2026 01:27
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@xkonjin xkonjin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick review pass:

  • Main risk area here is connection lifecycle, retry behavior, and state re-initialization.
  • I didn’t see targeted regression coverage in the diff; please add or point CI at a focused test for the changed path in init.py, types.py, schema.json (+1 more).
  • Before merge, I’d smoke-test the behavior touched by init.py, types.py, schema.json (+1 more) with malformed input / retry / rollback cases, since that’s where this class of change usually breaks.

The types and schema for `paths` (top-level) and `workdir` (agents/steps)
were added in 75337dd but had no runtime implementation. This adds:

- Path resolution with env var expansion ($HOME, $VAR)
- Preflight validation in dryRun() (required/optional, duplicate names)
- workdir→cwd mapping for agent steps (both PTY and non-interactive)
- workdir→cwd mapping for deterministic steps
- Step-level workdir override for agent steps
- Includes real E2E test workflow for cross-repo validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…resume

Fixes two Devin review findings:
1. workdir missing from AgentWorkflowStep, DeterministicWorkflowStep,
   WorktreeWorkflowStep in schema.json (additionalProperties: false
   would reject workdir on steps)
2. resume() never called resolvePathDefinitions(), so workdir lookups
   would fail on resumed workflow runs
devin-ai-integration[bot]

This comment was marked as resolved.

executeWorktreeStep now uses resolveStepWorkdir() like deterministic
and agent steps, so worktree operations happen in the correct repo
when workdir references a named path.
// Build the git worktree command
// If createBranch is true and branch doesn't exist, use -b flag
const absoluteWorktreePath = path.resolve(this.cwd, worktreePath);
const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This path concatenation which depends on
library input
is later used in a
shell command
.
This path concatenation which depends on
library input
is later used in a
shell command
.

Copilot Autofix

AI about 2 months ago

General approach: avoid passing dynamic, potentially untrusted strings to a shell interpreter. Instead of building full command lines and executing them via sh -c, call git directly with an argument array using cpSpawn('git', ['worktree', 'add', ...]). This ensures Node passes the arguments directly to git without an intermediate shell, eliminating shell metacharacter interpretation and fixing all CodeQL variants in one place.

Concrete fix in this code:

  1. Replace the checkBranchCmd string and its execution via cpSpawn('sh', ['-c', checkBranchCmd], ...) with a direct git invocation:

    • Use cpSpawn('git', ['rev-parse', '--verify', '--quiet', branch], { cwd: stepCwd, env: ... }).
    • Keep the same branchExists boolean logic based on exit code.
  2. Replace construction of worktreeCmd as a string and the subsequent cpSpawn('sh', ['-c', worktreeCmd], ...) with array-form git calls:

    • Build a const worktreeArgs: string[]:
      • If branchExists: ['worktree', 'add', absoluteWorktreePath, branch].
      • Else if createBranch: ['worktree', 'add', '-b', branch, absoluteWorktreePath, baseBranch].
    • Spawn via cpSpawn('git', worktreeArgs, { cwd: stepCwd, env: ... }).
    • Preserve stdout/stderr collection, abort handling, and exit-code checking logic unchanged.
  3. Because we already import spawn as cpSpawn from node:child_process, no new imports are required. We are only changing how we call cpSpawn, not its signature.

No changes are required in builder.ts or run.ts; hardening the sink (executeWorktreeStep in runner.ts) resolves the entire taint path.

Suggested changeset 1
packages/sdk/src/workflows/runner.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/sdk/src/workflows/runner.ts b/packages/sdk/src/workflows/runner.ts
--- a/packages/sdk/src/workflows/runner.ts
+++ b/packages/sdk/src/workflows/runner.ts
@@ -2048,12 +2048,11 @@
       // If createBranch is true and branch doesn't exist, use -b flag
       const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
 
-      // First, check if the branch already exists
-      const checkBranchCmd = `git rev-parse --verify --quiet ${branch} 2>/dev/null`;
+      // First, check if the branch already exists using a direct git invocation
       let branchExists = false;
 
       await new Promise<void>((resolve) => {
-        const checkChild = cpSpawn('sh', ['-c', checkBranchCmd], {
+        const checkChild = cpSpawn('git', ['rev-parse', '--verify', '--quiet', branch], {
           stdio: 'pipe',
           cwd: stepCwd,
           env: { ...process.env },
@@ -2065,14 +2061,14 @@
         checkChild.on('error', () => resolve());
       });
 
-      // Build appropriate worktree add command
-      let worktreeCmd: string;
+      // Build appropriate worktree add command arguments
+      let worktreeArgs: string[];
       if (branchExists) {
         // Branch exists, just checkout into worktree
-        worktreeCmd = `git worktree add "${absoluteWorktreePath}" ${branch}`;
+        worktreeArgs = ['worktree', 'add', absoluteWorktreePath, branch];
       } else if (createBranch) {
         // Create new branch from baseBranch
-        worktreeCmd = `git worktree add -b ${branch} "${absoluteWorktreePath}" ${baseBranch}`;
+        worktreeArgs = ['worktree', 'add', '-b', branch, absoluteWorktreePath, baseBranch];
       } else {
         // Branch doesn't exist and we're not creating it
         const errorMsg = `Branch "${branch}" does not exist and createBranch is false`;
@@ -2081,7 +2073,7 @@
       }
 
       const output = await new Promise<string>((resolve, reject) => {
-        const child = cpSpawn('sh', ['-c', worktreeCmd], {
+        const child = cpSpawn('git', worktreeArgs, {
           stdio: 'pipe',
           cwd: stepCwd,
           env: { ...process.env },
EOF
@@ -2048,12 +2048,11 @@
// If createBranch is true and branch doesn't exist, use -b flag
const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);

// First, check if the branch already exists
const checkBranchCmd = `git rev-parse --verify --quiet ${branch} 2>/dev/null`;
// First, check if the branch already exists using a direct git invocation
let branchExists = false;

await new Promise<void>((resolve) => {
const checkChild = cpSpawn('sh', ['-c', checkBranchCmd], {
const checkChild = cpSpawn('git', ['rev-parse', '--verify', '--quiet', branch], {
stdio: 'pipe',
cwd: stepCwd,
env: { ...process.env },
@@ -2065,14 +2061,14 @@
checkChild.on('error', () => resolve());
});

// Build appropriate worktree add command
let worktreeCmd: string;
// Build appropriate worktree add command arguments
let worktreeArgs: string[];
if (branchExists) {
// Branch exists, just checkout into worktree
worktreeCmd = `git worktree add "${absoluteWorktreePath}" ${branch}`;
worktreeArgs = ['worktree', 'add', absoluteWorktreePath, branch];
} else if (createBranch) {
// Create new branch from baseBranch
worktreeCmd = `git worktree add -b ${branch} "${absoluteWorktreePath}" ${baseBranch}`;
worktreeArgs = ['worktree', 'add', '-b', branch, absoluteWorktreePath, baseBranch];
} else {
// Branch doesn't exist and we're not creating it
const errorMsg = `Branch "${branch}" does not exist and createBranch is false`;
@@ -2081,7 +2073,7 @@
}

const output = await new Promise<string>((resolve, reject) => {
const child = cpSpawn('sh', ['-c', worktreeCmd], {
const child = cpSpawn('git', worktreeArgs, {
stdio: 'pipe',
cwd: stepCwd,
env: { ...process.env },
Copilot is powered by AI and may make mistakes. Always verify output.
// Build the git worktree command
// If createBranch is true and branch doesn't exist, use -b flag
const absoluteWorktreePath = path.resolve(this.cwd, worktreePath);
const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This path concatenation which depends on
library input
is later used in a
shell command
.
This path concatenation which depends on library input is later used in a
shell command
.

Copilot Autofix

AI about 2 months ago

In general, unsafe shell command construction should be fixed by avoiding sh -c with string interpolation and instead passing arguments as an array to spawn/execFile, or—if a shell is necessary—by rigorously escaping any interpolated data using a trustworthy quoting library such as shell-quote. Here, the git worktree operation does not require shell features like pipes or redirections, so the best fix is to call git directly and pass branch and path as separate arguments.

Concretely, in executeWorktreeStep we should:

  • Stop building a worktreeCmd string that embeds absoluteWorktreePath.
  • Replace cpSpawn('sh', ['-c', worktreeCmd], …) with a direct cpSpawn('git', ['worktree', 'add', …], …) call, where absoluteWorktreePath and branch are passed as separate array elements.
  • Keep the surrounding promise/stream handling unchanged so behavior and logging remain the same.
  • We do not need new imports because cpSpawn is already imported and we don’t introduce new libraries.

The change is localized to the region where worktreeCmd is used: lines around 2046–2089, ensuring we don’t alter other behavior like workdir selection or environment handling.

Suggested changeset 1
packages/sdk/src/workflows/runner.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/sdk/src/workflows/runner.ts b/packages/sdk/src/workflows/runner.ts
--- a/packages/sdk/src/workflows/runner.ts
+++ b/packages/sdk/src/workflows/runner.ts
@@ -2081,7 +2081,16 @@
       }
 
       const output = await new Promise<string>((resolve, reject) => {
-        const child = cpSpawn('sh', ['-c', worktreeCmd], {
+        const args = ['worktree', 'add'];
+        if (createBranch && !branchExists) {
+          args.push('-b', branch);
+        }
+        args.push(absoluteWorktreePath);
+        if (!createBranch || branchExists) {
+          args.push(branch);
+        }
+
+        const child = cpSpawn('git', args, {
           stdio: 'pipe',
           cwd: stepCwd,
           env: { ...process.env },
EOF
@@ -2081,7 +2081,16 @@
}

const output = await new Promise<string>((resolve, reject) => {
const child = cpSpawn('sh', ['-c', worktreeCmd], {
const args = ['worktree', 'add'];
if (createBranch && !branchExists) {
args.push('-b', branch);
}
args.push(absoluteWorktreePath);
if (!createBranch || branchExists) {
args.push(branch);
}

const child = cpSpawn('git', args, {
stdio: 'pipe',
cwd: stepCwd,
env: { ...process.env },
Copilot is powered by AI and may make mistakes. Always verify output.
@khaliqgant khaliqgant merged commit 2063110 into main Mar 10, 2026
31 of 32 checks passed
@khaliqgant khaliqgant deleted the project-spec branch March 10, 2026 09:37
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 9 additional findings in Devin Review.

Open in Devin Review

Comment on lines 4 to 6
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "RelayYamlConfig",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 schema.json is invalid JSON due to plain-text lines prepended before the JSON object

Lines 1-3 of packages/sdk/src/workflows/schema.json contain plain-text comments (Added workdir to AgentWorkflowStep, etc.) before the JSON body starts on line 4. This makes the entire file unparseable as JSON, which breaks any consumer that tries to load this schema — including editor autocompletion, JSON Schema validators, and the example at packages/sdk/src/examples/workflow-superiority.ts:41 that references this file.

Suggested change
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "RelayYamlConfig",
{
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants