feat(scheduler): ScriptRunner through the process spawn policy

Spec: on Windows the command goes through Arguments like the bash tool, since cmd.exe doesn't parse ArgumentList quoting; the workspace is created if missing, and a timeout vs cancellation outcome is spelled out.
This commit is contained in:
EugeneTes
2026-09-27 10:14:33 +02:00
parent 1cc60267f9
commit 51bc0cedf1
3 changed files with 434 additions and 4 deletions

View File

@@ -0,0 +1,184 @@
using System.Collections;
using System.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
/// <summary>Real processes (Unix only, like the other script tests) with a real ProcessSpawnPolicy over an injected environment.</summary>
public sealed class ScriptRunnerTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("scriptrunner-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>The real environment (so the shell starts) plus host secrets the default deny-list must remove.</summary>
private static Dictionary<string, string?> Snapshot()
{
var env = new Dictionary<string, string?>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value;
env.Remove("TZ");
env["TELEGRAM_BOT_TOKEN"] = "123:bot-token";
env["OPENAI_API_KEY"] = "sk-test-key";
return env;
}
private ScriptRunner Runner(Action<ProcessEnvironmentOptions>? configure = null, SchedulerOptions? options = null)
{
var env = Snapshot();
var processOptions = new ProcessEnvironmentOptions { HardenHostProcess = false };
configure?.Invoke(processOptions);
return new ScriptRunner(options ?? new SchedulerOptions(), new ProcessSpawnPolicy(processOptions, null, () => env), NullLogger<ScriptRunner>.Instance);
}
private ScriptRequest Request(string command, TimeSpan? timeout = null, string? zone = null, string? cwd = null) =>
new(command, cwd ?? Path.Combine(_root, "workspace"), timeout ?? TimeSpan.FromSeconds(30), "111", "w8r4t", zone);
[Fact]
public async Task Output_is_trimmed_stdout()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(Request("printf ' price 48\\n\\n'"), CancellationToken.None);
Assert.Equal(new ScriptOutcome.Output("price 48"), outcome);
}
[Fact]
public async Task Empty_output_is_silent()
{
if (OperatingSystem.IsWindows()) return;
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("true"), CancellationToken.None));
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("printf ' \\n'; echo noise >&2"), CancellationToken.None));
}
[Fact]
public async Task Non_zero_exit_is_a_failure_with_the_last_20_stderr_lines()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(Request("for i in $(seq 1 30); do echo line$i >&2; done; exit 3"), CancellationToken.None);
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
Assert.Equal(3, failed.ExitCode);
Assert.False(failed.TimedOut);
Assert.Equal(Enumerable.Range(11, 20).Select(i => $"line{i}"), failed.StderrTail.Split('\n'));
}
[Fact]
public async Task Timeout_kills_the_process_tree()
{
if (OperatingSystem.IsWindows()) return;
var pidFile = Path.Combine(_root, "child.pid");
var stopwatch = Stopwatch.StartNew();
var outcome = await Runner().RunAsync(Request($"sleep 30 & echo $! > '{pidFile}'; wait", TimeSpan.FromSeconds(1)), CancellationToken.None);
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
Assert.True(failed.TimedOut);
Assert.Null(failed.ExitCode);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), $"took {stopwatch.Elapsed}");
var child = int.Parse(File.ReadAllText(pidFile).Trim(), System.Globalization.CultureInfo.InvariantCulture);
await Task.Delay(200);
Assert.True(IsGone(child), "the background sleep must be killed with the tree");
}
private static bool IsGone(int pid)
{
try { return Process.GetProcessById(pid).HasExited; }
catch (ArgumentException) { return true; }
}
[Fact]
public async Task Cancellation_kills_the_script_and_throws()
{
if (OperatingSystem.IsWindows()) return;
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
var stopwatch = Stopwatch.StartNew();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Runner().RunAsync(Request("sleep 30"), cts.Token));
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10));
}
[Fact]
public async Task Capture_is_capped_to_head_and_tail()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(Request("head -c 200000 /dev/zero | tr '\\0' a; printf END"), CancellationToken.None);
var text = Assert.IsType<ScriptOutcome.Output>(outcome).Text;
Assert.StartsWith(new string('a', 100), text);
Assert.EndsWith("aEND", text);
Assert.Contains("characters omitted", text);
Assert.True(text.Length < OutputCapture.HeadChars + OutputCapture.TailChars + 200);
}
[Fact]
public async Task Host_secrets_are_absent_and_TZ_is_the_target_zone()
{
if (OperatingSystem.IsWindows()) return;
var outcome = await Runner().RunAsync(
Request("printf '%s|%s|%s' \"${TELEGRAM_BOT_TOKEN-unset}\" \"${OPENAI_API_KEY-unset}\" \"$TZ\"", zone: "Asia/Tokyo"),
CancellationToken.None);
Assert.Equal(new ScriptOutcome.Output("unset|unset|Asia/Tokyo"), outcome);
}
[Fact]
public async Task A_throwing_host_hook_fails_without_starting_a_process()
{
var marker = Path.Combine(_root, "workspace", "started");
var runner = Runner(o => o.Hooks.Add(_ => throw new InvalidOperationException("nope")));
var outcome = await runner.RunAsync(Request(OperatingSystem.IsWindows() ? $"type nul > \"{marker}\"" : $"touch '{marker}'"), CancellationToken.None);
Assert.Equal(new ScriptOutcome.Failed(null, "process environment hook failed: nope", false), outcome);
Assert.False(File.Exists(marker));
}
[Fact]
public async Task Cwd_is_the_workspace()
{
if (OperatingSystem.IsWindows()) return;
var cwd = Path.Combine(_root, "not-yet-created");
var outcome = await Runner().RunAsync(Request("basename \"$(pwd)\"", cwd: cwd), CancellationToken.None);
Assert.Equal(new ScriptOutcome.Output("not-yet-created"), outcome);
}
[Fact]
public async Task A_shell_that_fails_to_start_is_a_failure()
{
var shell = Path.Combine(_root, "no-such-shell");
var outcome = await Runner(options: new SchedulerOptions { Shell = [shell, "-c"] }).RunAsync(Request("true"), CancellationToken.None);
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
Assert.StartsWith($"failed to start {shell}: ", failed.StderrTail);
Assert.Null(failed.ExitCode);
}
}
public sealed class OutputCaptureTests
{
[Fact]
public void Keeps_the_head_and_tail_and_counts_the_rest()
{
var capture = new OutputCapture();
capture.Append(new string('h', OutputCapture.HeadChars));
capture.Append(new string('x', 1000));
capture.Append(new string('t', OutputCapture.TailChars));
var text = capture.ToString();
Assert.StartsWith(new string('h', OutputCapture.HeadChars), text);
Assert.EndsWith(new string('t', OutputCapture.TailChars), text);
Assert.Contains("[… 1000 characters omitted …]", text);
}
[Fact]
public void Short_output_is_kept_whole()
{
var capture = new OutputCapture();
capture.Append("one\n");
capture.Append("two");
Assert.Equal("one\ntwo", capture.ToString());
}
}