fcc5576b04
* 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>
280 lines
8.7 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|