186 lines
8.1 KiB
C#
186 lines
8.1 KiB
C#
using Json.Schema;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Zcbot.WindowsNode;
|
|
|
|
internal static class AdapterContractLoader
|
|
{
|
|
internal static AdapterDescriptor Load(string directory)
|
|
{
|
|
var root = Path.GetFullPath(directory);
|
|
var manifestPath = ResolveFile(root, "adapter.json");
|
|
using var manifestDocument = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
|
|
var manifestRoot = manifestDocument.RootElement;
|
|
RequireOnlyProperties(
|
|
manifestRoot, "capability", "adapter_version", "runtime", "runtime_id",
|
|
"entrypoint", "contract", "worker_timeout_minutes", "running_detail");
|
|
var manifest = new AdapterManifest(
|
|
RequiredString(manifestRoot, "capability"),
|
|
RequiredVersion(manifestRoot, "adapter_version"),
|
|
RequiredString(manifestRoot, "runtime"),
|
|
OptionalString(manifestRoot, "runtime_id"),
|
|
RequiredString(manifestRoot, "entrypoint"),
|
|
RequiredString(manifestRoot, "contract"),
|
|
OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440),
|
|
RequiredString(manifestRoot, "running_detail"));
|
|
ValidateManifest(manifest);
|
|
|
|
var contractPath = ResolveFile(root, manifest.Contract);
|
|
var contractBytes = File.ReadAllBytes(contractPath);
|
|
var contractSha256 = Convert.ToHexString(SHA256.HashData(contractBytes)).ToLowerInvariant();
|
|
using var contractDocument = JsonDocument.Parse(contractBytes);
|
|
var contractRoot = contractDocument.RootElement;
|
|
var capability = RequiredString(contractRoot, "capability");
|
|
if (!capability.Equals(manifest.Capability, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidDataException("Adapter manifest capability does not match its contract.");
|
|
}
|
|
var currentVersion = ParseVersion(manifest.AdapterVersion);
|
|
var features = contractRoot.GetProperty("features").EnumerateObject()
|
|
.Where(item => ParseVersion(item.Value.GetString() ?? "0.0.0") <= currentVersion)
|
|
.Select(item => item.Name)
|
|
.ToArray();
|
|
var (workspaceStateFilename, previewOutputIds) = ReadWorkspace(contractRoot);
|
|
var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone());
|
|
var entrypointPath = ResolveFile(root, manifest.Entrypoint);
|
|
ValidateEntrypoint(manifest, entrypointPath);
|
|
return new AdapterDescriptor(
|
|
root,
|
|
entrypointPath,
|
|
contractPath,
|
|
contractSha256,
|
|
manifest,
|
|
new NodeAdapterContract(
|
|
capability,
|
|
RequiredString(contractRoot, "display_name"),
|
|
features,
|
|
schema,
|
|
workspaceStateFilename,
|
|
previewOutputIds));
|
|
}
|
|
|
|
private static void ValidateManifest(AdapterManifest manifest)
|
|
{
|
|
if (!Regex.IsMatch(manifest.Capability, "^[a-z][a-z0-9_.-]+@v[1-9][0-9]*$"))
|
|
{
|
|
throw new InvalidDataException("Adapter capability is invalid.");
|
|
}
|
|
if (manifest.Runtime is not ("python" or "executable"))
|
|
{
|
|
throw new InvalidDataException("Adapter runtime must be python or executable.");
|
|
}
|
|
if (manifest.Runtime == "python"
|
|
&& (manifest.RuntimeId is null
|
|
|| !Regex.IsMatch(manifest.RuntimeId, "^[a-z][a-z0-9_-]{0,31}$")))
|
|
{
|
|
throw new InvalidDataException("Python adapter runtime_id is invalid.");
|
|
}
|
|
if (manifest.Runtime == "executable" && manifest.RuntimeId is not null)
|
|
{
|
|
throw new InvalidDataException("Executable adapter must not declare runtime_id.");
|
|
}
|
|
}
|
|
|
|
private static (string? StateFilename, IReadOnlyList<string> PreviewOutputIds) ReadWorkspace(
|
|
JsonElement contractRoot)
|
|
{
|
|
if (!contractRoot.TryGetProperty("workspace", out var workspace)
|
|
|| workspace.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return (null, []);
|
|
}
|
|
var stateOutput = RequiredString(workspace, "state_output");
|
|
var outputs = contractRoot.GetProperty("outputs");
|
|
if (!outputs.TryGetProperty(stateOutput, out var stateSpec))
|
|
{
|
|
throw new InvalidDataException("Workspace state output is missing from contract.");
|
|
}
|
|
var previewOutputIds = workspace.GetProperty("preview_outputs")
|
|
.EnumerateArray()
|
|
.Select(item => item.GetString()
|
|
?? throw new InvalidDataException("Workspace preview output is invalid."))
|
|
.ToArray();
|
|
return (RequiredString(stateSpec, "filename"), previewOutputIds);
|
|
}
|
|
|
|
private static void ValidateEntrypoint(AdapterManifest manifest, string entrypointPath)
|
|
{
|
|
if ((manifest.Runtime == "python"
|
|
&& !Path.GetExtension(entrypointPath).Equals(".py", StringComparison.OrdinalIgnoreCase))
|
|
|| (manifest.Runtime == "executable"
|
|
&& !Path.GetExtension(entrypointPath).Equals(".exe", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new InvalidDataException("Adapter entrypoint extension does not match its runtime.");
|
|
}
|
|
}
|
|
|
|
private static string ResolveFile(string root, string relativePath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathFullyQualified(relativePath))
|
|
{
|
|
throw new InvalidDataException("Adapter file path must be relative.");
|
|
}
|
|
var resolved = Path.GetFullPath(Path.Combine(root, relativePath));
|
|
if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
|
|| !File.Exists(resolved))
|
|
{
|
|
throw new InvalidDataException("Adapter file is missing or outside its directory.");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
private static void RequireOnlyProperties(JsonElement value, params string[] names)
|
|
{
|
|
if (value.ValueKind != JsonValueKind.Object)
|
|
{
|
|
throw new InvalidDataException("Adapter manifest must be an object.");
|
|
}
|
|
var allowed = names.ToHashSet(StringComparer.Ordinal);
|
|
if (value.EnumerateObject().Any(item => !allowed.Contains(item.Name)))
|
|
{
|
|
throw new InvalidDataException("Adapter manifest contains unknown properties.");
|
|
}
|
|
}
|
|
|
|
private static string RequiredString(JsonElement value, string name) =>
|
|
value.TryGetProperty(name, out var property)
|
|
&& property.ValueKind == JsonValueKind.String
|
|
&& !string.IsNullOrWhiteSpace(property.GetString())
|
|
? property.GetString()!
|
|
: throw new InvalidDataException($"Adapter property {name} is missing.");
|
|
|
|
private static string RequiredVersion(JsonElement value, string name)
|
|
{
|
|
var version = RequiredString(value, name);
|
|
if (!Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+$")
|
|
|| !Version.TryParse(version, out _))
|
|
{
|
|
throw new InvalidDataException($"Adapter property {name} is not a semantic version.");
|
|
}
|
|
return version;
|
|
}
|
|
|
|
private static int OptionalInteger(
|
|
JsonElement value, string name, int defaultValue, int minimum, int maximum)
|
|
{
|
|
if (!value.TryGetProperty(name, out var property)) return defaultValue;
|
|
return property.TryGetInt32(out var result)
|
|
&& result >= minimum
|
|
&& result <= maximum
|
|
? result
|
|
: throw new InvalidDataException($"Adapter property {name} is invalid.");
|
|
}
|
|
|
|
private static string? OptionalString(JsonElement value, string name) =>
|
|
!value.TryGetProperty(name, out var property) || property.ValueKind == JsonValueKind.Null
|
|
? null
|
|
: property.ValueKind == JsonValueKind.String
|
|
? property.GetString()
|
|
: throw new InvalidDataException($"Adapter property {name} must be a string.");
|
|
|
|
private static Version ParseVersion(string value) =>
|
|
Version.TryParse(value, out var version) ? version : new Version(0, 0, 0);
|
|
}
|