Files
microsoft--agent-framework/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs
Eduard van Valkenburg fcc5576b04 .NET: feat(dotnet): Add LocalCodeAct package for local Python execution (#6105)
* feat(dotnet): Add LocalCodeAct package scaffold

Create Microsoft.Agents.AI.LocalCodeAct package with:
- Project file with embedded Python resources
- ExecutionMode enum (Subprocess only)
- ProcessExecutionLimits record
- FileMount record and FileMountMode enum
- README.md documentation
- Embedded Python runner and validator scripts

This is the .NET equivalent of the Python agent-framework-local-codeact
package. Next: Implement process bridge and tool integration.

* feat(dotnet): Add embedded Python runner and validator

Copy Python runner and validator scripts from the Python implementation
as embedded resources for the .NET package.

* feat(dotnet): Add CodeValidator wrapper

Implement CodeValidator.cs that:
- Extracts embedded Python validator script to temp file
- Invokes Python validator with JSON request
- Passes custom allow/block lists
- Throws CodeValidationException on failures
- Cleans up temp files

Uses the embedded Resources/validator.py for AST validation.

* feat(dotnet): Add LocalExecuteCodeFunction

Implement LocalExecuteCodeFunction as AIFunction:
- Accepts Python executable path (required)
- Registers host tools for code to call
- Validates code via CodeValidator if custom lists provided
- Executes via ProcessBridge
- Converts result dict to ChatMessage list
- Builds dynamic description including available tools

Matches Python LocalExecuteCodeTool functionality.

* feat(dotnet): Add LocalCodeActProvider

Implement AIContextProvider that:
- Injects execute_code tool into context
- Adds CodeAct instructions
- Enforces single-provider-per-agent via StateKeys
- Wraps LocalExecuteCodeFunction lifecycle

Minimal provider implementation matching Python LocalCodeActProvider.

* feat(dotnet): Add tests and sample for LocalCodeAct

Add unit tests:
- LocalExecuteCodeFunctionTests (4 tests)
- ProcessExecutionLimitsTests (2 tests)
- FileMountTests (2 tests)

Add sample:
- LocalCodeAct/Program.cs - Demonstrates provider and function usage
- LocalCodeAct/README.md - Documentation and safety warnings

Tests verify basic construction, metadata, and disposal.
Sample shows provider creation, function setup, and configuration.

Note: Build requires .NET 10 SDK per global.json.

* feat(dotnet): Add LocalCodeAct sample project

Add sample demonstrating:
- LocalCodeActProvider creation and configuration
- LocalExecuteCodeFunction direct usage
- Execution modes and file mount configuration
- Safety warnings and prerequisites

Includes project file and README with security guidance.

* feat(dotnet): Add file mount support and integration tests

- Added FileMountHelper.cs for file mount normalization, snapshot, and capture
- Updated LocalExecuteCodeFunction to support file mounts parameter
- Added file snapshot before/after execution with capture logic
- Updated LocalCodeActProvider to pass file mounts through
- Created comprehensive IntegrationTests.cs with 10 test cases:
  - Simple code execution
  - Timeout handling
  - Syntax error handling
  - Blocked import validation
  - Blocked builtin validation
  - Custom allowed imports
  - File mount read/write with capture
  - Stdout capture
  - Provider tool injection

All features from Python implementation now ported to .NET.

* Rewrite .NET LocalCodeAct to address all PR review comments

Complete rewrite that follows the Hyperlight package conventions
(see Microsoft.Agents.AI.Hyperlight) and addresses all 24 review
comments on PR #6105:

Architectural fixes:
* LocalCodeActProvider now uses options-class constructor pattern
  matching HyperlightCodeActProvider.
* Override of ProvideAIContextAsync uses the correct
  (InvokingContext, CancellationToken) signature returning
  ValueTask<AIContext>.
* ExecuteCodeFunction follows the AIFunction Name/Description/JsonSchema
  property pattern with InvokeCoreAsync override.
* Provider exposes AddTools/GetTools/RemoveTools/ClearTools and
  AddFileMounts/GetFileMounts/RemoveFileMounts/ClearFileMounts CRUD
  methods, with snapshot-at-invocation semantics under a lock.

Runtime/security fixes:
* Subprocess IPC uses JsonObject/JsonNode end-to-end (no
  Dictionary<string, object?> casts that broke under JsonElement
  deserialization).
* Validator runs in its own subprocess with a dedicated timeout
  (ProcessExecutionLimits.ValidationTimeoutSeconds), never reuses
  the runner script.
* Validation enabled by default; can be opt-ed out via
  ValidationEnabled = false.
* validator.py has a __main__ entrypoint that reads JSON from
  stdin and exits with structured errors.
* validator.py is now compatible with Python 3.9+ (Match nodes
  added conditionally).
* call_id parsed as long to match Python id(kwargs) range.

Other:
* README rewritten with valid C# syntax (options-class, FileMount
  constructor) and accurate descriptions of validator and file
  capture behavior.
* Added integration tests that exercise the real subprocess and
  validator (skipped gracefully when python3 is not on PATH).
* All 18 tests pass (15 unit + 3 integration) across net8/net9/net10.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sync embedded validator.py with Python package allow-list enforcement

The embedded Python validator script used by the .NET LocalCodeAct
package now enforces the builtin allow-list, matching the latest
behavior of agent_framework_local_codeact._validator. Names that are
real Python builtins must appear in the allow-list, while unknown names
(user-defined functions, registered tools) remain allowed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Hosted-LocalCodeAct foundry hosted-agent sample

Mirrors the Python foundry_hosted_agent.py sample for the local-codeact
package: registers compute and fetch_data as sandbox-only host tools on
LocalCodeActProvider so the model only sees execute_code and reaches them
via await call_tool(...). Includes the standard hosted-agent supporting
files (agent.yaml, agent.manifest.yaml, Dockerfile, Dockerfile.contributor,
.env.example, README.md) and installs python3 in the container images so
the embedded runner and validator can execute.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(local-codeact-dotnet): sync validator os.* allow-list with Python

Mirror the Python package change: the embedded validator.py invoked by the
.NET ProcessBridge replaces the os.* deny-list with an allow-list of
{environ, path}. Add allowed_os_attrs parameter to validate_code and
_CodeValidator, and surface it via the stdin JSON request schema so the
.NET host can opt in to a broader allow-list when needed.

Default behavior tightens to match the documented contract: any os.*
attribute outside {environ, path} (for example os.listdir, os.open,
os.getcwd) is rejected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(local-codeact-dotnet): address review + tighten validator

- validator.py: enforce os.* allow-list on `from os import X` so names like
  `system`, `getcwd` cannot bypass the visit_Attribute restriction.
- ProcessBridge.ConfigureEnvironment: document that null Environment inherits
  the parent env (matching real behavior) and update the public
  LocalCodeActProviderOptions.Environment doc to describe the explicit
  empty-dictionary opt-in for a scrubbed environment.
- Tests:
  * FileMountHelperTests covers per-file, per-mount, and total
    capture-limit branches that return TextContent omissions.
  * Integration tests cover unknown-tool dispatch error, tool throwing
    exception, and CodeValidator timeout that kills the process and
    raises CodeValidationException.
- Sample: drop unused `Microsoft.Agents.AI.Foundry` using in
  Hosted-LocalCodeAct/Program.cs to satisfy IDE0005 check-format.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(local-codeact-dotnet): remove stale orphan sample

The dotnet/samples/LocalCodeAct/ scaffolding sample referenced APIs
that don't exist in the current package (`ExecutionMode`, FileMount
object-initializer syntax, the old LocalExecuteCodeFunction
constructor signature, function.Metadata.*), produced a long list of
check-format violations (CHARSET, IMPORTS, IDE0073 header, IDE0005
unused using, IDE1006 Async suffix, RCS1037 trailing whitespace), and
did not match any of the documented sample layouts.

The hosted-agent example at
dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct
is the supported entry-point sample for this package.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style(local-codeact-dotnet): satisfy check-format rules

- Add UTF-8 BOM to source files (CHARSET)
- Remove unused using directives (IDE0005)
- Simplify type names (IDE0001/IDE0002/IDE0090)
- Rename static field JsonOptions -> s_jsonOptions (IDE1006)
- Rename static field SyncRoot -> s_syncRoot (IDE1006)
- Add missing this. qualifications in ProcessBridge (IDE0009)
- Remove unused _options field from LocalCodeActProvider (IDE0052)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(local-codeact-dotnet): wire hosted sample into solution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(local-codeact-dotnet): sync embedded Python scripts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(local-codeact-dotnet): exercise Python integration on Windows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Address LocalCodeAct API review feedback

Move the required Python executable path to LocalCodeAct constructors, invert the validation flag default, and apply small project/file mount cleanup suggestions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Address LocalCodeAct concurrency review

Surface unauthorized mount traversal errors and use concurrent provider registries for LocalCodeAct tool and file mount CRUD operations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Simplify LocalCodeAct function wrappers

Use AIFunctionFactory-created inner functions for LocalCodeAct execute_code wrappers and remove redundant script cache and JsonNode cloning logic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Update LocalCodeAct factory result tests

Handle JsonElement result values produced by AIFunctionFactory delegation in LocalCodeAct execute_code integration tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 15:30:52 +00:00

280 lines
8.7 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests;
/// <summary>
/// Integration tests that launch a real Python subprocess. Skipped automatically when
/// no Python interpreter is discoverable on PATH.
/// </summary>
public sealed class LocalExecuteCodeFunctionIntegrationTests
{
private static readonly string? s_python = FindPython();
private static void SkipIfNoPython()
{
if (s_python is null)
{
Assert.Skip("No Python interpreter found on PATH; skipping integration test.");
}
}
[Fact]
public async Task ExecuteCode_PrintsAndReturnsResultAsync()
{
SkipIfNoPython();
var function = new LocalExecuteCodeFunction(s_python!);
var args = new AIFunctionArguments
{
["code"] = "print('hello world')\n1 + 2",
};
var result = await function.InvokeAsync(args, CancellationToken.None);
Assert.NotNull(result);
var combined = GetResultText(result);
Assert.Contains("hello world", combined);
Assert.Contains("3", combined);
}
[Fact]
public async Task ExecuteCode_ValidationBlocksDisallowedImportAsync()
{
SkipIfNoPython();
var function = new LocalExecuteCodeFunction(s_python!);
var args = new AIFunctionArguments
{
["code"] = "import subprocess",
};
await Assert.ThrowsAsync<CodeValidationException>(async () =>
await function.InvokeAsync(args, CancellationToken.None));
}
[Fact]
public async Task ExecuteCode_CapturesFilesInWritableMountAsync()
{
SkipIfNoPython();
var hostDir = Directory.CreateTempSubdirectory("localcodeact-mount-").FullName;
try
{
var options = new LocalCodeActProviderOptions
{
FileMounts = new[]
{
new FileMount(hostDir, "/output", FileMountMode.ReadWrite),
},
};
var function = new LocalExecuteCodeFunction(s_python!, options);
// Use os.path.join via the actual host path - the mount path is descriptive metadata only
var escapedPath = hostDir.Replace("\\", "\\\\", StringComparison.Ordinal);
var args = new AIFunctionArguments
{
["code"] = $"from pathlib import Path\nPath(r'{escapedPath}/out.txt').write_text('captured')",
};
var result = await function.InvokeAsync(args, CancellationToken.None);
Assert.NotNull(result);
AssertResultContainsDataContent(result, "/output/out.txt");
}
finally
{
Directory.Delete(hostDir, recursive: true);
}
}
[Fact]
public async Task ExecuteCode_UnknownToolNameReturnsErrorToGeneratedCodeAsync()
{
SkipIfNoPython();
// No tools are registered, so any call_tool from generated code resolves to
// the "Unknown tool" branch in ProcessBridge.HandleToolCallAsync.
var function = new LocalExecuteCodeFunction(s_python!);
var args = new AIFunctionArguments
{
["code"] = @"
try:
await call_tool('definitely_not_registered', x=1)
print('NO_ERROR')
except Exception as exc:
print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc))
",
};
var result = await function.InvokeAsync(args, CancellationToken.None);
var combined = GetResultText(result);
Assert.Contains("GOT_ERROR", combined);
Assert.Contains("definitely_not_registered", combined);
Assert.DoesNotContain("NO_ERROR", combined);
}
[Fact]
public async Task ExecuteCode_ToolThrowingExceptionPropagatesToGeneratedCodeAsync()
{
SkipIfNoPython();
// Tool that always throws — exercises ProcessBridge.HandleToolCallAsync exception path
// which sends a structured error response back to the subprocess.
Func<string, string> faulty = message => throw new InvalidOperationException("intentional: " + message);
var faultyTool = AIFunctionFactory.Create(faulty, name: "faulty");
var options = new LocalCodeActProviderOptions
{
Tools = new[] { faultyTool },
};
var function = new LocalExecuteCodeFunction(s_python!, options);
var args = new AIFunctionArguments
{
["code"] = @"
try:
await call_tool('faulty', message='boom')
print('NO_ERROR')
except Exception as exc:
print('GOT_ERROR:' + type(exc).__name__ + ':' + str(exc))
",
};
var result = await function.InvokeAsync(args, CancellationToken.None);
var combined = GetResultText(result);
Assert.Contains("GOT_ERROR", combined);
Assert.Contains("InvalidOperationException", combined);
Assert.Contains("intentional: boom", combined);
}
[Fact]
public async Task Validator_TimeoutKillsProcessAndThrowsAsync()
{
SkipIfNoPython();
// Custom validator script that ignores stdin and blocks forever so the
// parent timeout fires and exercises the timeout catch in CodeValidator.
var tempDir = Directory.CreateTempSubdirectory("localcodeact-vtimeout-").FullName;
try
{
var scriptPath = Path.Combine(tempDir, "hang_validator.py");
File.WriteAllText(scriptPath, "import time\nwhile True:\n time.sleep(60)\n");
var validator = new Internal.CodeValidator(
s_python!,
scriptPath,
TimeSpan.FromSeconds(1),
allowedImports: null,
blockedImports: null,
allowedBuiltins: null,
blockedBuiltins: null);
var ex = await Assert.ThrowsAsync<CodeValidationException>(
async () => await validator.ValidateAsync("print('x')", CancellationToken.None));
Assert.Contains("exceeded", ex.Message);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
private static string GetResultText(object? result) =>
result switch
{
IEnumerable<AIContent> contents => string.Join("\n", contents.OfType<TextContent>().Select(t => t.Text)),
JsonElement element => element.GetRawText(),
_ => result?.ToString() ?? string.Empty,
};
private static void AssertResultContainsDataContent(object? result, string expectedPath)
{
if (result is IEnumerable<AIContent> contents)
{
Assert.Contains(contents, c => c is DataContent);
return;
}
var json = Assert.IsType<JsonElement>(result).GetRawText();
Assert.Contains(expectedPath, json);
}
private static string? FindPython()
{
var configured = Environment.GetEnvironmentVariable("LOCAL_CODEACT_PYTHON");
if (!string.IsNullOrWhiteSpace(configured) && IsUsablePython(configured))
{
return configured;
}
var executableNames = OperatingSystem.IsWindows()
? new[] { "python3.exe", "python.exe" }
: new[] { "python3", "python" };
foreach (var name in executableNames)
{
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
foreach (var dir in path.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir))
{
continue;
}
var candidate = Path.Combine(dir, name);
if (File.Exists(candidate) && IsUsablePython(candidate))
{
return candidate;
}
}
}
return null;
}
private static bool IsUsablePython(string candidate)
{
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = candidate,
ArgumentList = { "--version" },
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
if (process is null)
{
return false;
}
if (!process.WaitForExit(milliseconds: 5000))
{
process.Kill(entireProcessTree: true);
return false;
}
return process.ExitCode == 0;
}
catch
{
return false;
}
}
}