Initial commit to new repository
This commit is contained in:
246
src/AxCopilot/Services/Agent/SnippetRunnerTool.cs
Normal file
246
src/AxCopilot/Services/Agent/SnippetRunnerTool.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// C#/Python/JavaScript 코드 스니펫을 즉시 실행하는 도구.
|
||||
/// 임시 파일에 코드를 저장하고 해당 런타임으로 실행합니다.
|
||||
/// </summary>
|
||||
public class SnippetRunnerTool : IAgentTool
|
||||
{
|
||||
public string Name => "snippet_runner";
|
||||
public string Description =>
|
||||
"Execute a code snippet in C#, Python, or JavaScript. " +
|
||||
"Writes the code to a temp file, runs it, and returns stdout/stderr. " +
|
||||
"Useful for quick calculations, data transformations, format conversions, and testing algorithms. " +
|
||||
"Max execution time: 30 seconds. Max output: 8000 chars.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["language"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Programming language: 'csharp', 'python', or 'javascript'",
|
||||
Enum = ["csharp", "python", "javascript"],
|
||||
},
|
||||
["code"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Source code to execute. For C#: top-level statements or full Program class. " +
|
||||
"For Python: standard script. For JavaScript: Node.js script.",
|
||||
},
|
||||
["timeout"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Timeout in seconds. Default: 30, max: 60.",
|
||||
},
|
||||
},
|
||||
Required = ["language", "code"],
|
||||
};
|
||||
|
||||
// 위험한 코드 패턴 차단
|
||||
private static readonly string[] DangerousPatterns =
|
||||
[
|
||||
"Process.Start", "ProcessStartInfo",
|
||||
"Registry.", "RegistryKey",
|
||||
"Environment.Exit",
|
||||
"File.Delete", "Directory.Delete",
|
||||
"Format-Volume", "Remove-Item",
|
||||
"os.remove", "os.rmdir", "shutil.rmtree",
|
||||
"subprocess.call", "subprocess.Popen",
|
||||
"child_process", "require('fs').unlink",
|
||||
"exec(", "eval(",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var language = args.GetProperty("language").GetString() ?? "";
|
||||
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
|
||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return ToolResult.Fail("code가 비어 있습니다.");
|
||||
|
||||
// 위험 패턴 검사
|
||||
foreach (var pattern in DangerousPatterns)
|
||||
{
|
||||
if (code.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail($"보안 차단: '{pattern}' 패턴은 snippet_runner에서 허용되지 않습니다. process 도구를 사용하세요.");
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
if (!await context.CheckWritePermissionAsync(Name, $"[{language}] {(code.Length > 100 ? code[..100] + "..." : code)}"))
|
||||
return ToolResult.Fail("코드 실행 권한 거부");
|
||||
|
||||
// 설정에서 비활성화 여부 확인
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var enabled = app?.SettingsService?.Settings?.Llm?.Code?.EnableSnippetRunner ?? true;
|
||||
if (!enabled)
|
||||
return ToolResult.Fail("snippet_runner가 설정에서 비활성화되어 있습니다. 설정 → AX Agent → 기능에서 활성화하세요.");
|
||||
|
||||
return language switch
|
||||
{
|
||||
"csharp" => await RunCSharpAsync(code, timeout, context, ct),
|
||||
"python" => await RunScriptAsync("python", ".py", code, timeout, context, ct),
|
||||
"javascript" => await RunScriptAsync("node", ".js", code, timeout, context, ct),
|
||||
_ => ToolResult.Fail($"지원하지 않는 언어: {language}. csharp, python, javascript 중 선택하세요."),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ToolResult> RunCSharpAsync(string code, int timeout, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// dotnet-script 사용 시도, 없으면 dotnet run 임시 프로젝트
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// dotnet-script 먼저 확인
|
||||
if (await IsToolAvailableAsync("dotnet-script"))
|
||||
{
|
||||
var scriptFile = Path.Combine(tempDir, "snippet.csx");
|
||||
await File.WriteAllTextAsync(scriptFile, code, ct);
|
||||
return await RunProcessAsync("dotnet-script", scriptFile, timeout, tempDir, ct);
|
||||
}
|
||||
|
||||
// dotnet run으로 폴백 — 임시 콘솔 프로젝트 생성
|
||||
var csprojContent = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(tempDir, "Snippet.csproj"), csprojContent, ct);
|
||||
|
||||
// top-level statements 코드를 Program.cs로
|
||||
var programCode = code.Contains("class ") && code.Contains("static void Main")
|
||||
? code // 이미 전체 클래스
|
||||
: code; // top-level statements (net8.0 지원)
|
||||
await File.WriteAllTextAsync(Path.Combine(tempDir, "Program.cs"), programCode, ct);
|
||||
|
||||
return await RunProcessAsync("dotnet", $"run --project \"{tempDir}\"", timeout, tempDir, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 정리
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> RunScriptAsync(string runtime, string extension, string code,
|
||||
int timeout, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// 런타임 사용 가능 확인
|
||||
if (!await IsToolAvailableAsync(runtime))
|
||||
return ToolResult.Fail($"{runtime}이 설치되지 않았습니다. 시스템에 {runtime}을 설치하세요.");
|
||||
|
||||
var tempFile = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}{extension}");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, code, Encoding.UTF8, ct);
|
||||
return await RunProcessAsync(runtime, $"\"{tempFile}\"", timeout, context.WorkFolder, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tempFile); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> RunProcessAsync(string fileName, string arguments,
|
||||
int timeout, string workDir, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(workDir) && Directory.Exists(workDir))
|
||||
psi.WorkingDirectory = workDir;
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
var stdout = new StringBuilder();
|
||||
var stderr = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"실행 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
return ToolResult.Fail($"실행 타임아웃 ({timeout}초 초과)");
|
||||
}
|
||||
|
||||
var output = stdout.ToString().TrimEnd();
|
||||
var error = stderr.ToString().TrimEnd();
|
||||
|
||||
// 출력 크기 제한
|
||||
if (output.Length > 8000)
|
||||
output = output[..8000] + "\n... (출력 잘림)";
|
||||
|
||||
var result = new StringBuilder();
|
||||
result.AppendLine($"[Exit code: {process.ExitCode}]");
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
result.AppendLine(output);
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
result.AppendLine($"[stderr]\n{error}");
|
||||
|
||||
return ToolResult.Ok(result.ToString());
|
||||
}
|
||||
|
||||
private static async Task<bool> IsToolAvailableAsync(string tool)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = Environment.OSVersion.Platform == PlatformID.Win32NT ? "where" : "which",
|
||||
Arguments = tool,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
await process.WaitForExitAsync();
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user