// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Tools.Shell.UnitTests; /// /// Tests for the side-effect-free argv builders on . /// These don't require a Docker daemon to run. /// public sealed class DockerShellExecutorTests { [Fact] public void BuildRunArgv_EmitsRestrictiveDefaults() { var argv = DockerShellExecutor.BuildRunArgv( binary: "docker", image: "alpine:3.19", containerName: "af-shell-test", user: ContainerUser.Default, network: "none", memoryBytes: 256L * 1024 * 1024, pidsLimit: 64, workdir: "/workspace", hostWorkdir: null, mountReadonly: true, readOnlyRoot: true, extraEnv: null, extraArgs: null); Assert.Equal("docker", argv[0]); Assert.Equal("run", argv[1]); Assert.Contains("-d", argv); Assert.Contains("--rm", argv); Assert.Contains("--network", argv); Assert.Contains("none", argv); Assert.Contains("--cap-drop", argv); Assert.Contains("ALL", argv); Assert.Contains("--security-opt", argv); Assert.Contains("no-new-privileges", argv); Assert.Contains("--read-only", argv); Assert.Contains("--tmpfs", argv); // Image, then sleep infinity at the end. Assert.Equal("alpine:3.19", argv[argv.Count - 3]); Assert.Equal("sleep", argv[argv.Count - 2]); Assert.Equal("infinity", argv[argv.Count - 1]); } [Fact] public void BuildRunArgv_HostWorkdir_AddsVolumeMount() { var argv = DockerShellExecutor.BuildRunArgv( binary: "docker", image: "alpine:3.19", containerName: "af-shell-test", user: new ContainerUser("1000", "1000"), network: "none", memoryBytes: 256L * 1024 * 1024, pidsLimit: 64, workdir: "/workspace", hostWorkdir: "/tmp/proj", mountReadonly: false, readOnlyRoot: false, extraEnv: null, extraArgs: null); var idx = argv.ToList().IndexOf("-v"); Assert.True(idx >= 0, "expected -v flag"); Assert.Equal("/tmp/proj:/workspace:rw", argv[idx + 1]); Assert.DoesNotContain("--read-only", argv); } [Fact] public void BuildRunArgv_HostWorkdir_DefaultsToReadonly() { var argv = DockerShellExecutor.BuildRunArgv( binary: "docker", image: "alpine:3.19", containerName: "x", user: new ContainerUser("1000", "1000"), network: "none", memoryBytes: 256L * 1024 * 1024, pidsLimit: 64, workdir: "/workspace", hostWorkdir: "/host/path", mountReadonly: true, readOnlyRoot: true, extraEnv: null, extraArgs: null); var list = argv.ToList(); var idx = list.IndexOf("-v"); Assert.Equal("/host/path:/workspace:ro", argv[idx + 1]); } [Fact] public void BuildRunArgv_EnvAndExtraArgs_AreAppended() { var env = new Dictionary { ["LOG"] = "1", ["MODE"] = "ci" }; var extra = new[] { "--label", "owner=test" }; var argv = DockerShellExecutor.BuildRunArgv( binary: "docker", image: "alpine:3.19", containerName: "x", user: new ContainerUser("1000", "1000"), network: "none", memoryBytes: 256L * 1024 * 1024, pidsLimit: 64, workdir: "/workspace", hostWorkdir: null, mountReadonly: true, readOnlyRoot: true, extraEnv: env, extraArgs: extra); var list = argv.ToList(); Assert.Contains("LOG=1", list); Assert.Contains("MODE=ci", list); Assert.Contains("--label", list); Assert.Contains("owner=test", list); } private static readonly string[] s_expectedInteractive = new[] { "docker", "exec", "-i", "af-shell-x", "bash", "--noprofile", "--norc" }; [Fact] public void BuildExecArgv_EmitsBashNoProfileNoRc() { var argv = DockerShellExecutor.BuildExecArgv("docker", "af-shell-x"); Assert.Equal(s_expectedInteractive, argv); } [Fact] public async Task Ctor_GeneratesUniqueContainerNameAsync() { await using var t1 = new DockerShellExecutor(new() { Mode = ShellMode.Stateless }); await using var t2 = new DockerShellExecutor(new() { Mode = ShellMode.Stateless }); Assert.StartsWith("af-shell-", t1.ContainerName, StringComparison.Ordinal); Assert.StartsWith("af-shell-", t2.ContainerName, StringComparison.Ordinal); Assert.NotEqual(t1.ContainerName, t2.ContainerName); } [Fact] public async Task Ctor_RespectsExplicitContainerNameAsync() { await using var t = new DockerShellExecutor(new() { ContainerName = "my-explicit-name", Mode = ShellMode.Stateless }); Assert.Equal("my-explicit-name", t.ContainerName); } [Fact] public async Task ShellExecutor_DockerShellTool_ImplementsInterfaceAsync() { await using var t = new DockerShellExecutor(new() { Mode = ShellMode.Stateless }); ShellExecutor executor = t; Assert.NotNull(executor); } [Fact] public async Task AsAIFunction_DefaultRequireApproval_IsApprovalGatedAsync() { // requireApproval defaults to null, which now always wraps in // ApprovalRequiredAIFunction — container configuration alone is // not a sufficient signal to safely auto-execute model-generated // commands, so the caller must explicitly opt out. await using var t = new DockerShellExecutor(new() { Mode = ShellMode.Stateless }); var fn = t.AsAIFunction(); Assert.IsType(fn); Assert.Equal("run_shell", fn.Name); } [Fact] public async Task AsAIFunction_OptInApproval_WrapsInApprovalRequiredAsync() { await using var t = new DockerShellExecutor(new() { Mode = ShellMode.Stateless }); var fn = t.AsAIFunction(requireApproval: true); Assert.IsType(fn); } [Fact] public async Task AsAIFunction_ExplicitOptOut_IsNotApprovalGatedAsync() { await using var t = new DockerShellExecutor(new() { Mode = ShellMode.Stateless, Network = "host", }); var fn = t.AsAIFunction(requireApproval: false); Assert.IsNotType(fn); } [Fact] public async Task IsAvailableAsync_NonExistentBinary_ReturnsFalseAsync() { var ok = await DockerShellExecutor.IsAvailableAsync(binary: "definitely-not-a-real-binary-xyz123"); Assert.False(ok); } [Fact] public async Task RunAsync_RejectedCommand_ThrowsShellCommandRejectedAsync() { // Pure policy path: the policy check runs before any docker invocation, // so this exercises rejection without needing a Docker daemon. await using var t = new DockerShellExecutor(new() { Mode = ShellMode.Stateless, Policy = new ShellPolicy(denyList: [@"\brm\s+-rf?\s+[\/]"]), }); await Assert.ThrowsAsync( () => t.RunAsync("rm -rf /")); } }