summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Agent.Core.Tests/Agent.Core.Tests.csproj16
-rw-r--r--tests/Agent.Core.Tests/BaselineTests.cs10
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs66
-rw-r--r--tests/Agent.Core.Tests/FixedPromptTests.cs14
-rw-r--r--tests/Agent.Core.Tests/InternalToolTests.cs88
-rw-r--r--tests/Agent.Core.Tests/LocaleCatalogTests.cs54
-rw-r--r--tests/Agent.Core.Tests/McpHttpTests.cs88
-rw-r--r--tests/Agent.Core.Tests/McpStdioTests.cs157
-rw-r--r--tests/Agent.Core.Tests/OllamaClientTests.cs143
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs220
-rw-r--r--tests/Agent.Core.Tests/TaskRegistryTests.cs35
-rw-r--r--tests/Agent.Core.Tests/TestDirectory.cs21
-rw-r--r--tests/Agent.Core.Tests/Usings.cs1
-rw-r--r--tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj14
-rw-r--r--tests/Agent.Planner.Tests/BaselineTests.cs10
-rw-r--r--tests/Agent.Planner.Tests/TaskGraphTests.cs80
-rw-r--r--tests/Agent.Planner.Tests/Usings.cs1
-rw-r--r--tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj15
-rw-r--r--tests/Agent.Windows.Tests/BaselineTests.cs10
-rw-r--r--tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs89
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs579
-rw-r--r--tests/Agent.Windows.Tests/Usings.cs1
22 files changed, 1712 insertions, 0 deletions
diff --git a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj
new file mode 100644
index 0000000..1326733
--- /dev/null
+++ b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj
@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Agent.Core\Agent.Core.csproj" />
+ <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit.v3" />
+ </ItemGroup>
+</Project>
diff --git a/tests/Agent.Core.Tests/BaselineTests.cs b/tests/Agent.Core.Tests/BaselineTests.cs
new file mode 100644
index 0000000..a9b8fb7
--- /dev/null
+++ b/tests/Agent.Core.Tests/BaselineTests.cs
@@ -0,0 +1,10 @@
+namespace Agent.Core.Tests;
+
+public sealed class BaselineTests
+{
+ [Fact]
+ public void CoreAssemblyLoads()
+ {
+ Assert.Equal("Agent.Core", typeof(Core.AssemblyMarker).Assembly.GetName().Name);
+ }
+}
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs
new file mode 100644
index 0000000..7ef4afb
--- /dev/null
+++ b/tests/Agent.Core.Tests/ConfigurationTests.cs
@@ -0,0 +1,66 @@
+using Agent.App.Cli;
+using Agent.App.Configuration;
+
+namespace Agent.Core.Tests;
+
+public sealed class ConfigurationTests
+{
+ [Fact]
+ public void ValidYamlLoadsTypedConfiguration()
+ {
+ using var directory = new TestDirectory();
+ string config = CreateValidConfiguration(directory);
+ AgentConfiguration result = ConfigurationLoader.Load(config);
+ Assert.Equal(1, result.SchemaVersion);
+ Assert.Equal("model", result.Ollama.Model);
+ Assert.Equal("deny", result.Sandbox.Network.Mode);
+ }
+
+ [Fact]
+ public void UnknownYamlPropertyIsFatal()
+ {
+ using var directory = new TestDirectory();
+ string config = CreateValidConfiguration(directory);
+ System.IO.File.AppendAllText(config, "unknown_root: true\n");
+ Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config));
+ }
+
+ [Fact]
+ public void InvalidSchemaIsFatal()
+ {
+ using var directory = new TestDirectory();
+ string config = CreateValidConfiguration(directory);
+ System.IO.File.WriteAllText(config, System.IO.File.ReadAllText(config).Replace("schema_version: 1", "schema_version: 2", StringComparison.Ordinal));
+ Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config));
+ }
+
+ [Fact]
+ public void CommandLineLanguageOverrideIsCaptured()
+ {
+ CommandLine result = CommandLine.Parse(["serve", "--lang", "ja", "--transport", "http"]);
+ Assert.Equal("ja", result.Language);
+ Assert.Equal("http", result.Transport);
+ }
+
+ private static string CreateValidConfiguration(TestDirectory directory)
+ {
+ directory.File("prompts/user-system.md", string.Empty);
+ Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "config/locales"));
+ Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills"));
+ return directory.File("agent.yaml", """
+ schema_version: 1
+ application:
+ language: en
+ agent:
+ user_system_prompt: prompts/user-system.md
+ ollama:
+ endpoint: http://127.0.0.1:11434
+ model: model
+ localization:
+ directory: config/locales
+ fallback: en
+ skills:
+ directory: skills
+ """);
+ }
+}
diff --git a/tests/Agent.Core.Tests/FixedPromptTests.cs b/tests/Agent.Core.Tests/FixedPromptTests.cs
new file mode 100644
index 0000000..93ec7be
--- /dev/null
+++ b/tests/Agent.Core.Tests/FixedPromptTests.cs
@@ -0,0 +1,14 @@
+using Agent.Core.Prompts;
+
+namespace Agent.Core.Tests;
+
+public sealed class FixedPromptTests
+{
+ [Fact]
+ public void FixedPromptIsEmbeddedAndNonEmpty()
+ {
+ string prompt = FixedPrompt.Load();
+ Assert.Contains("SandboxPolicy", prompt, StringComparison.Ordinal);
+ Assert.DoesNotContain("user-system.md", prompt, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs
new file mode 100644
index 0000000..1e241ce
--- /dev/null
+++ b/tests/Agent.Core.Tests/InternalToolTests.cs
@@ -0,0 +1,88 @@
+using System.Text.Json;
+using Agent.Core.Tools;
+
+namespace Agent.Core.Tests;
+
+public sealed class InternalToolTests
+{
+ [Fact]
+ public void Catalog_exposes_exactly_five_strict_tool_groups()
+ {
+ Assert.Equal(["file_search", "file_read", "file_edit", "process_run", "task_update"], InternalToolCatalog.All.Select(x => x.Name));
+ foreach (InternalToolDefinition tool in InternalToolCatalog.All) Assert.False(tool.Parameters.GetProperty("additionalProperties").GetBoolean());
+ }
+
+ [Theory]
+ [InlineData("file_read", "{}")]
+ [InlineData("file_read", "{\"path\":\"a\",\"mode\":\"whole\",\"unknown\":1}")]
+ [InlineData("file_read", "{\"path\":\"a\",\"mode\":\"invalid\"}")]
+ [InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")]
+ [InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")]
+ [InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")]
+ public void Malformed_arguments_are_rejected(string tool, string json)
+ {
+ using JsonDocument document = JsonDocument.Parse(json);
+ Assert.Throws<ToolValidationException>(() => InternalToolValidator.Validate(tool, document.RootElement));
+ }
+
+ [Fact]
+ public async Task Policy_deny_has_no_backend_side_effect()
+ {
+ FakeInternalToolBackend backend = new();
+ InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(), backend);
+ using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"changed"}""");
+
+ ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "file_edit", document.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.False(result.ExternalEffect);
+ Assert.Empty(backend.Calls);
+ }
+
+ [Fact]
+ public async Task Allowed_call_reaches_fake_backend_only_after_validation_and_policy()
+ {
+ FakeInternalToolBackend backend = new();
+ InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(new HashSet<string>(["file_read"], StringComparer.Ordinal)), backend);
+ using JsonDocument document = JsonDocument.Parse("""{"path":"x","mode":"whole"}""");
+
+ ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "file_read", document.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Single(backend.Calls);
+ }
+
+ [Fact]
+ public void Guard_requires_effect_and_fails_on_fifth_identical_failure()
+ {
+ ToolUseGuard guard = new();
+ for (int attempt = 1; attempt < 5; attempt++) Assert.Equal(ToolUseGuardDecision.Retry, guard.Evaluate(true, false, false, "same").Decision);
+ ToolUseGuardResult fifth = guard.Evaluate(true, false, false, "same");
+ Assert.Equal(ToolUseGuardDecision.Fail, fifth.Decision);
+ Assert.Equal(5, fifth.IdenticalFailureCount);
+ }
+
+ [Fact]
+ public void Guard_does_not_force_tools_for_pure_analysis()
+ {
+ Assert.Equal(ToolUseGuardDecision.Complete, new ToolUseGuard().Evaluate(false, false, false).Decision);
+ }
+
+ [Fact]
+ public void Task_update_mutates_the_real_deterministic_graph_without_external_effect_evidence()
+ {
+ Guid scope = Guid.NewGuid();
+ Guid node = Guid.NewGuid();
+ var handler = new TaskGraphToolHandler();
+ handler.BeginTask(scope);
+ using JsonDocument add = JsonDocument.Parse($$"""{"operation":"add","task_id":"{{node}}","description":"Build fixture","depends_on":[],"budget_class":"1K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30}""");
+
+ ToolExecutionResult result = handler.Execute(scope, add.RootElement);
+
+ Assert.True(result.Success);
+ Assert.False(result.ExternalEffect);
+ Assert.Contains(node.ToString(), result.Output, StringComparison.OrdinalIgnoreCase);
+ handler.EndTask(scope);
+ Assert.Throws<InvalidOperationException>(() => handler.Execute(scope, add.RootElement));
+ }
+}
diff --git a/tests/Agent.Core.Tests/LocaleCatalogTests.cs b/tests/Agent.Core.Tests/LocaleCatalogTests.cs
new file mode 100644
index 0000000..e5b6085
--- /dev/null
+++ b/tests/Agent.Core.Tests/LocaleCatalogTests.cs
@@ -0,0 +1,54 @@
+using Agent.Core.Localization;
+
+namespace Agent.Core.Tests;
+
+public sealed class LocaleCatalogTests
+{
+ [Fact]
+ public void MissingSelectedKeyFallsBackToEnglish()
+ {
+ using var directory = new TestDirectory();
+ directory.File("en.json", """{"hello":"Hello {name}","only_en":"fallback"}""");
+ directory.File("ja.json", """{"hello":"こんにちは {name}"}""");
+ LocaleCatalog catalog = LocaleCatalog.Load(directory.Path, "ja");
+ Assert.Equal("fallback", catalog.Get("only_en"));
+ Assert.Equal("こんにちは Codex", catalog.Format("hello", new Dictionary<string, object?> { ["name"] = "Codex" }));
+ }
+
+ [Fact]
+ public void AdditionalLocaleLoadsWithoutCodeChange()
+ {
+ using var directory = new TestDirectory();
+ directory.File("en.json", """{"hello":"Hello"}""");
+ directory.File("zh-CN.json", """{"hello":"你好"}""");
+ Assert.Equal("你好", LocaleCatalog.Load(directory.Path, "zh-CN").Get("hello"));
+ }
+
+ [Theory]
+ [InlineData("{broken")]
+ [InlineData("[]")]
+ public void MalformedLocaleIsFatal(string json)
+ {
+ using var directory = new TestDirectory();
+ directory.File("en.json", json);
+ Assert.Throws<LocaleException>(() => LocaleCatalog.Load(directory.Path, "en"));
+ }
+
+ [Fact]
+ public void UnknownKeyIsFatal()
+ {
+ using var directory = new TestDirectory();
+ directory.File("en.json", """{"known":"value"}""");
+ directory.File("ja.json", """{"known":"値","unknown":"x"}""");
+ Assert.Throws<LocaleException>(() => LocaleCatalog.Load(directory.Path, "ja"));
+ }
+
+ [Fact]
+ public void PlaceholderMismatchIsFatal()
+ {
+ using var directory = new TestDirectory();
+ directory.File("en.json", """{"hello":"Hello {name}"}""");
+ directory.File("ja.json", """{"hello":"こんにちは {user}"}""");
+ Assert.Throws<LocaleException>(() => LocaleCatalog.Load(directory.Path, "ja"));
+ }
+}
diff --git a/tests/Agent.Core.Tests/McpHttpTests.cs b/tests/Agent.Core.Tests/McpHttpTests.cs
new file mode 100644
index 0000000..7e3f1cf
--- /dev/null
+++ b/tests/Agent.Core.Tests/McpHttpTests.cs
@@ -0,0 +1,88 @@
+using System.Net;
+using System.Net.Sockets;
+using Agent.Core.Localization;
+using Agent.Core.Ollama;
+using Agent.Core.Runtime;
+using Agent.Mcp;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+
+namespace Agent.Core.Tests;
+
+public sealed class McpHttpTests
+{
+ [Fact(Timeout = 30_000)]
+ public async Task Loopback_streamable_http_works_and_get_sse_legacy_sse_are_rejected()
+ {
+ int port = FreePort();
+ using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en");
+ var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>());
+ Task server = McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "127.0.0.1", port, stop.Token);
+ using var http = new HttpClient();
+ Uri endpoint = new($"http://127.0.0.1:{port}/mcp");
+ await WaitUntilListening(http, endpoint, TestContext.Current.CancellationToken);
+
+ var transport = new HttpClientTransport(new HttpClientTransportOptions
+ {
+ Name = "http-test",
+ Endpoint = endpoint,
+ TransportMode = HttpTransportMode.StreamableHttp
+ });
+ await using (McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken))
+ {
+ IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal));
+ }
+
+ using HttpResponseMessage get = await http.GetAsync(endpoint, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode);
+ using var hostileOrigin = new HttpRequestMessage(HttpMethod.Post, endpoint);
+ hostileOrigin.Headers.Add("Origin", "https://example.invalid");
+ using HttpResponseMessage forbidden = await http.SendAsync(hostileOrigin, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
+ using var loopbackOrigin = new HttpRequestMessage(HttpMethod.Get, endpoint);
+ loopbackOrigin.Headers.Add("Origin", $"http://127.0.0.1:{port}");
+ using HttpResponseMessage loopbackAllowed = await http.SendAsync(loopbackOrigin, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, loopbackAllowed.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync(new Uri($"http://127.0.0.1:{port}/sse"), TestContext.Current.CancellationToken)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.PostAsync(new Uri($"http://127.0.0.1:{port}/message"), null, TestContext.Current.CancellationToken)).StatusCode);
+
+ await stop.CancelAsync();
+ await server;
+ }
+
+ [Fact]
+ public async Task Non_loopback_bind_is_rejected_before_server_start()
+ {
+ LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en");
+ var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>());
+ await Assert.ThrowsAsync<ArgumentException>(() => McpHost.RunHttpAsync(locale, settings,
+ new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "0.0.0.0", 8080, TestContext.Current.CancellationToken));
+ }
+
+ private static int FreePort()
+ {
+ TcpListener listener = new(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private static async Task WaitUntilListening(HttpClient client, Uri endpoint, CancellationToken cancellationToken)
+ {
+ for (int attempt = 0; attempt < 100; attempt++)
+ {
+ try
+ {
+ using HttpResponseMessage response = await client.GetAsync(endpoint, cancellationToken);
+ return;
+ }
+ catch (HttpRequestException) when (attempt < 99)
+ {
+ await Task.Delay(25, cancellationToken);
+ }
+ }
+ }
+}
diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs
new file mode 100644
index 0000000..66f4851
--- /dev/null
+++ b/tests/Agent.Core.Tests/McpStdioTests.cs
@@ -0,0 +1,157 @@
+using Agent.App.Configuration;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace Agent.Core.Tests;
+
+public sealed class McpStdioTests
+{
+ [Fact(Timeout = 30_000)]
+ public async Task InitializeListAndCallExposeOnlyTwoStrictTools()
+ {
+ using var directory = new TestDirectory();
+ await using var ollama = new FakeOllamaServer();
+ string configPath = CreateBundleConfiguration(directory, ollama.Endpoint);
+ string application = typeof(ConfigurationLoader).Assembly.Location;
+ var transport = new StdioClientTransport(new StdioClientTransportOptions
+ {
+ Name = "agent-mcp-test",
+ Command = "dotnet",
+ Arguments = [application, "serve", "--config", configPath, "--lang", "en"],
+ WorkingDirectory = directory.Path
+ });
+
+ await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken);
+ IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal));
+ foreach (McpClientTool tool in tools)
+ {
+ Assert.False(tool.JsonSchema.GetProperty("additionalProperties").GetBoolean());
+ }
+
+ CallToolResult run = await client.CallToolAsync(
+ "agent_run",
+ new Dictionary<string, object?> { ["request"] = "deterministic test" },
+ cancellationToken: TestContext.Current.CancellationToken);
+ string text = Assert.IsType<TextContentBlock>(Assert.Single(run.Content)).Text;
+ Assert.Contains("status: queued", text, StringComparison.Ordinal);
+ Guid taskId = ReadGuid(text, "task_id");
+ Guid sessionId = ReadGuid(text, "session_id");
+
+ CallToolResult status = await client.CallToolAsync(
+ "agent_status",
+ new Dictionary<string, object?> { ["task_id"] = taskId, ["session_id"] = sessionId },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Contains("task_id:", Assert.IsType<TextContentBlock>(Assert.Single(status.Content)).Text, StringComparison.Ordinal);
+
+ CallToolResult invalid = await client.CallToolAsync(
+ "agent_run",
+ new Dictionary<string, object?> { ["request"] = "x", ["model"] = "forbidden" },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.True(invalid.IsError);
+
+ CallToolResult invalidImage = await client.CallToolAsync(
+ "agent_run",
+ new Dictionary<string, object?>
+ {
+ ["request"] = "x",
+ ["images"] = new[] { new Dictionary<string, object?> { ["mime_type"] = "image/gif", ["data_base64"] = "AA==" } }
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.True(invalidImage.IsError);
+ }
+
+ private static Guid ReadGuid(string text, string key)
+ {
+ string line = text.Split('\n', StringSplitOptions.TrimEntries).Single(value => value.StartsWith(key + ":", StringComparison.Ordinal));
+ return Guid.Parse(line[(key.Length + 1)..].Trim());
+ }
+
+ private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint)
+ {
+ directory.File("prompts/user-system.md", string.Empty);
+ directory.File("config/locales/en.json", """{"app.starting":"Starting","check.success":"OK","check.failure":"Failed {message}","check.wfp_admin_required":"Administrator required","config.invalid":"Invalid {message}","locale.invalid":"Locale {message}","mcp.task_queued":"Task {task} queued in {session}","mcp.task_not_found":"Task {task} not found","sandbox.access_denied":"Denied {path}","ollama.vision_unsupported":"No vision"}""");
+ Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills"));
+ return directory.File("agent.yaml", $$"""
+ schema_version: 1
+ application:
+ language: en
+ mcp:
+ transport: stdio
+ agent:
+ user_system_prompt: prompts/user-system.md
+ ollama:
+ endpoint: {{endpoint}}
+ model: model
+ localization:
+ directory: config/locales
+ fallback: en
+ skills:
+ directory: skills
+ logging:
+ directory: logs
+ """);
+ }
+
+ private sealed class FakeOllamaServer : IAsyncDisposable
+ {
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly CancellationTokenSource _stop = new();
+ private readonly Task _loop;
+
+ public FakeOllamaServer()
+ {
+ _listener.Start();
+ Endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/");
+ _loop = RunAsync();
+ }
+
+ public Uri Endpoint { get; }
+
+ private async Task RunAsync()
+ {
+ try
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token);
+ await HandleAsync(client, _stop.Token);
+ }
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
+ }
+
+ private static async Task HandleAsync(TcpClient client, CancellationToken cancellationToken)
+ {
+ NetworkStream stream = client.GetStream();
+ using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true);
+ int contentLength = 0;
+ while (await reader.ReadLineAsync(cancellationToken) is { } line && line.Length > 0)
+ {
+ if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
+ contentLength = int.Parse(line[15..].Trim(), System.Globalization.CultureInfo.InvariantCulture);
+ }
+ if (contentLength > 0)
+ {
+ char[] body = new char[contentLength];
+ await reader.ReadBlockAsync(body.AsMemory(), cancellationToken);
+ }
+ byte[] payload = Encoding.UTF8.GetBytes("""{"message":{"role":"assistant","content":"done"},"prompt_eval_count":3,"eval_count":2}""");
+ byte[] header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {payload.Length}\r\nConnection: close\r\n\r\n");
+ await stream.WriteAsync(header, cancellationToken);
+ await stream.WriteAsync(payload, cancellationToken);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _stop.CancelAsync();
+ _listener.Stop();
+ await _loop;
+ _stop.Dispose();
+ }
+ }
+}
diff --git a/tests/Agent.Core.Tests/OllamaClientTests.cs b/tests/Agent.Core.Tests/OllamaClientTests.cs
new file mode 100644
index 0000000..aa98764
--- /dev/null
+++ b/tests/Agent.Core.Tests/OllamaClientTests.cs
@@ -0,0 +1,143 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Agent.App.Configuration;
+using Agent.Core.Ollama;
+using Agent.Core.Tools;
+
+namespace Agent.Core.Tests;
+
+public sealed class OllamaClientTests
+{
+ [Fact]
+ public async Task Chat_sends_expected_request_and_returns_usage()
+ {
+ string? body = null;
+ var handler = new StubHandler(async (request, _) =>
+ {
+ body = await request.Content!.ReadAsStringAsync();
+ return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"done"},"prompt_eval_count":12,"eval_count":7}""");
+ });
+ OllamaClient client = Create(handler, options: new Dictionary<string, JsonElement>
+ {
+ ["temperature"] = JsonSerializer.SerializeToElement(0.2),
+ ["seed"] = JsonSerializer.SerializeToElement(42)
+ });
+
+ OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], 0.7, TestContext.Current.CancellationToken);
+
+ Assert.Equal("done", result.Content);
+ Assert.Equal(12, result.InputTokens);
+ Assert.Equal(7, result.OutputTokens);
+ using JsonDocument document = JsonDocument.Parse(body!);
+ JsonElement root = document.RootElement;
+ Assert.Equal("test-model", root.GetProperty("model").GetString());
+ Assert.False(root.GetProperty("stream").GetBoolean());
+ Assert.Equal(0.7, root.GetProperty("options").GetProperty("temperature").GetDouble());
+ Assert.Equal(42, root.GetProperty("options").GetProperty("seed").GetInt32());
+ }
+
+ [Fact]
+ public async Task Transient_failure_retries_three_times_with_configured_backoff()
+ {
+ int attempts = 0;
+ var delays = new List<TimeSpan>();
+ var handler = new StubHandler((_, _) =>
+ {
+ attempts++;
+ return Task.FromResult(attempts < 4
+ ? Json(HttpStatusCode.ServiceUnavailable, "busy")
+ : Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"ok"}}"""));
+ });
+ var client = new OllamaClient(new HttpClient(handler), Settings(), (delay, _) =>
+ {
+ delays.Add(delay);
+ return Task.CompletedTask;
+ });
+
+ OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken);
+
+ Assert.Equal("ok", result.Content);
+ Assert.Equal(4, attempts);
+ Assert.Equal([TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(1000)], delays);
+ }
+
+ [Fact]
+ public async Task Permanent_failure_is_not_retried()
+ {
+ int attempts = 0;
+ var handler = new StubHandler((_, _) =>
+ {
+ attempts++;
+ return Task.FromResult(Json(HttpStatusCode.BadRequest, "invalid"));
+ });
+ OllamaClient client = Create(handler);
+
+ OllamaException error = await Assert.ThrowsAsync<OllamaException>(() =>
+ client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken));
+
+ Assert.False(error.IsTransient);
+ Assert.Equal(1, attempts);
+ }
+
+ [Fact]
+ public void Typed_options_override_extra_options()
+ {
+ var configuration = new OllamaConfiguration
+ {
+ Endpoint = "http://127.0.0.1:11434",
+ Model = "model",
+ Options = new OllamaGenerationOptions { Temperature = 0.4, NumCtx = 8192 },
+ ExtraOptions = new Dictionary<string, object?>
+ {
+ ["temperature"] = 1.9,
+ ["num_ctx"] = 256,
+ ["custom"] = "preserved"
+ }
+ };
+
+ OllamaSettings settings = OllamaSettingsFactory.Create(configuration);
+
+ Assert.Equal(0.4, settings.Options["temperature"].GetDouble());
+ Assert.Equal(8192, settings.Options["num_ctx"].GetInt32());
+ Assert.Equal("preserved", settings.Options["custom"].GetString());
+ }
+
+ [Fact]
+ public async Task Native_tool_catalog_is_serialized_and_tool_call_is_parsed()
+ {
+ string? body = null;
+ var handler = new StubHandler(async (request, _) =>
+ {
+ body = await request.Content!.ReadAsStringAsync();
+ return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"file_read","arguments":{"path":"x","mode":"whole"}}}]},"prompt_eval_count":2,"eval_count":1}""");
+ });
+ OllamaClient client = Create(handler);
+
+ OllamaResult result = await client.ChatWithToolsAsync([new OllamaMessage("user", "read")], null, InternalToolCatalog.All, TestContext.Current.CancellationToken);
+
+ using JsonDocument document = JsonDocument.Parse(body!);
+ Assert.Equal(5, document.RootElement.GetProperty("tools").GetArrayLength());
+ Assert.All(document.RootElement.GetProperty("tools").EnumerateArray(), tool =>
+ Assert.False(tool.GetProperty("function").GetProperty("parameters").GetProperty("additionalProperties").GetBoolean()));
+ OllamaToolCall call = Assert.Single(result.ToolCalls!);
+ Assert.Equal("file_read", call.Function.Name);
+ Assert.Equal("x", call.Function.Arguments.GetProperty("path").GetString());
+ }
+
+ private static OllamaClient Create(HttpMessageHandler handler, IReadOnlyDictionary<string, JsonElement>? options = null) =>
+ new(new HttpClient(handler), Settings(options), static (_, _) => Task.CompletedTask);
+
+ private static OllamaSettings Settings(IReadOnlyDictionary<string, JsonElement>? options = null) =>
+ new(new Uri("http://127.0.0.1:11434"), "test-model", false, "0", options ?? new Dictionary<string, JsonElement>());
+
+ private static HttpResponseMessage Json(HttpStatusCode statusCode, string content) => new(statusCode)
+ {
+ Content = new StringContent(content, Encoding.UTF8, "application/json")
+ };
+
+ private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> callback) : HttpMessageHandler
+ {
+ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => callback(request, cancellationToken);
+ }
+}
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
new file mode 100644
index 0000000..23ad2d2
--- /dev/null
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -0,0 +1,220 @@
+using Agent.Core.Ollama;
+using Agent.Core.Prompts;
+using Agent.Core.Runtime;
+using Agent.Core.Sessions;
+using Agent.Core.Tasks;
+using Agent.Core.Tools;
+using System.Text.Json;
+
+namespace Agent.Core.Tests;
+
+public sealed class PromptAndRuntimeTests
+{
+ [Fact]
+ public void Prompt_order_is_fixed_and_request_is_last()
+ {
+ IReadOnlyList<OllamaMessage> messages = PromptComposer.Compose(new PromptContext(
+ "fixed", "user system", "ja", ["skill"], "capability", "graph", "summary",
+ [new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")],
+ "new request", ["image"]));
+
+ Assert.Equal(["fixed", "user system", "Respond in locale: ja.", "skill", "capability", "graph", "summary", "old", "reply", "new request"], messages.Select(x => x.Content));
+ Assert.Equal("system", messages[0].Role);
+ Assert.Equal("user", messages[^1].Role);
+ Assert.Equal(["image"], messages[^1].Images);
+ }
+
+ [Fact]
+ public async Task Runtime_continues_session_and_accumulates_usage()
+ {
+ var sessions = new SessionStore();
+ var registry = new TaskRegistry(sessions);
+ var ollama = new RecordingOllamaClient();
+ var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("custom", "ja", [], "sandbox"));
+ TaskSnapshot first = registry.Enqueue("first", null, 0.5);
+ Assert.True(registry.Transition(first.TaskId, AgentTaskState.Planning));
+ Assert.True(registry.Transition(first.TaskId, AgentTaskState.Running));
+
+ await runtime.ExecuteAsync(first.TaskId, TestContext.Current.CancellationToken);
+ Assert.True(registry.TryGet(first.TaskId, null, out TaskSnapshot? completed));
+ Assert.True(completed!.State == AgentTaskState.Completed, completed.Result);
+ Assert.Equal(3, completed.InputTokens);
+ Assert.Equal(2, completed.OutputTokens);
+
+ TaskSnapshot second = registry.Enqueue("second", first.SessionId, null);
+ Assert.True(registry.Transition(second.TaskId, AgentTaskState.Planning));
+ Assert.True(registry.Transition(second.TaskId, AgentTaskState.Running));
+ await runtime.ExecuteAsync(second.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, ollama.Calls.Count);
+ Assert.Contains(ollama.Calls[1], message => message.Role == "user" && message.Content == "first");
+ Assert.Contains(ollama.Calls[1], message => message.Role == "assistant" && message.Content == "answer-1");
+ Assert.Equal(0.5, ollama.Temperatures[0]);
+ Assert.Null(ollama.Temperatures[1]);
+ }
+
+ [Fact]
+ public void Session_store_is_process_local()
+ {
+ SessionStore first = new();
+ Guid id = first.Create().SessionId;
+ SessionStore restarted = new();
+
+ Assert.False(restarted.TryGet(id, out _));
+ }
+
+ [Fact]
+ public async Task Non_vision_model_rejects_images_with_selected_locale_message_before_chat()
+ {
+ TaskRegistry registry = new();
+ var ollama = new NonVisionOllamaClient();
+ const string localized = "選択したモデルは画像入力に対応していません。";
+ var runtime = new AgentRuntime(registry, ollama,
+ new AgentRuntimeSettings("", "ja", [], "sandbox", VisionUnsupportedMessage: localized));
+ TaskSnapshot task = registry.Enqueue("画像を確認", null, null, ["base64-image"]);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+ registry.Transition(task.TaskId, AgentTaskState.Running);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed));
+ Assert.Equal(AgentTaskState.Failed, failed!.State);
+ Assert.Equal(localized, failed.Result);
+ Assert.Equal(0, ollama.ChatCalls);
+ }
+
+ [Fact]
+ public async Task Runtime_dispatches_native_tool_call_then_completes_only_after_effect_evidence()
+ {
+ TaskRegistry registry = new();
+ FakeInternalToolBackend backend = new();
+ var dispatcher = new InternalToolDispatcher(
+ new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend);
+ var ollama = new ToolCallingOllamaClient();
+ var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "ja", [], "sandbox"), dispatcher);
+ TaskSnapshot task = registry.Enqueue("fileを修正", null, null);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+ registry.Transition(task.TaskId, AgentTaskState.Running);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed));
+ Assert.True(completed!.State == AgentTaskState.Completed, $"{completed.Result}; backend={backend.Calls.Count}:{string.Join(',', backend.Calls.Select(x => x.Name))}; ollama={ollama.CallCount}");
+ Assert.Equal("修正しました。", completed.Result);
+ Assert.Single(backend.Calls);
+ Assert.Equal("file_edit", backend.Calls[0].Name);
+ Assert.Equal(2, ollama.CallCount);
+ Assert.Equal(5, ollama.ToolCount);
+ }
+
+ [Fact]
+ public async Task Runtime_reaches_terminal_failed_state_when_resource_cleanup_fails()
+ {
+ TaskRegistry registry = new();
+ var backend = new CleanupFailingBackend();
+ var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(), backend);
+ var runtime = new AgentRuntime(registry, new RecordingOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher);
+ TaskSnapshot task = registry.Enqueue("analyze", null, null);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+ registry.Transition(task.TaskId, AgentTaskState.Running);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed));
+ Assert.Equal(AgentTaskState.Failed, failed!.State);
+ Assert.Contains("cleanup failed", failed.Result, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(2, backend.EndCalls);
+ }
+
+ [Fact]
+ public async Task Runtime_cannot_complete_with_an_unresolved_tool_failure()
+ {
+ TaskRegistry registry = new();
+ var dispatcher = new InternalToolDispatcher(
+ new DenyByDefaultPolicy(new HashSet<string>(["process_run"], StringComparer.Ordinal)), new FailingProcessBackend());
+ var runtime = new AgentRuntime(registry, new FailingThenFinalOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox", MaxIterations: 2), dispatcher);
+ TaskSnapshot task = registry.Enqueue("run build", null, null);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+ registry.Transition(task.TaskId, AgentTaskState.Running);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed));
+ Assert.Equal(AgentTaskState.Failed, failed!.State);
+ Assert.Contains("iteration limit", failed.Result, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private sealed class RecordingOllamaClient : IOllamaClient
+ {
+ public List<IReadOnlyList<OllamaMessage>> Calls { get; } = [];
+ public List<double?> Temperatures { get; } = [];
+
+ public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken)
+ {
+ Calls.Add(messages);
+ Temperatures.Add(temperatureOverride);
+ return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2));
+ }
+ }
+
+ private sealed class ToolCallingOllamaClient : IOllamaClient
+ {
+ public int CallCount { get; private set; }
+ public int ToolCount { get; private set; }
+ public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) =>
+ Task.FromResult(new OllamaResult("修正しました。", 1, 1));
+ public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ ToolCount = tools.Count;
+ if (CallCount == 1)
+ {
+ using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"new"}""");
+ return Task.FromResult(new OllamaResult("", 2, 1, [new OllamaToolCall(new OllamaFunctionCall("file_edit", document.RootElement.Clone()))]));
+ }
+ return Task.FromResult(new OllamaResult("修正しました。", 2, 2));
+ }
+ }
+
+ private sealed class NonVisionOllamaClient : IOllamaClient
+ {
+ public int ChatCalls { get; private set; }
+ public Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(false);
+ public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken)
+ {
+ ChatCalls++;
+ return Task.FromResult(new OllamaResult("unexpected", 0, 0));
+ }
+ }
+
+ private sealed class CleanupFailingBackend : IInternalToolBackend
+ {
+ public int EndCalls { get; private set; }
+ public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) =>
+ Task.FromResult(new ToolExecutionResult(true, "unused", false));
+ public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken)
+ {
+ EndCalls++;
+ throw new IOException("cleanup failed");
+ }
+ }
+
+ private sealed class FailingProcessBackend : IInternalToolBackend
+ {
+ public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) =>
+ Task.FromResult(new ToolExecutionResult(false, "exit 1", true, "process:1"));
+ }
+
+ private sealed class FailingThenFinalOllamaClient : IOllamaClient
+ {
+ private int _calls;
+ public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) =>
+ Task.FromResult(new OllamaResult("done", 1, 1));
+ public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
+ {
+ if (_calls++ > 0) return Task.FromResult(new OllamaResult("done", 1, 1));
+ using JsonDocument arguments = JsonDocument.Parse("""{"executable":"x","args":[],"cwd":"."}""");
+ return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("process_run", arguments.RootElement.Clone()))]));
+ }
+ }
+}
diff --git a/tests/Agent.Core.Tests/TaskRegistryTests.cs b/tests/Agent.Core.Tests/TaskRegistryTests.cs
new file mode 100644
index 0000000..33e16ab
--- /dev/null
+++ b/tests/Agent.Core.Tests/TaskRegistryTests.cs
@@ -0,0 +1,35 @@
+using Agent.Core.Tasks;
+
+namespace Agent.Core.Tests;
+
+public sealed class TaskRegistryTests
+{
+ [Fact]
+ public void NewTaskGetsUniqueTaskAndSessionIds()
+ {
+ var registry = new TaskRegistry();
+ TaskSnapshot first = registry.Enqueue("one", null, null);
+ TaskSnapshot second = registry.Enqueue("two", null, 0.2);
+ Assert.NotEqual(first.TaskId, second.TaskId);
+ Assert.NotEqual(first.SessionId, second.SessionId);
+ Assert.Equal(AgentTaskState.Queued, first.State);
+ }
+
+ [Fact]
+ public void UnknownSessionIsRejected()
+ {
+ var registry = new TaskRegistry();
+ Assert.Throws<KeyNotFoundException>(() => registry.Enqueue("request", Guid.NewGuid(), null));
+ }
+
+ [Fact]
+ public void TerminalTaskCannotTransition()
+ {
+ var registry = new TaskRegistry();
+ TaskSnapshot task = registry.Enqueue("request", null, null);
+ Assert.True(registry.Transition(task.TaskId, AgentTaskState.Planning));
+ Assert.True(registry.Transition(task.TaskId, AgentTaskState.Running));
+ Assert.True(registry.Transition(task.TaskId, AgentTaskState.Completed));
+ Assert.False(registry.Transition(task.TaskId, AgentTaskState.Running));
+ }
+}
diff --git a/tests/Agent.Core.Tests/TestDirectory.cs b/tests/Agent.Core.Tests/TestDirectory.cs
new file mode 100644
index 0000000..075d050
--- /dev/null
+++ b/tests/Agent.Core.Tests/TestDirectory.cs
@@ -0,0 +1,21 @@
+namespace Agent.Core.Tests;
+
+internal sealed class TestDirectory : IDisposable
+{
+ public TestDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agent-mcp-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public string Path { get; }
+ public string File(string relativePath, string content)
+ {
+ string path = System.IO.Path.Combine(Path, relativePath);
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
+ System.IO.File.WriteAllText(path, content);
+ return path;
+ }
+
+ public void Dispose() => Directory.Delete(Path, true);
+}
diff --git a/tests/Agent.Core.Tests/Usings.cs b/tests/Agent.Core.Tests/Usings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/Agent.Core.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj b/tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj
new file mode 100644
index 0000000..16264cb
--- /dev/null
+++ b/tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Agent.Planner\Agent.Planner.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit.v3" />
+ </ItemGroup>
+</Project>
diff --git a/tests/Agent.Planner.Tests/BaselineTests.cs b/tests/Agent.Planner.Tests/BaselineTests.cs
new file mode 100644
index 0000000..a459b18
--- /dev/null
+++ b/tests/Agent.Planner.Tests/BaselineTests.cs
@@ -0,0 +1,10 @@
+namespace Agent.Planner.Tests;
+
+public sealed class BaselineTests
+{
+ [Fact]
+ public void PlannerAssemblyLoads()
+ {
+ Assert.Equal("Agent.Planner", typeof(Planner.AssemblyMarker).Assembly.GetName().Name);
+ }
+}
diff --git a/tests/Agent.Planner.Tests/TaskGraphTests.cs b/tests/Agent.Planner.Tests/TaskGraphTests.cs
new file mode 100644
index 0000000..b09cd56
--- /dev/null
+++ b/tests/Agent.Planner.Tests/TaskGraphTests.cs
@@ -0,0 +1,80 @@
+using Agent.Planner;
+
+namespace Agent.Planner.Tests;
+
+public sealed class TaskGraphTests
+{
+ private static readonly TokenEstimate Small = new(60, 60, 60);
+
+ [Fact]
+ public void Known_dag_has_deterministic_critical_path()
+ {
+ TaskGraph graph = new();
+ PlannerTask a = graph.AddTask("A", TokenBudgetClass.K1, new TokenEstimate(60, 60, 60));
+ PlannerTask b = graph.AddTask("B", TokenBudgetClass.K1, new TokenEstimate(120, 120, 120), [a.Id]);
+ _ = graph.AddTask("C", TokenBudgetClass.K1, new TokenEstimate(30, 30, 30), [a.Id]);
+ PlannerTask d = graph.AddTask("D", TokenBudgetClass.K1, new TokenEstimate(60, 60, 60), [b.Id]);
+
+ Assert.Equal([a.Id, b.Id, d.Id], graph.CriticalPath.TaskIds);
+ Assert.Equal(240, graph.CriticalPath.CumulativeTokenCost);
+ }
+
+ [Fact]
+ public void Cycle_is_rejected_without_mutating_graph()
+ {
+ TaskGraph graph = new();
+ PlannerTask a = graph.AddTask("A", TokenBudgetClass.K1, Small);
+ PlannerTask b = graph.AddTask("B", TokenBudgetClass.K1, Small, [a.Id]);
+
+ Assert.Throws<PlannerValidationException>(() => graph.AddDependency(a.Id, b.Id));
+ Assert.Empty(graph.Get(a.Id).Dependencies);
+ }
+
+ [Fact]
+ public void Max_tasks_is_enforced()
+ {
+ TaskGraph graph = new(1);
+ _ = graph.AddTask("A", TokenBudgetClass.K1, Small);
+ Assert.Throws<PlannerValidationException>(() => graph.AddTask("B", TokenBudgetClass.K1, Small));
+ }
+
+ [Theory]
+ [InlineData(0, 1, 1)]
+ [InlineData(2, 1, 2)]
+ [InlineData(1, 3, 2)]
+ [InlineData(1, 2, 2048)]
+ public void Invalid_estimate_is_rejected(long optimistic, long likely, long pessimistic)
+ {
+ TaskGraph graph = new();
+ Assert.Throws<PlannerValidationException>(() => graph.AddTask("bad", TokenBudgetClass.K1, new TokenEstimate(optimistic, likely, pessimistic)));
+ }
+
+ [Fact]
+ public void Completed_task_is_immutable()
+ {
+ TaskGraph graph = new();
+ PlannerTask task = graph.AddTask("A", TokenBudgetClass.K1, Small);
+ graph.SetState(task.Id, PlannerTaskState.Completed);
+
+ Assert.Throws<PlannerValidationException>(() => graph.SetEstimate(task.Id, TokenBudgetClass.K2, new TokenEstimate(1, 2, 3)));
+ Assert.Throws<PlannerValidationException>(() => graph.SetState(task.Id, PlannerTaskState.Failed));
+ Assert.Throws<PlannerValidationException>(() => graph.AddUsage(task.Id, 1, 1));
+ }
+
+ [Fact]
+ public void Graph_change_recalculates_cpm_and_usage_accumulates()
+ {
+ TaskGraph graph = new();
+ PlannerTask a = graph.AddTask("A", TokenBudgetClass.K1, Small);
+ PlannerTask b = graph.AddTask("B", TokenBudgetClass.K1, new TokenEstimate(30, 30, 30));
+ Assert.Equal([a.Id], graph.CriticalPath.TaskIds);
+
+ graph.AddDependency(b.Id, a.Id);
+ graph.AddUsage(b.Id, 10, 5);
+ graph.AddUsage(b.Id, 2, 3);
+
+ Assert.Equal([a.Id, b.Id], graph.CriticalPath.TaskIds);
+ Assert.Equal(20, graph.Get(b.Id).ActualTotalTokens);
+ Assert.Equal(2_048, graph.TotalTokenBudget);
+ }
+}
diff --git a/tests/Agent.Planner.Tests/Usings.cs b/tests/Agent.Planner.Tests/Usings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/Agent.Planner.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
new file mode 100644
index 0000000..0b45401
--- /dev/null
+++ b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit.v3" />
+ </ItemGroup>
+</Project>
diff --git a/tests/Agent.Windows.Tests/BaselineTests.cs b/tests/Agent.Windows.Tests/BaselineTests.cs
new file mode 100644
index 0000000..a4c1cfd
--- /dev/null
+++ b/tests/Agent.Windows.Tests/BaselineTests.cs
@@ -0,0 +1,10 @@
+namespace Agent.Windows.Tests;
+
+public sealed class BaselineTests
+{
+ [Fact]
+ public void WindowsAssemblyLoads()
+ {
+ Assert.Equal("Agent.Windows", typeof(Windows.AssemblyMarker).Assembly.GetName().Name);
+ }
+}
diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
new file mode 100644
index 0000000..7dd3629
--- /dev/null
+++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
@@ -0,0 +1,89 @@
+using System.Runtime.CompilerServices;
+using System.Security.AccessControl;
+using System.Diagnostics;
+using Agent.Windows.Network;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Tests;
+
+internal static class CrashScenarioBootstrap
+{
+ [ModuleInitializer]
+ internal static void RunIfRequested()
+ {
+ string? scenario = Environment.GetEnvironmentVariable("AGENT_MCP_CRASH_SCENARIO");
+ if (string.IsNullOrEmpty(scenario)) return;
+ string root = Environment.GetEnvironmentVariable("AGENT_MCP_CRASH_ROOT")
+ ?? throw new InvalidOperationException("Crash root is required.");
+ Guid taskId = Guid.Parse(Environment.GetEnvironmentVariable("AGENT_MCP_CRASH_TASK")!);
+ try
+ {
+ TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(root, "journals"), taskId);
+ switch (scenario)
+ {
+ case "acl":
+ var acl = new TaskAclManager(AppContainerProfile.EnsureFixed().Sid, new MemorySecurityAuditSink(), journal);
+ _ = acl.Grant(taskId, Path.Combine(root, "resource"), FileCapability.Read | FileCapability.Write);
+ break;
+ case "acl-before":
+ byte[] descriptor = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(Path.Combine(root, "resource")), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ _ = journal.Prepare("acl", Path.Combine(root, "resource"),
+ new Dictionary<string, string> { ["security_descriptor"] = Convert.ToBase64String(descriptor) });
+ break;
+ case "file-before":
+ string temporary = Path.Combine(root, "resource.txt.agent-mcp-prepared.tmp");
+ string backup = Path.Combine(root, "backups", taskId.ToString("N"), "prepared.backup");
+ Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
+ File.WriteAllText(temporary, "temporary");
+ File.WriteAllText(backup, "before-crash");
+ _ = journal.Prepare("file.replace", Path.Combine(root, "resource.txt"), new Dictionary<string, string>
+ {
+ ["existed"] = bool.TrueString, ["original_sha256"] = string.Empty, ["original_base64"] = string.Empty,
+ ["backup_path"] = backup, ["temporary_path"] = temporary
+ });
+ break;
+ case "file-replace":
+ _ = new TransactionalFileEditor(journal, Path.Combine(root, "backups"), 1)
+ .Replace(Path.Combine(root, "resource.txt"), "after-crash");
+ break;
+ case "process-after":
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ string auditPath = Path.Combine(root, "crash-audit.jsonl");
+ var processAudit = new JsonLinesSecurityAuditSink(auditPath);
+ var launcher = new RestrictedProcessLauncher(
+ new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), processAudit);
+ string escapedAudit = auditPath.Replace("'", "''", StringComparison.Ordinal);
+ string crashError = Path.Combine(root, "crash-error.txt").Replace("'", "''", StringComparison.Ordinal);
+ string watchdogScript = $"$d=[DateTime]::UtcNow.AddSeconds(10); while([DateTime]::UtcNow -lt $d){{ if((Test-Path -LiteralPath '{escapedAudit}') -and (Select-String -LiteralPath '{escapedAudit}' -SimpleMatch '\"Event\":\"process.start\"' -Quiet)){{ Stop-Process -Id {Environment.ProcessId} -Force; exit 0 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{crashError}' -Value 'Sandbox process did not start before crash'; Stop-Process -Id {Environment.ProcessId} -Force";
+ _ = Process.Start(new ProcessStartInfo
+ {
+ FileName = powershell, UseShellExecute = false, CreateNoWindow = true,
+ ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", watchdogScript }
+ });
+ _ = launcher.Run(taskId, powershell,
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"],
+ Environment.SystemDirectory, TimeSpan.FromMinutes(1));
+ break;
+ case "cleanup-mid":
+ var cleanupAcl = new TaskAclManager(AppContainerProfile.EnsureFixed().Sid, new MemorySecurityAuditSink(), journal);
+ _ = cleanupAcl.Grant(taskId, Path.Combine(root, "resource"), FileCapability.Read | FileCapability.Write);
+ FileEditTransaction edit = new TransactionalFileEditor(journal, Path.Combine(root, "backups"), 1)
+ .Replace(Path.Combine(root, "resource.txt"), "during-cleanup");
+ edit.Rollback();
+ break;
+ case "wfp":
+ _ = TaskNetworkPolicy.Apply(journal, AppContainerProfile.EnsureFixed().Sid, [], new MemorySecurityAuditSink());
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown crash scenario '{scenario}'.");
+ }
+ }
+ catch (Exception ex)
+ {
+ File.WriteAllText(Path.Combine(root, "crash-error.txt"), ex.ToString());
+ }
+ Environment.FailFast($"Intentional security recovery crash point: {scenario}");
+ }
+}
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
new file mode 100644
index 0000000..d54ab13
--- /dev/null
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -0,0 +1,579 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using System.Security.AccessControl;
+using System.Text;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+using Agent.Windows.Network;
+using Agent.Windows.Tools;
+using Agent.Core.Tools;
+using System.Text.Json;
+
+namespace Agent.Windows.Tests;
+
+public sealed class SecurityPolicyTests
+{
+ [Fact]
+ public void Read_write_and_delete_capabilities_are_independent()
+ {
+ using TestDirectory directory = new();
+ string file = Path.Combine(directory.Path, "file.txt");
+ File.WriteAllText(file, "data");
+ PathPolicy policy = new([new FilePolicyEntry(directory.Path, FileCapability.Read | FileCapability.Write)]);
+
+ Assert.True(policy.IsAllowed(file, FileCapability.Read));
+ Assert.True(policy.IsAllowed(Path.Combine(directory.Path, "new.txt"), FileCapability.Write));
+ Assert.False(policy.IsAllowed(file, FileCapability.Delete));
+ Assert.Throws<SandboxPolicyException>(() => policy.Demand(file, FileCapability.Delete));
+ }
+
+ [Fact]
+ public void Path_outside_approved_root_is_denied()
+ {
+ using TestDirectory approved = new();
+ using TestDirectory outside = new();
+ string secret = Path.Combine(outside.Path, "secret.txt");
+ File.WriteAllText(secret, "secret");
+ PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]);
+
+ Assert.False(policy.IsAllowed(secret, FileCapability.Read));
+ }
+
+ [Fact]
+ public void Reparse_escape_is_rejected()
+ {
+ using TestDirectory approved = new();
+ using TestDirectory outside = new();
+ string link = Path.Combine(approved.Path, "escape");
+ using Process junction = Process.Start(new ProcessStartInfo
+ {
+ FileName = Environment.GetEnvironmentVariable("ComSpec")!,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ ArgumentList = { "/d", "/c", "mklink", "/J", link, outside.Path }
+ })!;
+ junction.WaitForExit();
+ Assert.Equal(0, junction.ExitCode);
+ PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]);
+
+ try
+ {
+ SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read));
+ Assert.Equal("path.reparse", error.Code);
+ }
+ finally
+ {
+ Directory.Delete(link);
+ }
+ }
+
+ [Fact]
+ public void Symlink_chain_escape_is_rejected()
+ {
+ using TestDirectory approved = new();
+ using TestDirectory outside = new();
+ string second = Path.Combine(approved.Path, "second-link");
+ string first = Path.Combine(approved.Path, "first-link");
+ Directory.CreateSymbolicLink(second, outside.Path);
+ Directory.CreateSymbolicLink(first, second);
+ PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]);
+
+ SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() =>
+ policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read));
+
+ Assert.Equal("path.reparse", error.Code);
+ Directory.Delete(first);
+ Directory.Delete(second);
+ }
+
+ [Fact]
+ public void Executable_requires_approved_path_and_hash_and_shell_toggle()
+ {
+ string executable = Environment.ProcessPath!;
+ string hash = ExecutablePolicy.ComputeSha256(executable);
+ Assert.Equal(hash, new ExecutablePolicy([new ExecutableApproval(executable, hash)], true).Verify(executable).Sha256);
+ Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([], true).Verify(executable));
+ string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..];
+ Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(executable, badHash)], true).Verify(executable));
+
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], false).Verify(powershell));
+ }
+
+ [Fact]
+ public void Restricted_parent_cannot_start_unbrokered_descendant()
+ {
+ using TestDirectory directory = new();
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ var audit = new MemorySecurityAuditSink();
+ var launcher = new RestrictedProcessLauncher(
+ new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit);
+ string copied = Path.Combine(directory.Path, "renamed-child.exe");
+ File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, copied);
+ string[] markers = Enumerable.Range(1, 4).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray();
+ string[] commands =
+ [
+ $"$ErrorActionPreference='Stop'; Start-Process -FilePath $env:ComSpec -ArgumentList '/d','/c','echo compromised > \"{markers[0]}\"' -Wait",
+ $"$ErrorActionPreference='Stop'; [Diagnostics.Process]::Start($env:ComSpec, '/d /c echo compromised ^> \"{markers[1]}\"').WaitForExit()",
+ $"$ErrorActionPreference='Stop'; & $env:ComSpec /d /c 'echo compromised > \"{markers[2]}\"'",
+ $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[3]}\"' -Wait"
+ ];
+
+ foreach (string command in commands)
+ {
+ RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), powershell,
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30));
+ Assert.NotEqual(0u, result.ExitCode);
+ }
+
+ Assert.All(markers, marker => Assert.False(File.Exists(marker)));
+ Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["child_process_policy"] == "restricted");
+ Assert.Contains(audit.Events, item => item.Event == "process.exit" && item.Data["exit_code"] != "0");
+ Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "policy.deny" && item.Data["child_process_policy"] == "restricted"));
+ }
+
+ [Fact]
+ public void Job_timeout_terminates_the_entire_sandbox_job_without_orphan()
+ {
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ var audit = new MemorySecurityAuditSink();
+ var launcher = new RestrictedProcessLauncher(
+ new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit);
+
+ Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), powershell,
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"],
+ Environment.SystemDirectory, TimeSpan.FromMilliseconds(250)));
+
+ SecurityAuditEvent timeout = Assert.Single(audit.Events, item => item.Event == "process.timeout");
+ int pid = int.Parse(timeout.Data["pid"], System.Globalization.CultureInfo.InvariantCulture);
+ Assert.Throws<ArgumentException>(() => Process.GetProcessById(pid));
+ }
+
+ [Fact]
+ public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval()
+ {
+ string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe");
+ string managedAssembly = typeof(SecurityPolicyTests).Assembly.Location;
+ var audit = new MemorySecurityAuditSink();
+ var launcher = new RestrictedProcessLauncher(
+ new ExecutablePolicy([new ExecutableApproval(dotnet, ExecutablePolicy.ComputeSha256(dotnet))], true), audit);
+
+ SandboxPolicyException denied = Assert.Throws<SandboxPolicyException>(() => launcher.Run(
+ Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5)));
+
+ Assert.Equal("process.path_denied", denied.Code);
+ Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied");
+ }
+
+ [Fact]
+ public void Fixed_app_container_is_stable_and_acl_is_restored_exactly()
+ {
+ AppContainerProfile first = AppContainerProfile.EnsureFixed();
+ AppContainerProfile second = AppContainerProfile.EnsureFixed();
+ Assert.Equal(AppContainerProfile.FixedName, first.Name);
+ Assert.Equal(first.Sid.Value, second.Sid.Value);
+
+ using TestDirectory directory = new();
+ byte[] before = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ var audit = new MemorySecurityAuditSink();
+ TaskAclManager manager = new(first.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = manager.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write);
+ byte[] during = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ Assert.NotEqual(Convert.ToHexString(before), Convert.ToHexString(during));
+
+ manager.Restore(taskId, snapshot);
+
+ byte[] after = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ Assert.Equal(Convert.ToHexString(before), Convert.ToHexString(after));
+ Assert.Contains(audit.Events, item => item.Event == "acl.apply");
+ Assert.Contains(audit.Events, item => item.Event == "acl.restore");
+ }
+
+ [Fact]
+ public void Brokered_process_runs_in_fixed_app_container_with_task_acl()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string marker = Path.Combine(directory.Path, "appcontainer-write.txt");
+ string commandProcessor = Environment.GetEnvironmentVariable("ComSpec")!;
+ var audit = new MemorySecurityAuditSink();
+ TaskAclManager acl = new(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write);
+ try
+ {
+ var launcher = new RestrictedProcessLauncher(
+ new ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true),
+ audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024));
+
+ RestrictedProcessResult result = launcher.Run(taskId, commandProcessor, ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30));
+
+ Assert.Equal(0u, result.ExitCode);
+ Assert.Equal("APP_OK", File.ReadAllText(marker).Trim());
+ Assert.Contains("CAPTURE_OK", result.StandardOutput, StringComparison.Ordinal);
+ Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["app_container_sid"] == profile.Sid.Value);
+ }
+ finally
+ {
+ acl.Restore(taskId, snapshot);
+ }
+ }
+
+ [Fact]
+ public void File_transaction_commit_rollback_and_hash_guard_leave_no_journal()
+ {
+ using TestDirectory directory = new();
+ string journals = Path.Combine(directory.Path, "journals");
+ string backups = Path.Combine(directory.Path, "backups");
+ string target = Path.Combine(directory.Path, "target.txt");
+ File.WriteAllText(target, "original");
+ TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid());
+ TransactionalFileEditor editor = new(journal, backups, memoryBackupLimitBytes: 1);
+
+ FileEditTransaction rollback = editor.Replace(target, "replacement");
+ Assert.Equal("replacement", File.ReadAllText(target));
+ rollback.Rollback();
+ Assert.Equal("original", File.ReadAllText(target));
+
+ FileEditTransaction commit = editor.Replace(target, "committed");
+ commit.Commit();
+ journal.CompleteAndDelete();
+
+ Assert.Equal("committed", File.ReadAllText(target));
+ Assert.Empty(Directory.EnumerateFiles(journals));
+ Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N"))));
+ }
+
+ [Fact]
+ public void Recovery_rolls_back_applied_file_in_reverse_cleanup_path()
+ {
+ using TestDirectory directory = new();
+ string journals = Path.Combine(directory.Path, "journals");
+ string backups = Path.Combine(directory.Path, "backups");
+ string target = Path.Combine(directory.Path, "target.txt");
+ File.WriteAllText(target, "before-crash");
+ TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid());
+ _ = new TransactionalFileEditor(journal, backups, memoryBackupLimitBytes: 1).Replace(target, "after-crash");
+
+ RecoveryResult result = new RecoveryManager(journals, new MemorySecurityAuditSink()).RecoverAll();
+
+ Assert.True(result.IsClean);
+ Assert.Equal("before-crash", File.ReadAllText(target));
+ Assert.Empty(Directory.EnumerateFiles(journals));
+ Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N"))));
+ }
+
+ [Fact]
+ public void Recovery_after_crash_restores_acl_loopback_and_dynamic_wfp_state()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string journals = Path.Combine(directory.Path, "journals");
+ string resource = Path.Combine(directory.Path, "resource");
+ Directory.CreateDirectory(resource);
+ byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ var audit = new MemorySecurityAuditSink();
+ Guid taskId = Guid.NewGuid();
+ TaskResourceJournal journal = TaskResourceJournal.Create(journals, taskId);
+
+ var acl = new TaskAclManager(profile.Sid, audit, journal);
+ _ = acl.Grant(taskId, resource, FileCapability.Read | FileCapability.Write);
+
+ Assert.False(LoopbackExemption.Contains(profile.Sid));
+ Guid loopbackEntry = journal.Prepare("network.loopback_exemption", profile.Sid.Value,
+ new Dictionary<string, string> { ["originally_present"] = bool.FalseString });
+ Assert.True(LoopbackExemption.Ensure(profile.Sid));
+ journal.MarkApplied(loopbackEntry, new Dictionary<string, string> { ["added"] = bool.TrueString });
+
+ Guid wfpEntry = journal.Prepare("wfp.dynamic", profile.Sid.Value,
+ new Dictionary<string, string> { ["session"] = "dynamic", ["allowances"] = string.Empty });
+ using (WfpDynamicSession crashedSession = WfpDynamicSession.Open())
+ {
+ IReadOnlyList<ulong> filterIds = crashedSession.ApplyDefaultBlock(profile.Sid);
+ journal.MarkApplied(wfpEntry, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', filterIds) });
+ // Closing the engine without RemovePolicy models abrupt process exit:
+ // BFE owns and deletes every object in this dynamic session.
+ }
+
+ RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll();
+
+ Assert.True(recovered.IsClean);
+ Assert.Equal(1, recovered.JournalsRecovered);
+ Assert.Equal(3, recovered.ResourcesRecovered);
+ Assert.False(LoopbackExemption.Contains(profile.Sid));
+ byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.Empty(Directory.EnumerateFiles(journals));
+ }
+
+ [Theory]
+ [InlineData("acl")]
+ [InlineData("acl-before")]
+ [InlineData("file-before")]
+ [InlineData("file-replace")]
+ [InlineData("process-after")]
+ [InlineData("cleanup-mid")]
+ [InlineData("wfp")]
+ public void Independent_process_crash_is_fully_recovered_on_next_start(string scenario)
+ {
+ using TestDirectory directory = new();
+ string resourceDirectory = Path.Combine(directory.Path, "resource");
+ string resourceFile = Path.Combine(directory.Path, "resource.txt");
+ Directory.CreateDirectory(resourceDirectory);
+ File.WriteAllText(resourceFile, "before-crash");
+ byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ Guid taskId = Guid.NewGuid();
+ using var child = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = Environment.ProcessPath!,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ Environment =
+ {
+ ["AGENT_MCP_CRASH_SCENARIO"] = scenario,
+ ["AGENT_MCP_CRASH_ROOT"] = directory.Path,
+ ["AGENT_MCP_CRASH_TASK"] = taskId.ToString("D")
+ }
+ }
+ };
+ Assert.True(child.Start());
+ Assert.True(child.WaitForExit(60_000));
+ Assert.NotEqual(0, child.ExitCode);
+ Assert.False(File.Exists(Path.Combine(directory.Path, "crash-error.txt")),
+ File.Exists(Path.Combine(directory.Path, "crash-error.txt")) ? File.ReadAllText(Path.Combine(directory.Path, "crash-error.txt")) : string.Empty);
+ Assert.Single(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+
+ RecoveryResult recovered = new RecoveryManager(Path.Combine(directory.Path, "journals"), new MemorySecurityAuditSink()).RecoverAll();
+
+ Assert.True(recovered.IsClean, string.Join(Environment.NewLine, recovered.Errors));
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+ Assert.Equal("before-crash", File.ReadAllText(resourceFile));
+ byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.False(LoopbackExemption.Contains(AppContainerProfile.EnsureFixed().Sid));
+ string backups = Path.Combine(directory.Path, "backups");
+ Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any());
+ if (scenario == "process-after")
+ {
+ string auditPath = Path.Combine(directory.Path, "crash-audit.jsonl");
+ using JsonDocument audit = JsonDocument.Parse(File.ReadLines(auditPath).Single(line => line.Contains("process.start", StringComparison.Ordinal)));
+ int sandboxPid = int.Parse(audit.RootElement.GetProperty("Data").GetProperty("pid").GetString()!,
+ System.Globalization.CultureInfo.InvariantCulture);
+ Assert.True(SpinWait.SpinUntil(() => !ProcessExists(sandboxPid), TimeSpan.FromSeconds(5)), $"Orphan sandbox PID {sandboxPid} survived broker crash.");
+ }
+ }
+
+ private static bool ProcessExists(int pid)
+ {
+ try { using Process process = Process.GetProcessById(pid); return !process.HasExited; }
+ catch (ArgumentException) { return false; }
+ }
+
+ [Fact]
+ public void Rollback_refuses_to_overwrite_post_transaction_external_change()
+ {
+ using TestDirectory directory = new();
+ string target = Path.Combine(directory.Path, "target.txt");
+ File.WriteAllText(target, "before");
+ TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "journals"), Guid.NewGuid());
+ FileEditTransaction transaction = new TransactionalFileEditor(journal, Path.Combine(directory.Path, "backups")).Replace(target, "replacement");
+ File.WriteAllText(target, "external-change");
+
+ Assert.Throws<InvalidOperationException>(() => transaction.Rollback());
+
+ File.WriteAllText(target, "replacement");
+ transaction.Rollback();
+ journal.CompleteAndDelete();
+ }
+
+ [Fact]
+ public void Wfp_dynamic_session_adds_and_explicitly_removes_ipv4_ipv6_package_filters()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using WfpDynamicSession session = WfpDynamicSession.Open();
+ session.ProbeWriteTransaction();
+
+ IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(profile.Sid,
+ [new NetworkEndpoint(IPAddress.Loopback, 11434), new NetworkEndpoint(IPAddress.IPv6Loopback, 11434)]);
+
+ Assert.Equal(4, ids.Count);
+ Assert.All(ids, id => Assert.NotEqual(0UL, id));
+ session.RemovePolicy();
+ }
+
+ [Fact]
+ public async Task Windows_backend_applies_file_effect_and_cleans_acl_wfp_temp_and_journal()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string target = Path.Combine(workspace, "target.txt");
+ File.WriteAllText(target, "before");
+ byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument edit = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"overwrite","content":"after"}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", edit.RootElement, TestContext.Current.CancellationToken);
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.ExternalEffect);
+ Assert.Equal("after", File.ReadAllText(target));
+ byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
+ Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+ string backups = Path.Combine(directory.Path, "backups");
+ Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any());
+ Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0");
+ Assert.Contains(audit.Events, item => item.Event == "acl.restore");
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task Wfp_enforces_actual_appcontainer_loopback_deny_allow_and_port_scope()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ string curl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "curl.exe");
+ var audit = new MemorySecurityAuditSink();
+ var launcher = new RestrictedProcessLauncher(
+ new ExecutablePolicy([new ExecutableApproval(curl, ExecutablePolicy.ComputeSha256(curl))], true),
+ audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024));
+ using TestDirectory directory = new();
+
+ bool diagnosticExemptionAdded = LoopbackExemption.Ensure(profile.Sid);
+ try
+ {
+ using var isolationControl = new OneShotHttpServer(IPAddress.Loopback);
+ RestrictedProcessResult isolationResult = Curl(launcher, Guid.NewGuid(), curl, isolationControl.Url, Environment.SystemDirectory);
+ Assert.True(isolationResult.ExitCode == 0u,
+ $"Loopback exemption did not enable AppContainer traffic before WFP was applied. exit {isolationResult.ExitCode}, stderr: {isolationResult.StandardError}");
+ Assert.True(isolationControl.Accepted);
+ }
+ finally
+ {
+ if (diagnosticExemptionAdded) LoopbackExemption.Remove(profile.Sid);
+ }
+
+ using (var deniedServer = new OneShotHttpServer(IPAddress.Loopback))
+ {
+ Guid taskId = Guid.NewGuid();
+ TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "deny-journal"), taskId);
+ using (TaskNetworkPolicy.Apply(journal, profile.Sid, [], audit))
+ {
+ RestrictedProcessResult denied = Curl(launcher, taskId, curl, deniedServer.Url, Environment.SystemDirectory);
+ Assert.NotEqual(0u, denied.ExitCode);
+ Assert.False(deniedServer.Accepted);
+ }
+ journal.CompleteAndDelete();
+ }
+
+ IPAddress allowedAddress = IPAddress.Loopback;
+ using (var controlServer = new OneShotHttpServer(allowedAddress))
+ using (var controlClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) })
+ {
+ string controlBody = await controlClient.GetStringAsync(controlServer.Url, TestContext.Current.CancellationToken);
+ Assert.Equal("WFP_OK", controlBody);
+ Assert.True(controlServer.Accepted);
+ }
+ using var allowedV4 = new OneShotHttpServer(allowedAddress);
+ using var deniedPort = new OneShotHttpServer(allowedAddress);
+ Guid allowTaskId = Guid.NewGuid();
+ TaskResourceJournal allowJournal = TaskResourceJournal.Create(Path.Combine(directory.Path, "allow-journal"), allowTaskId);
+ IReadOnlyList<NetworkEndpoint> allowances =
+ [
+ new(allowedAddress, checked((ushort)allowedV4.Port))
+ ];
+ using (TaskNetworkPolicy.Apply(allowJournal, profile.Sid, allowances, audit))
+ {
+ RestrictedProcessResult v4 = Curl(launcher, allowTaskId, curl, allowedV4.Url, Environment.SystemDirectory);
+ RestrictedProcessResult wrongPort = Curl(launcher, allowTaskId, curl, deniedPort.Url, Environment.SystemDirectory);
+ Assert.True(v4.ExitCode == 0u, $"Allowed AppContainer curl failed with exit {v4.ExitCode}. stderr: {v4.StandardError}");
+ Assert.Contains("WFP_OK", v4.StandardOutput, StringComparison.Ordinal);
+ Assert.True(allowedV4.Accepted);
+ Assert.NotEqual(0u, wrongPort.ExitCode);
+ Assert.False(deniedPort.Accepted);
+ }
+ allowJournal.CompleteAndDelete();
+ Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0");
+ }
+
+ private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) =>
+ launcher.Run(taskId, curl, ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10));
+
+ private sealed class TestDirectory : IDisposable
+ {
+ public TestDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agent-mcp-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+ public string Path { get; }
+ public void Dispose() => Directory.Delete(Path, recursive: true);
+ }
+
+ private sealed class OneShotHttpServer : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly CancellationTokenSource _stop = new();
+ private readonly Task _serve;
+ private int _accepted;
+
+ public OneShotHttpServer(IPAddress address)
+ {
+ _listener = new TcpListener(address, 0);
+ _listener.Start();
+ Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+ string host = address.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString();
+ Url = $"http://{host}:{Port}/";
+ _serve = ServeAsync();
+ }
+
+ public int Port { get; }
+ public string Url { get; }
+ public bool Accepted => Volatile.Read(ref _accepted) != 0;
+
+ private async Task ServeAsync()
+ {
+ try
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token);
+ Interlocked.Exchange(ref _accepted, 1);
+ using NetworkStream stream = client.GetStream();
+ byte[] request = new byte[4096];
+ int received = 0;
+ while (received < request.Length)
+ {
+ int count = await stream.ReadAsync(request.AsMemory(received), _stop.Token);
+ if (count == 0) break;
+ received += count;
+ if (received >= 4 && request.AsSpan(0, received).IndexOf("\r\n\r\n"u8) >= 0) break;
+ }
+ byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nWFP_OK");
+ await stream.WriteAsync(response, _stop.Token);
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) { }
+ }
+
+ public void Dispose()
+ {
+ _stop.Cancel();
+ _listener.Stop();
+ try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { }
+ _stop.Dispose();
+ }
+ }
+}
diff --git a/tests/Agent.Windows.Tests/Usings.cs b/tests/Agent.Windows.Tests/Usings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/Agent.Windows.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;