← All source files

V18 / 8d896b8a86e5

ProviderClient.Addons.cs

Lilith/Core/Addons/ProviderClient.Addons.cs

Changed in this version
CS
using System.Reflection;
using System.Runtime.Loader;
using System.Security.Cryptography;
using System.Text.Json;
using Lilith.Core.Addons;
using Lilith.Core.Scenarios;
using Lilith.Core.Tools;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Providers;

public abstract partial class ProviderClient
{
    internal ScenarioFileService? AddonScenarioFiles {get;private set;}
    private void SetAddonCatalog(ScenarioFileService files) { AddonScenarioFiles=files;Schedules?.UpdateScenarioCatalog(files); }
    private readonly List<AssemblyLoadContext> _addonContexts=[];
    private readonly List<string> _addonTools=[];
    private string? _bundledScenarioRoot;
    public bool AddonRestartPending {get;private set;}
    public IReadOnlyList<string> ActiveAddonScenarios {get;private set;}=[];
    internal bool IsAddonTool(string name)=>_addonTools.Contains(name,StringComparer.Ordinal);
    public string ReviewAddon(string id)
    {
        using var operation=EnterOperation();var p=new AddonStore(Workspace??throw new InvalidOperationException("Workspace unavailable.")).Load(id);
        return JsonSerializer.Serialize(new{package=p,signature=AddonStore.Signature(p)},AddonStore.Json);
    }
    public string PendingAddons()
    {
        using var operation=EnterOperation();var store=new AddonStore(Workspace??throw new InvalidOperationException("Workspace unavailable."));
        return JsonSerializer.Serialize(store.List().Where(p=>p.Status=="pending").Select(p=>new{Id=p.Id,Package=p,Signature=AddonStore.Signature(p)}),AddonStore.Json);
    }
    public string RejectAddon(string id,string signature)
    {
        using var operation=EnterOperation();var store=new AddonStore(Workspace??throw new InvalidOperationException("Workspace unavailable."));var p=store.Load(id);
        if(p.Status!="pending"||AddonStore.Signature(p)!=signature)throw new InvalidOperationException("Stale addon decision.");store.Save(p with{Status="rejected"});return "Addon rejected; no code loaded.";
    }
    public async Task<string> ApproveAddonAsync(string id,string signature,CancellationToken ct=default)
    {
        using var operation=EnterOperation();return await ActivateAddonCoreAsync(id,signature,ct);
    }
    internal async Task<string> ActivateAddonCoreAsync(string id,string signature,CancellationToken ct)
    {
        var store=new AddonStore(Workspace??throw new InvalidOperationException("Workspace unavailable."));var p=store.Load(id);
        if(p.Status!="pending"||AddonStore.Signature(p)!=signature)throw new InvalidOperationException("Stale or already decided addon approval.");
        string dll=await store.Build(p,ct);string hash=Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(dll,ct)));
        var current=store.Load(id);if(AddonStore.Signature(current)!=signature)throw new InvalidOperationException("Addon source changed during compilation.");
        string approval=ApprovalPath(id);Directory.CreateDirectory(Path.GetDirectoryName(approval)!);
        (approval+".tmp").EnsureNoLinks();
        await File.WriteAllTextAsync(approval+".tmp",JsonSerializer.Serialize(new{signature,assemblyHash=hash}),ct);File.Move(approval+".tmp",approval,true);
        store.Save(p with{Status="active",AssemblyHash=hash});AddonRestartPending=true;
        return JsonSerializer.Serialize(new{status="restart_required",addon_id=id,scenario=p.Name,assembly_sha256=hash,message="Exact approved addon compiled. The addon runtime will restart after this turn; no generated tool has executed yet."});
    }
    private string ApprovalPath(string id)
    {
        if(!Guid.TryParseExact(id,"N",out _))throw new ArgumentException("Invalid addon ID.");
        string path=Path.Combine(Workspace!.Options.RootDirectory,".lilith","addon-approvals",id+".json");path.EnsureNoLinks();return path;
    }
    internal void LoadAddons(string? root)
    {
        _bundledScenarioRoot=root;
        var bundled=new ScenarioFileService(root);
        if(Workspace is null){SetAddonCatalog(bundled);return;}
        var store=new AddonStore(Workspace);
        var active=store.List().Where(p=>p.Status=="active").Take(33).ToArray();
        if(active.Length>32)throw new InvalidDataException("At most 32 active addon packages are supported.");
        if(active.Length==0){SetAddonCatalog(bundled);ActiveAddonScenarios=[];return;}
        string merged=Path.Combine(HistoryStore.RootDirectory,"AddonCatalog",Guid.NewGuid().ToString("N"));Directory.CreateDirectory(merged);
        foreach(string n in bundled.ListNames())File.WriteAllText(Path.Combine(merged,n+".xml"),bundled.LoadScenarioXml(n));
        List<string> names=[];
        foreach(var p in active)
        {
            using var approval=JsonDocument.Parse(File.ReadAllText(ApprovalPath(p.Id)));
            if(approval.RootElement.GetProperty("signature").GetString()!=AddonStore.Signature(p))throw new InvalidDataException("Approved addon source was modified.");
            string dll=Path.Combine(store.Folder(p.Id),"Build","bin","Release",$"net{Environment.Version.Major}.0","CraftedAddon.dll");dll.EnsureNoLinks();
            byte[] bytes=File.ReadAllBytes(dll);string hash=Convert.ToHexString(SHA256.HashData(bytes));
            if(hash!=p.AssemblyHash||hash!=approval.RootElement.GetProperty("assemblyHash").GetString())throw new InvalidDataException("Approved addon assembly was modified or needs rebuilding for this runtime.");
            if(File.Exists(Path.Combine(merged,p.Name+".xml"))||_tools.ContainsKey(p.ToolName))throw new InvalidDataException("Addon conflicts with an existing scenario/tool.");
            var context=new AssemblyLoadContext("Addon-"+p.Id,true);context.Resolving+=(_,name)=>name.Name==typeof(ILilithTool).Assembly.GetName().Name?typeof(ILilithTool).Assembly:null;
            _addonContexts.Add(context);
            using var stream=new MemoryStream(bytes);var assembly=context.LoadFromStream(stream);
            var types=assembly.GetExportedTypes().Where(t=>typeof(ILilithTool).IsAssignableFrom(t)&&!t.IsAbstract).ToArray();
            if(types.Length!=1)throw new InvalidDataException("Addon must export exactly one tool class.");
            var tool=(ILilithTool)(Activator.CreateInstance(types[0])??throw new InvalidDataException("Missing tool constructor."));
            if(tool.Name!=p.ToolName)throw new InvalidDataException("Generated tool name differs from approval.");
            if(tool is IAddonContextAware aware){string data=Path.Combine(store.Folder(p.Id),"Data");data.EnsureNoLinks();Directory.CreateDirectory(data);aware.Configure(new(data));}
            _tools.Add(tool.Name,tool);_addonTools.Add(tool.Name);
            File.WriteAllText(Path.Combine(merged,p.Name+".xml"),p.ScenarioXml);names.Add(p.Name);
        }
        SetAddonCatalog(new(merged));ActiveAddonScenarios=names;
    }
    public void RestartAddonRuntime()
    {
        using var operation=EnterOperation();
        if(!AddonRestartPending)return;
        SaveCore();
        foreach(string name in _addonTools)_tools.Remove(name);_addonTools.Clear();
        foreach(var context in _addonContexts)context.Unload();_addonContexts.Clear();
        LoadAddons(_bundledScenarioRoot);
        _tools["run_scenario"]=new RunScenarioTool(this,AddonScenarioFiles!);
        if(_thinking is not null)_thinking=new(this,AddonScenarioFiles!.RootDirectory);
        HistoryStore.StartNewSession();ClearCore();AddonRestartPending=false;
        Logger.WriteLine("Boot","Addon runtime restarted with freshly compiled tools and a fresh conversation.",Lilith.Core.Logging.LogColors.Cyan);
    }
}