This commit is contained in:
Vitali sharp8n 2026-09-11 15:06:06 +03:00
parent 150e854f40
commit 9d601791ae
3 changed files with 86 additions and 2 deletions

View file

@ -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
}
}
/// <summary>
/// Chooses the interpreter for a shell script. A shell script may declare a
/// specific shell in its shebang (e.g. <c>#!/usr/bin/env bash</c>); running it
/// under the registered POSIX <c>sh</c> instead would reject bash-only syntax
/// such as <c>set -o pipefail</c>. 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.
/// </summary>
private (string Executable, List<string> Args) ResolveInterpreter(string entryPath)
{
var fallback = (_executable, new List<string>());
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;
}

View file

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Workflows": {
"ChatApiUrl": "http://localhost:5258"
}
}

View file

@ -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()
{