Files
MAF1/MAF1.Web/PluginHost/PluginProcessRunner.cs
T
admin777 b631acd42e feat: 将插件 stdin 协议定为 v1,并按声明注入凭据
进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。
2026-09-11 10:46:52 +08:00

263 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using MAF1.PluginContract;
using MAF1.Utils;
namespace MAF1.Plugins;
/// <summary>
/// 启动子进程跑插件:stdin 写 PluginRequeststdout 读输出 JSON,超时 Kill。
/// 这是「进程外 Agent」的完整示例,对照 FileCityPlugin/Program.cs 一起看。
/// </summary>
public sealed class PluginProcessRunner(PluginOptions options, CredentialStore credentials)
{
public async Task<Dictionary<string, object?>> RunAsync(
LoadedPlugin plugin,
Dictionary<string, object?> inputs,
string? credentialId,
IReadOnlyDictionary<string, string>? credentialBindings,
CancellationToken cancellationToken)
{
PluginManifest manifest = plugin.Manifest;
ValidateDeclaredInputs(manifest, inputs);
Dictionary<string, PluginCredentialPayload> payload = ResolveCredentials(manifest, credentialId, credentialBindings);
PluginRequest request = new()
{
ProtocolVersion = PluginProtocol.Normalize(manifest.ProtocolVersion),
Inputs = inputs,
Credentials = payload,
};
int timeoutSeconds = manifest.TimeoutSeconds > 0 ? manifest.TimeoutSeconds : options.DefaultTimeoutSeconds;
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
ProcessStartInfo start = CreateStartInfo(plugin, payload);
using Process process = new() { StartInfo = start, EnableRaisingEvents = true };
if (!process.Start())
{
throw new InvalidOperationException($"无法启动插件 {manifest.Id}{start.FileName}");
}
string json = JsonSerializer.Serialize(request, PluginJson.Options);
await process.StandardInput.WriteAsync(json.AsMemory(), timeout.Token);
await process.StandardInput.FlushAsync(timeout.Token);
process.StandardInput.Close();
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
TryKill(process);
throw new TimeoutException($"插件 {manifest.Id} 超过 {timeoutSeconds} 秒未结束,已终止。");
}
string stdout = await stdoutTask;
string stderr = await stderrTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 退出码 {process.ExitCode}。{FormatStd(stderr, stdout)}");
}
Dictionary<string, object?> outputs = ParseOutputs(stdout);
ValidateDeclaredOutputs(manifest, outputs);
if (!string.IsNullOrWhiteSpace(stderr))
{
outputs["_stderr"] = stderr.Trim();
}
return outputs;
}
private Dictionary<string, PluginCredentialPayload> ResolveCredentials(
PluginManifest manifest,
string? credentialId,
IReadOnlyDictionary<string, string>? credentialBindings)
{
Dictionary<string, PluginCredentialPayload> map = new(StringComparer.OrdinalIgnoreCase);
foreach (PluginCredentialNeed need in manifest.Credentials)
{
if (string.IsNullOrWhiteSpace(need.Type) && string.IsNullOrWhiteSpace(need.Name))
{
continue;
}
string declaredType = string.IsNullOrWhiteSpace(need.Type) ? need.Name : need.Type;
string? preferredId = null;
if (credentialBindings is not null
&& !string.IsNullOrWhiteSpace(need.Name)
&& credentialBindings.TryGetValue(need.Name, out string? bound)
&& !string.IsNullOrWhiteSpace(bound))
{
preferredId = bound;
}
else if (PluginCredentialTypes.IsLlm(declaredType) || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase))
{
preferredId = credentialId;
}
CredentialRecord? record = credentials.TryResolveForType(declaredType, preferredId);
if (record is null)
{
if (need.Required)
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 需要类型 `{declaredType}` 的凭据 `{need.Name}`,但宿主 Credentials 里没有匹配项。");
}
continue;
}
if (need.Required && PluginCredentialTypes.IsLlm(declaredType) && string.IsNullOrWhiteSpace(record.ApiKey))
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。");
}
map[string.IsNullOrWhiteSpace(need.Name) ? declaredType : need.Name] = credentials.ToPayload(record);
}
return map;
}
/// <summary>工作目录=插件文件夹。凭据写入子进程环境变量,不读宿主 appsettings 路径。</summary>
private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary<string, PluginCredentialPayload> payload)
{
string command = plugin.Manifest.Launch.Command;
if (!Path.IsPathRooted(command))
{
string local = Path.Combine(plugin.FolderPath, command);
if (File.Exists(local))
{
command = local;
}
}
ProcessStartInfo start = new()
{
FileName = command,
WorkingDirectory = plugin.FolderPath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (string arg in plugin.Manifest.Launch.Args)
{
start.ArgumentList.Add(arg);
}
ApplyDotEnv(plugin.FolderPath, start);
foreach (KeyValuePair<string, string> pair in plugin.Manifest.Env)
{
start.Environment[pair.Key] = pair.Value;
}
PluginStdio.ApplyCredentialsToEnvironment(payload, start.Environment);
return start;
}
private static void ApplyDotEnv(string pluginDir, ProcessStartInfo start)
{
string path = Path.Combine(pluginDir, ".env");
if (!File.Exists(path))
{
return;
}
foreach (string raw in File.ReadAllLines(path))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith('#') || !line.Contains('='))
{
continue;
}
int split = line.IndexOf('=');
string key = line[..split].Trim();
string value = line[(split + 1)..].Trim().Trim('"');
if (!string.IsNullOrWhiteSpace(key))
{
start.Environment[key] = value;
}
}
}
private static void ValidateDeclaredInputs(PluginManifest manifest, Dictionary<string, object?> inputs)
{
foreach (PluginPort port in manifest.Inputs.Where(p => p.Required))
{
if (!inputs.TryGetValue(port.Name, out object? value) || value is null || value.ToString() == "")
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 的必填输入 `{port.Name}` 为空。请在节点配置里填写,或从上一节点连到这个端口。字段名必须和 plugin.json / 插件代码一致。");
}
}
}
private static void ValidateDeclaredOutputs(PluginManifest manifest, Dictionary<string, object?> outputs)
{
List<string> missing = manifest.Outputs
.Select(p => p.Name)
.Where(name => !outputs.Keys.Any(k => k.Equals(name, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (missing.Count > 0)
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 的输出字段与 plugin.json 不一致,缺少:{string.Join(", ", missing)}。请让插件 stdout 的 JSON 字段和清单 outputs 完全对应。");
}
}
private static Dictionary<string, object?> ParseOutputs(string stdout)
{
string json = JsonText.UnwrapObject(stdout);
using JsonDocument doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("插件 stdout 必须是一个 JSON 对象。");
}
Dictionary<string, object?> map = new(StringComparer.OrdinalIgnoreCase);
foreach (JsonProperty property in doc.RootElement.EnumerateObject())
{
map[property.Name] = property.Value.Clone();
}
return map;
}
private static string FormatStd(string stderr, string stdout)
{
string err = string.IsNullOrWhiteSpace(stderr) ? "" : $"stderr: {stderr.Trim()}";
string outText = string.IsNullOrWhiteSpace(stdout) ? "" : $"stdout: {stdout.Trim()}";
return string.Join(" ", new[] { err, outText }.Where(s => s.Length > 0));
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch
{
// 进程可能已经退出。
}
}
}