diff --git a/Services/Execution/SubprocessScriptExecutor.cs b/Services/Execution/SubprocessScriptExecutor.cs
index 617ac20..e62870d 100644
--- a/Services/Execution/SubprocessScriptExecutor.cs
+++ b/Services/Execution/SubprocessScriptExecutor.cs
@@ -32,9 +32,12 @@ public class SubprocessScriptExecutor : IScriptExecutor
var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation);
var entryPath = Path.Combine(workingDir, invocation.EntryFile);
+ var (executable, args) = ResolveInterpreter(entryPath);
+ args.Add(entryPath);
+
var output = await ProcessRunner.RunAsync(
- _executable,
- new[] { entryPath },
+ executable,
+ args,
workingDir,
ExecutionHelpers.BuildEnvironment(invocation),
invocation.Input,
@@ -50,6 +53,54 @@ public class SubprocessScriptExecutor : IScriptExecutor
}
}
+ ///
+ /// Chooses the interpreter for a shell script. A shell script may declare a
+ /// specific shell in its shebang (e.g. #!/usr/bin/env bash); running it
+ /// under the registered POSIX sh instead would reject bash-only syntax
+ /// such as set -o pipefail. When the shebang names an interpreter that
+ /// is available on PATH we honour it; otherwise (no shebang, unknown
+ /// interpreter) we fall back to the registered default so behaviour is
+ /// unchanged for plain POSIX scripts.
+ ///
+ private (string Executable, List Args) ResolveInterpreter(string entryPath)
+ {
+ var fallback = (_executable, new List());
+ if (!string.Equals(_language, "shell", StringComparison.Ordinal) || !File.Exists(entryPath))
+ return fallback;
+
+ try
+ {
+ string? firstLine;
+ using (var reader = new StreamReader(entryPath))
+ firstLine = reader.ReadLine();
+
+ if (firstLine is null || !firstLine.StartsWith("#!", StringComparison.Ordinal))
+ return fallback;
+
+ var parts = firstLine.Substring(2).Trim()
+ .Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0)
+ return fallback;
+
+ var candidate = parts[0];
+ var extra = parts.Skip(1).ToList();
+
+ // "#!/usr/bin/env bash" -> run "bash" (env resolves it via PATH).
+ if (string.Equals(Path.GetFileName(candidate), "env", StringComparison.Ordinal) && extra.Count > 0)
+ {
+ candidate = extra[0];
+ extra.RemoveAt(0);
+ }
+
+ var resolved = ProcessRunner.FindInPath(candidate);
+ return resolved is null ? fallback : (resolved, extra);
+ }
+ catch (IOException)
+ {
+ return fallback;
+ }
+ }
+
private static int ParseInt(string? text, int fallback)
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
}
diff --git a/appsettings.Development.json b/appsettings.Development.json
new file mode 100644
index 0000000..3214c4c
--- /dev/null
+++ b/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Workflows": {
+ "ChatApiUrl": "http://localhost:5258"
+ }
+}
diff --git a/w4c-workflows-api.Tests/SubprocessScriptExecutorTests.cs b/w4c-workflows-api.Tests/SubprocessScriptExecutorTests.cs
index 8a97fec..e8d7a81 100644
--- a/w4c-workflows-api.Tests/SubprocessScriptExecutorTests.cs
+++ b/w4c-workflows-api.Tests/SubprocessScriptExecutorTests.cs
@@ -82,6 +82,28 @@ public class SubprocessScriptExecutorTests
Assert.Contains("time limit", result.Error);
}
+ [Fact]
+ public async Task Shell_honours_bash_shebang_for_bash_only_scripts()
+ {
+ if (ProcessRunner.FindInPath("bash") is null)
+ return; // no bash on this host — the fallback path is covered elsewhere
+
+ using var dir = new TempDir();
+ dir.Write("task.sh", """
+ #!/usr/bin/env bash
+ set -euo pipefail
+ cat >/dev/null || true
+ echo '{"ok":true}'
+ """);
+
+ var result = await Shell().ExecuteAsync(
+ Invocation.For("shell", "task.sh", "{}", dir.Path), default);
+
+ Assert.True(result.Success, result.Error);
+ using var output = JsonDocument.Parse(result.Output!);
+ Assert.True(output.RootElement.GetProperty("ok").GetBoolean());
+ }
+
[Fact]
public async Task Python_reads_stdin_and_returns_json_output()
{