Skip to content
23 changes: 23 additions & 0 deletions packages/git/src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ import { promisify } from 'util';

const promisifiedExecFile = promisify(execFile);

/**
* Resolve the bash binary path in a platform-aware way.
*
* On Windows, CreateProcess searches the System32 directory BEFORE the PATH
* env var. Bare `spawn('bash', ...)` therefore resolves to
* `C:\Windows\System32\bash.exe` (the WSL launcher), whose bash has broken
* `${VAR}` expansion when invoked in `-c` mode and uses `/mnt/c/` path
* convention instead of `/c/`. Both break workflow bash nodes.
*
* Fix: on Windows, default to the Git Bash absolute path. Overridable via
* ARCHON_BASH_PATH for non-standard Git installs (e.g. user-scope installer
* at %LOCALAPPDATA%\Programs\Git\bin\bash.exe).
*
* See: coleam00/Archon#1326
*/
export function resolveBashPath(): string {
if (process.env.ARCHON_BASH_PATH) return process.env.ARCHON_BASH_PATH;
if (process.platform === 'win32') {
return 'C:\\Program Files\\Git\\bin\\bash.exe';
}
return 'bash';
}

/** Wrapper around child_process.execFile for test mockability */
export async function execFileAsync(
cmd: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type {
export { toRepoPath, toBranchName, toWorktreePath } from './types';

// Process and filesystem wrappers
export { execFileAsync, mkdirAsync } from './exec';
export { execFileAsync, mkdirAsync, resolveBashPath } from './exec';

// Worktree operations
export {
Expand Down
23 changes: 15 additions & 8 deletions packages/workflows/src/dag-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,14 +1233,21 @@ describe('executeDagWorkflow -- bash nodes', () => {
{ ...minimalConfig, envVars: { MY_SECRET: 'abc123' } }
);

expect(execSpy).toHaveBeenCalledWith(
'bash',
['-c', 'echo ok'],
expect.objectContaining({
env: expect.objectContaining({ MY_SECRET: 'abc123' }),
})
);
execSpy.mockRestore();
// Expected bash command is platform-aware: `bash` on Linux/macOS, absolute
// Git Bash path on Windows (per resolveBashPath() — coleam00/Archon#1326).
// Wrap the assertion + mockRestore in try/finally so the spy doesn't leak
// into subsequent tests if the assertion fails.
try {
expect(execSpy).toHaveBeenCalledWith(
git.resolveBashPath(),
['-c', 'echo ok'],
expect.objectContaining({
env: expect.objectContaining({ MY_SECRET: 'abc123' }),
})
);
} finally {
execSpy.mockRestore();
}
});

it('bash node output with shell metacharacters does not inject into downstream bash script', async () => {
Expand Down
25 changes: 17 additions & 8 deletions packages/workflows/src/dag-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import { readFile } from 'fs/promises';
import { isAbsolute, resolve as resolvePath } from 'path';
import { execFileAsync } from '@archon/git';
import { execFileAsync, resolveBashPath } from '@archon/git';
import { discoverScriptsForCwd } from './script-discovery';
import type {
IWorkflowPlatform,
Expand Down Expand Up @@ -1319,8 +1319,9 @@ async function executeBashNode(
...(envVars ?? {}),
};

const bashPath = resolveBashPath();
try {
const { stdout, stderr } = await execFileAsync('bash', ['-c', finalScript], {
const { stdout, stderr } = await execFileAsync(bashPath, ['-c', finalScript], {
cwd,
timeout,
env: subprocessEnv,
Expand Down Expand Up @@ -1373,7 +1374,7 @@ async function executeBashNode(
if (isTimeout) {
errorMsg = `Bash node '${node.id}' timed out after ${String(timeout)}ms`;
} else if (err.message?.includes('ENOENT')) {
errorMsg = `Bash node '${node.id}' failed: bash executable not found in PATH`;
errorMsg = `Bash node '${node.id}' failed: bash executable not found at '${bashPath}'. Set ARCHON_BASH_PATH if Git Bash is installed elsewhere (e.g. user-scope installer at %LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe).`;
} else if (err.message?.includes('EACCES')) {
Comment on lines 1413 to 1415

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.

⚠️ Potential issue | 🟡 Minor

Use platform-aware ENOENT hints and reuse the already-resolved bash path.

Current ENOENT text is Windows-specific on all platforms, and Line 2127 recomputes the path instead of using loopBashPath (can desync diagnostics if env changes).

🔧 Suggested patch
+function getBashResolutionHint(): string {
+  return process.platform === 'win32'
+    ? 'Set ARCHON_BASH_PATH if Git Bash is installed elsewhere (e.g. user-scope installer at %LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe).'
+    : "Ensure 'bash' is installed or set ARCHON_BASH_PATH to a valid bash executable.";
+}
+
 ...
-    } else if (err.message?.includes('ENOENT')) {
-      errorMsg = `Bash node '${node.id}' failed: bash executable not found at '${bashPath}'. Set ARCHON_BASH_PATH if Git Bash is installed elsewhere (e.g. user-scope installer at %LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe).`;
+    } else if (err.message?.includes('ENOENT')) {
+      errorMsg = `Bash node '${node.id}' failed: bash executable not found at '${bashPath}'. ${getBashResolutionHint()}`;
 ...
-          throw new Error(
-            `Loop node '${node.id}' until_bash failed: cannot execute bash at '${resolveBashPath()}' (${bashErr.code}). Set ARCHON_BASH_PATH if Git Bash is installed elsewhere.`
-          );
+          throw new Error(
+            `Loop node '${node.id}' until_bash failed: cannot execute bash at '${loopBashPath}' (${bashErr.code}). ${getBashResolutionHint()}`
+          );

Also applies to: 2126-2128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workflows/src/dag-executor.ts` around lines 1376 - 1378, The ENOENT
branch currently emits a Windows-specific hint and recomputes the bash path;
update it to reuse the already-resolved loopBashPath variable (instead of
recomputing bashPath) and emit a platform-aware message: on Windows mention
setting ARCHON_BASH_PATH and an example Git Bash path, on POSIX mention
installing bash or ensuring it’s on PATH; include the node id (node.id) and the
loopBashPath value in the errorMsg for clear diagnostics.

errorMsg = `Bash node '${node.id}' failed: permission denied (check cwd permissions)`;
} else {
Expand Down Expand Up @@ -2108,18 +2109,26 @@ async function executeLoopNode(
nodeOutputs,
true // escapedForBash
);
await execFileAsync('bash', ['-c', substitutedBash], { cwd });
const loopBashPath = resolveBashPath();
await execFileAsync(loopBashPath, ['-c', substitutedBash], { cwd });
bashComplete = true; // exit 0 = complete
} catch (e) {
const bashErr = e as NodeJS.ErrnoException;
// ENOENT or other system errors are unexpected — log them
if (bashErr.code === 'ENOENT') {
getLog().warn(
// System-level errors (ENOENT/EACCES) mean the bash binary itself is
// unreachable or unexecutable — that's environment breakage, not a
// condition-not-met outcome. Surface immediately so the loop fails
// fast instead of burning iterations against a broken binary.
if (bashErr.code === 'ENOENT' || bashErr.code === 'EACCES') {
getLog().error(
{ err: bashErr, nodeId: node.id, iteration: i },
'loop_node.until_bash_exec_error'
);
throw new Error(
`Loop node '${node.id}' until_bash failed: cannot execute bash at '${resolveBashPath()}' (${bashErr.code}). Set ARCHON_BASH_PATH if Git Bash is installed elsewhere.`
);
}
bashComplete = false; // non-zero exit = not complete
// Non-zero exit from the bash script = condition not met yet, keep looping.
bashComplete = false;
}
}

Expand Down
Loading