247 lines
9.4 KiB
C#
247 lines
9.4 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|