Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AgenticPatterns.Tests/AgenticPatterns.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
<ProjectReference Include="..\EvaluationAndMonitoring.AgentFramework\EvaluationAndMonitoring.AgentFramework.csproj"/>
<ProjectReference Include="..\ExceptionHandlingAndRecovery.AgentFramework\ExceptionHandlingAndRecovery.AgentFramework.csproj"/>
<ProjectReference Include="..\IdempotentToolCalls.AgentFramework\IdempotentToolCalls.AgentFramework.csproj"/>
<ProjectReference Include="..\MCP.AgentFramework\MCP.AgentFramework.csproj"/>
<!-- NOT MCP.SemanticKernel: excluded conservatively, not because it conflicts - McpToolBinding
is mirrored into MCP.SemanticKernel's own namespace, so there is no name collision, and
Program.cs's top-level-statement type is internal and invisible across the assembly
boundary, so referencing it too would add nothing to test. -->
<ProjectReference Include="..\MemoryManagement.AgentFramework\MemoryManagement.AgentFramework.csproj"/>
<ProjectReference Include="..\OrchestratorWorkers.AgentFramework\OrchestratorWorkers.AgentFramework.csproj"/>
<ProjectReference Include="..\PatternExplorer\PatternExplorer.csproj"/>
Expand All @@ -32,6 +37,7 @@
<ProjectReference Include="..\SelfCorrectionLoop.AgentFramework\SelfCorrectionLoop.AgentFramework.csproj"/>
<ProjectReference Include="..\SemanticCaching.AgentFramework\SemanticCaching.AgentFramework.csproj"/>
<ProjectReference Include="..\SkillLearning.AgentFramework\SkillLearning.AgentFramework.csproj"/>
<ProjectReference Include="..\StigmergicCoordination.AgentFramework\StigmergicCoordination.AgentFramework.csproj"/>
<ProjectReference Include="..\TreeOfThoughts\TreeOfThoughts.csproj"/>
<ProjectReference Include="..\ToolAuthorization.AgentFramework\ToolAuthorization.AgentFramework.csproj"/>
<!-- NOT Voting.SemanticKernel: excluded conservatively, not because it conflicts - its
Expand Down
58 changes: 58 additions & 0 deletions AgenticPatterns.Tests/CodeActExecutionTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using CodeAct.AgentFramework.Execution;
using Microsoft.Extensions.AI;
using Shared.Sandbox;
using Xunit;

#pragma warning disable CS0618 // testing the deliberately-[Obsolete] unsafe runner is the point
Expand Down Expand Up @@ -154,6 +156,35 @@ public void HostCannotBeAskedToRunAnythingButTheScript()
Assert.Equal([Options.ContainerImage, "dotnet", "run", "/workspace/script.cs"], Args()[^4..]);
}

// ---- run-directory permissions must not depend on the operator's umask ----

// Directory.CreateDirectory(path, mode)'s mode is a mkdir(2) mode, masked by the
// process umask like any mkdir call - a restrictive umask (e.g. 077, common in CI/hardened
// hosts) silently drops the group/other bits the container's uid 65532 needs to traverse
// the bind mount and read script.cs, and CodeAct fails loudly with "Script failed" inside
// the container. This asserts the ACTUAL mode via File.GetUnixFileMode - regardless of
// whatever umask the test process happens to run under - rather than reasoning about the
// code, matching StigmergicBuildGateTests.CreateWorkspaceDirectoryIsWorldReadableAndTraversable.
[Fact]
public void RunDirectoryIsWorldReadableAndTraversableRegardlessOfUmask()
{
if (OperatingSystem.IsWindows()) return; // UnixFileMode is a no-op there
var runId = Guid.NewGuid().ToString("N");
var path = ContainerCodeRunner.CreateRunDirectory(runId);
try
{
var mode = File.GetUnixFileMode(path);
Assert.True(mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.OtherExecute));
Assert.True(mode.HasFlag(UnixFileMode.GroupRead) && mode.HasFlag(UnixFileMode.GroupExecute));

// The shared parent ("codeact") must be traversable too, or uid 65532 cannot
// even reach the per-run directory beneath it.
var parentMode = File.GetUnixFileMode(Path.GetDirectoryName(path)!);
Assert.True(parentMode.HasFlag(UnixFileMode.OtherRead) && parentMode.HasFlag(UnixFileMode.OtherExecute));
}
finally { Directory.Delete(path, recursive: true); }
}

// ---- output and cancellation lifecycle ----

[Fact]
Expand Down Expand Up @@ -181,4 +212,31 @@ public async Task CallerCancellationIsNotConvertedIntoATimeoutResult()
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
runner.RunAsync("Console.WriteLine();", new CancellationToken(canceled: true)));
}

// ---- cancellation plumbing: the agent-invocation token must reach the runner ----

// Same shape as Program.cs's ExecuteCSharp local function (a trailing CancellationToken
// parameter, not exposed to the model as a JSON-schema argument): proves
// AIFunctionFactory.Create injects the AIFunction invocation's token into that parameter
// for exactly this delegate shape, and that it is the SAME token RunAsync receives.
[Fact]
public async Task ExecuteCSharpToolForwardsTheInvocationTokenToTheRunner()
{
var runner = new RecordingCodeRunner();

async Task<string> ExecuteCSharp(string code, CancellationToken cancellationToken)
{
var execution = await runner.RunAsync(code, cancellationToken);
return execution.StandardOutput;
}

var tool = AIFunctionFactory.Create(ExecuteCSharp, "execute_csharp", "test tool");
using var cts = new CancellationTokenSource();
cts.Cancel();

await tool.InvokeAsync(new AIFunctionArguments { ["code"] = "Console.WriteLine();" }, cts.Token);

Assert.Equal(cts.Token, runner.ReceivedToken);
Assert.True(runner.ReceivedToken.IsCancellationRequested);
}
}
13 changes: 13 additions & 0 deletions AgenticPatterns.Tests/Fakes.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
using CodeAct.AgentFramework.Execution;
using Microsoft.Extensions.AI;

namespace AgenticPatterns.Tests;

/// <summary>Records the token it was invoked with, instead of actually running anything.</summary>
internal sealed class RecordingCodeRunner : IGeneratedCodeRunner
{
public CancellationToken ReceivedToken { get; private set; }

public Task<ExecutionResult> RunAsync(string sourceCode, CancellationToken cancellationToken)
{
ReceivedToken = cancellationToken;
return Task.FromResult(new ExecutionResult(0, "", "", TimedOut: false));
}
}

/// <summary>Returns pre-canned responses in order and counts calls.</summary>
internal sealed class ScriptedChatClient(params ChatResponse[] responses) : IChatClient
{
Expand Down
64 changes: 64 additions & 0 deletions AgenticPatterns.Tests/McpToolBindingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using MCP.AgentFramework;
using Xunit;

namespace AgenticPatterns.Tests;

public class McpToolBindingTests
{
static readonly HashSet<string> Allowed = new(["add", "echo"], StringComparer.Ordinal);

[Fact]
public void OnlyAllowlistedToolsAreBound() =>
Assert.Equal(["add", "echo"],
McpToolBinding.SelectAuthorized(["add", "echo", "printEnv", "sampleLLM"], Allowed).Order());

[Fact]
public void AMissingAllowlistedToolFailsClosed() =>
Assert.Throws<InvalidOperationException>(() => McpToolBinding.SelectAuthorized(["echo"], Allowed));

[Fact]
public void CaseInsensitiveAllowlistUsesTheAllowlistsOwnComparer()
{
var allowed = new HashSet<string>(["Add", "Echo"], StringComparer.OrdinalIgnoreCase);
Assert.Equal(["add", "echo"], McpToolBinding.SelectAuthorized(["add", "echo"], allowed).Order());
}

[Fact]
public void DuplicateDiscoveredNamesAreNotDuplicatedInTheResult() =>
Assert.Equal(["add", "echo"], McpToolBinding.SelectAuthorized(["add", "add", "echo"], Allowed).Order());
}

/// I1: the MCP sample used to pass `User: null` - the only caller-visible opt-out of a
/// SandboxOptions default in the tree - while README.md and PatternExplorer/patterns/MCP.md both
/// promised the "identical" locked-down boundary CodeAct gets, including `--user 65532:65532`.
/// It was non-root only because the image's own `USER mcp` line said so, and MCP.md tells readers
/// to point SandboxOptions.Image at any other sandboxed server. Verified by hand that the pinned
/// server runs fine under the explicit uid, so the boundary enforces it rather than trusting the
/// image - and this pins that so the next change to it is deliberate.
public class McpSandboxOptionsTests
{
[Fact]
public void TheMcpSandboxOptsOutOfNoDefaultAndRunsNonRoot()
{
var options = McpToolBinding.Sandbox();

Assert.Equal("65532:65532", options.User);
Assert.False(options.Network);
Assert.Equal(McpToolBinding.ServerImage, options.Image);
Assert.True(options.Interactive); // the stdio transport needs -i
Assert.NotNull(options.ContainerName);
Assert.Null(options.Mounts); // no host path is visible to the server
Assert.Null(options.Environment); // no host credential reaches it
}

[Fact]
public void TheNonRootUserActuallyReachesDocker()
{
var args = Shared.Sandbox.SandboxRunner.BuildRunArguments(McpToolBinding.Sandbox(), []).ToList();
Assert.Equal("65532:65532", args[args.IndexOf("--user") + 1]);
}

[Fact]
public void EachRunGetsItsOwnContainerNameSoATimeoutKillsTheRightOne() =>
Assert.NotEqual(McpToolBinding.Sandbox().ContainerName, McpToolBinding.Sandbox().ContainerName);
}
152 changes: 152 additions & 0 deletions AgenticPatterns.Tests/RunSessionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using PatternExplorer;
using Xunit;

namespace AgenticPatterns.Tests;

/// Exercises RunSession's registry, lookup and channel behavior directly - never through
/// Start/RunAsync, which would spawn a real `dotnet run` child process.
public class RunSessionTests
{
[Fact]
public void Two_sessions_are_independently_retrievable_and_isolated()
{
var a = new RunSession();
var b = new RunSession();
RunSession.Register(a);
RunSession.Register(b);
try
{
Assert.Same(a, RunSession.TryGet(a.Id, a.Token));
Assert.Same(b, RunSession.TryGet(b.Id, b.Token));

// A valid token, just from the wrong live run, must not unlock this one.
Assert.Null(RunSession.TryGet(a.Id, b.Token));

a.Cancel();

Assert.True(a.IsCancelled);
Assert.False(b.IsCancelled);
}
finally
{
RunSession.Unregister(a);
RunSession.Unregister(b);
}
}

[Fact]
public void TryGet_returns_null_for_wrong_token_or_wrong_id()
{
var session = new RunSession();
RunSession.Register(session);
try
{
Assert.Null(RunSession.TryGet(session.Id, "wrong-token"));
Assert.Null(RunSession.TryGet("wrong-id", session.Token));
}
finally
{
RunSession.Unregister(session);
}
}

[Fact]
public void Register_throws_once_the_live_run_cap_is_reached()
{
var sessions = Enumerable.Range(0, 8).Select(_ => new RunSession()).ToList();
foreach (var s in sessions) RunSession.Register(s);
try
{
var extra = new RunSession();
Assert.Throws<InvalidOperationException>(() => RunSession.Register(extra));
}
finally
{
foreach (var s in sessions) RunSession.Unregister(s);
}
}

[Fact]
public void Bounded_channel_drops_oldest_instead_of_growing_or_blocking()
{
var session = new RunSession();

for (var i = 0; i < 5000; i++)
Assert.True(session.Writer.TryWrite(new Chunk("out", i.ToString())));

var received = new List<Chunk>();
while (session.Reader.TryRead(out var chunk)) received.Add(chunk);

Assert.True(received.Count <= 4096);
Assert.DoesNotContain(received, c => c.T == "0");
Assert.Contains(received, c => c.T == "4999");
}
}

/// 2.5a's whole point: a sample launched from Explorer gets ONLY what it needs, never Explorer's
/// own environment (which holds the operator's credentials for every other tool). Deleting
/// `environment.Clear()` from RunSession leaves every other test in this suite green while every
/// child sample silently regains the lot, so it gets its own assertion here. Goes through
/// ApplyChildEnvironment, not Start - no `dotnet run` is spawned.
public class RunSessionEnvironmentTests
{
const string Secret = "AGENTIC_PATTERNS_TEST_UNRELATED_SECRET";
const string Allowed = "AzureOpenAi__ApiKey";

static void WithVariable(string name, string? value, Action body)
{
var previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
try { body(); }
finally { Environment.SetEnvironmentVariable(name, previous); }
}

[Fact]
public void The_child_gets_the_allowlist_and_dotnet_run_essentials_but_not_the_rest_of_the_host()
{
WithVariable(Secret, "leaked", () => WithVariable(Allowed, "sk-test", () =>
{
// A real ProcessStartInfo, which pre-populates Environment from this process - the
// exact thing Clear() has to undo.
var info = new System.Diagnostics.ProcessStartInfo("dotnet");
Assert.True(info.Environment.ContainsKey(Secret), "precondition: the host variable is inherited");

RunSession.ApplyChildEnvironment(info.Environment, new PatternProject("AgentFramework", "Some.Sample"));

Assert.False(info.Environment.ContainsKey(Secret));
Assert.Equal("sk-test", info.Environment[Allowed]);
Assert.True(info.Environment.ContainsKey("PATH"), "`dotnet run` needs PATH");
}));
}

[Fact]
public void A_variable_outside_the_projects_own_allowlist_is_not_forwarded()
{
WithVariable(Secret, "leaked", () =>
{
var info = new System.Diagnostics.ProcessStartInfo("dotnet");
var project = new PatternProject("AgentFramework", "Some.Sample")
{
EnvironmentAllowlist = [Allowed]
};

RunSession.ApplyChildEnvironment(info.Environment, project);

Assert.False(info.Environment.ContainsKey(Secret));
});
}

[Fact]
public void An_allowlisted_variable_that_is_unset_is_simply_absent_not_empty()
{
WithVariable(Secret, null, () =>
{
var info = new System.Diagnostics.ProcessStartInfo("dotnet");
var project = new PatternProject("AgentFramework", "Some.Sample") { EnvironmentAllowlist = [Secret] };

RunSession.ApplyChildEnvironment(info.Environment, project);

Assert.False(info.Environment.ContainsKey(Secret));
});
}
}
Loading
Loading