From 7472051424857aaf20670eda6977cb4b436478b2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 11 Sep 2026 20:05:16 -0500 Subject: [PATCH 1/4] Define managed Git skill plugin sources and state --- .../Doctor/ConfigSchemaDoctorCheckTests.cs | 31 ++ .../SkillSourcesConfigViewModelTests.cs | 44 +++ .../Tui/Config/SkillSourcesConfigViewModel.cs | 22 +- .../GitSkillPluginSourceValidatorTests.cs | 120 +++++++ src/Netclaw.Configuration/NetclawPaths.cs | 10 + src/Netclaw.Configuration/OperationalAlert.cs | 1 + .../Schemas/netclaw-config.v1.schema.json | 20 ++ src/Netclaw.Configuration/SkillFeedsConfig.cs | 258 +++++++++++++++ src/Netclaw.Configuration/SkillSyncResult.cs | 6 + .../Services/GitSkillPluginStateStoreTests.cs | 180 ++++++++++ .../Services/GitSkillPluginStateStore.cs | 309 ++++++++++++++++++ .../sqlite/007_git_skill_plugins.sql | 30 ++ .../Skills/RegexSkillContentScannerTests.cs | 7 +- .../Skills/ISkillContentScanner.cs | 10 +- .../Skills/RegexSkillContentScanner.cs | 2 +- 15 files changed, 1042 insertions(+), 8 deletions(-) create mode 100644 src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs create mode 100644 src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs create mode 100644 src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql diff --git a/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs index 41b2e97f7..e60e12cad 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs @@ -481,6 +481,37 @@ await File.WriteAllTextAsync(paths.NetclawConfigPath, Assert.Equal(DoctorSeverity.Pass, result.Severity); } + [Fact] + public async Task ReturnsPass_WhenGitSkillPluginSourceIsValid() + { + var basePath = CreateTempBasePath(); + var paths = new NetclawPaths(basePath); + paths.EnsureDirectoriesExist(); + + await File.WriteAllTextAsync(paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "SkillFeeds": { + "Plugins": [{ + "Name": "dotnet-skills", + "Repository": "Aaronontheweb/dotnet-skills", + "Format": "codex", + "ReferenceKind": "Commit", + "Reference": "13e26d39ed01d97ea592235d041304d289f4ba07", + "Enabled": true, + "TimeoutSeconds": 60 + }] + } + } + """, TestContext.Current.CancellationToken); + + var check = new ConfigSchemaDoctorCheck(paths); + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + } + [Fact] public async Task ReturnsPass_WhenToolsAudienceProfilesAndMcpCapabilityValid() { diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs index 789a8692d..a04e8cbbf 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Microsoft.Extensions.Configuration; +using System.Text.Json; using Netclaw.Cli.Tui.Config; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -66,6 +67,49 @@ public async Task Rescan_all_does_not_badge_disabled_remote_servers() Assert.DoesNotContain("advertised", remoteRow.Detail, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Toggle_feed_preserves_managed_Git_plugins() + { + File.WriteAllText( + _paths.NetclawConfigPath, + """ + {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Name":"dotnet-skills","Repository":"owner/repository","Format":"codex","Subdirectory":"packages/plugin","ReferenceKind":"Branch","Reference":"main","Enabled":true,"TimeoutSeconds":90}]}} + """); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + vm.CommitToggleEnabled(new SkillSourceActionTarget(SkillSourceKind.RemoteSkillServer, "custom-feed")); + + using var document = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var section = document.RootElement.GetProperty("SkillFeeds"); + Assert.False(section.GetProperty("Feeds")[0].GetProperty("Enabled").GetBoolean()); + var plugin = Assert.Single(section.GetProperty("Plugins").EnumerateArray()); + Assert.Equal("owner/repository", plugin.GetProperty("Repository").GetString()); + Assert.Equal("packages/plugin", plugin.GetProperty("Subdirectory").GetString()); + Assert.Equal("Branch", plugin.GetProperty("ReferenceKind").GetString()); + Assert.Equal(90, plugin.GetProperty("TimeoutSeconds").GetInt32()); + } + + [Fact] + public void Remove_last_feed_preserves_managed_Git_plugins() + { + File.WriteAllText( + _paths.NetclawConfigPath, + """ + {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Name":"dotnet-skills","Repository":"owner/repository","Format":"codex","ReferenceKind":"Commit","Reference":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","Enabled":false,"TimeoutSeconds":60}]}} + """); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + vm.CommitRemoveSource(new SkillSourceActionTarget(SkillSourceKind.RemoteSkillServer, "custom-feed")); + + using var document = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var section = document.RootElement.GetProperty("SkillFeeds"); + Assert.Empty(section.GetProperty("Feeds").EnumerateArray()); + var plugin = Assert.Single(section.GetProperty("Plugins").EnumerateArray()); + Assert.Equal("dotnet-skills", plugin.GetProperty("Name").GetString()); + Assert.Equal("Commit", plugin.GetProperty("ReferenceKind").GetString()); + Assert.False(plugin.GetProperty("Enabled").GetBoolean()); + } + [Fact] public async Task Rescan_all_invalidates_a_cached_count_when_the_server_is_disabled() { diff --git a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs index 2a3f8606a..105b6adf2 100644 --- a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs @@ -2263,7 +2263,7 @@ private bool SaveExternalConfig(ExternalSkillsConfig external) private bool SaveSkillFeedsConfig(SkillFeedsConfigDocument feeds) => TryEditConfig(root => { - if (feeds.Feeds.Count == 0) + if (feeds.Feeds.Count == 0 && feeds.Plugins.Count == 0) root.Remove("SkillFeeds"); else root["SkillFeeds"] = BuildSkillFeedsSection(feeds); @@ -2522,6 +2522,24 @@ private static Dictionary BuildSkillFeedsSection(SkillFeedsConfi return (object)item; }).ToArray(), + ["Plugins"] = config.Plugins.Select(static plugin => + { + var item = new Dictionary + { + ["Name"] = plugin.Name, + ["Repository"] = plugin.Repository, + ["Format"] = plugin.Format, + ["ReferenceKind"] = plugin.ReferenceKind.ToString(), + ["Reference"] = plugin.Reference, + ["Enabled"] = plugin.Enabled, + ["TimeoutSeconds"] = plugin.TimeoutSeconds, + }; + + if (!string.IsNullOrWhiteSpace(plugin.Subdirectory)) + item["Subdirectory"] = plugin.Subdirectory; + + return (object)item; + }).ToArray(), }; private static LocalSkillScanDisplay ScanLocalSkills(string directory, bool allowSymlinks) @@ -2617,6 +2635,8 @@ private sealed class SkillFeedsConfigDocument public int SyncIntervalMinutes { get; set; } = 60; public List Feeds { get; set; } = []; + + public List Plugins { get; set; } = []; } private sealed class SkillFeedConfigEntry diff --git a/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs b/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs new file mode 100644 index 000000000..3a930f7b1 --- /dev/null +++ b/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs @@ -0,0 +1,120 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class GitSkillPluginSourceValidatorTests +{ + [Theory] + [InlineData("Aaronontheweb/dotnet-skills", "Aaronontheweb/dotnet-skills")] + [InlineData("https://github.com/Aaronontheweb/dotnet-skills", "Aaronontheweb/dotnet-skills")] + [InlineData("https://github.com/Aaronontheweb/dotnet-skills.git", "Aaronontheweb/dotnet-skills")] + public void Repository_normalization_accepts_public_GitHub_forms(string value, string expected) + { + Assert.True(GitSkillPluginSourceValidator.TryNormalizeRepository(value, out var actual, out _)); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("http://github.com/owner/repo")] + [InlineData("https://user@github.com/owner/repo")] + [InlineData("https://github.com:8443/owner/repo")] + [InlineData("https://gitlab.com/owner/repo")] + [InlineData("https://github.com/owner/repo?token=secret")] + [InlineData("https://github.com/owner/repo#readme")] + [InlineData("--upload-pack=bad")] + public void Repository_normalization_rejects_unsafe_transport_forms(string value) + { + Assert.False(GitSkillPluginSourceValidator.TryNormalizeRepository(value, out _, out _)); + } + + [Fact] + public void Source_validation_requires_a_full_commit_identity() + { + var source = Source(); + source.ReferenceKind = GitSkillPluginReferenceKind.Commit; + source.Reference = "abc123"; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out var error)); + Assert.Contains("full 40-character", error); + } + + [Fact] + public void Fingerprint_changes_for_each_source_semantic() + { + var source = Source(); + var original = GitSkillPluginSourceValidator.Fingerprint(source); + + source.Subdirectory = "plugin"; + + Assert.NotEqual(original, GitSkillPluginSourceValidator.Fingerprint(source)); + } + + [Fact] + public void Collection_validation_rejects_duplicate_names() + { + var first = Source(); + var second = Source(); + second.Repository = "owner/other"; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSources([first, second], out var error)); + Assert.Contains("occurs more than once", error); + } + + [Fact] + public void Collection_validation_rejects_more_than_twenty_sources() + { + var sources = Enumerable.Range(0, 21).Select(index => + { + var source = Source(); + source.Name = $"source-{index}"; + return source; + }).ToArray(); + + Assert.False(GitSkillPluginSourceValidator.TryValidateSources(sources, out var error)); + Assert.Contains("No more than 20", error); + } + + [Theory] + [InlineData("./plugin/")] + [InlineData("/plugin")] + [InlineData("plugin/../other")] + public void Source_validation_rejects_a_noncanonical_subdirectory(string subdirectory) + { + var source = Source(); + source.Subdirectory = subdirectory; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out _)); + } + + [Theory] + [InlineData("owner/.")] + [InlineData("owner/..")] + [InlineData("./repository")] + public void Repository_normalization_rejects_dot_components(string repository) + { + Assert.False(GitSkillPluginSourceValidator.TryNormalizeRepository(repository, out _, out _)); + } + + [Fact] + public void Source_validation_rejects_an_unknown_reference_type() + { + var source = Source(); + source.ReferenceKind = (GitSkillPluginReferenceKind)99; + + Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out _)); + } + + private static GitSkillPluginSource Source() => new() + { + Name = "dotnet-skills", + Repository = "Aaronontheweb/dotnet-skills", + Format = "codex", + ReferenceKind = GitSkillPluginReferenceKind.Branch, + Reference = "main", + }; +} diff --git a/src/Netclaw.Configuration/NetclawPaths.cs b/src/Netclaw.Configuration/NetclawPaths.cs index dd48483fe..2a70f26cf 100644 --- a/src/Netclaw.Configuration/NetclawPaths.cs +++ b/src/Netclaw.Configuration/NetclawPaths.cs @@ -42,6 +42,15 @@ public sealed class NetclawPaths // ── Server feed skills (from private skill-server instances) ── public string ServerFeedsDirectory => Path.Combine(SkillsDirectory, ".server-feeds"); + // Managed plugin commits remain immutable while the registry can reference them. + public string ManagedGitSkillsDirectory => Path.Combine(SkillsDirectory, ".git-plugins"); + + public string ManagedGitSkillDirectory(string sourceName) + => Path.Combine(ManagedGitSkillsDirectory, sourceName); + + public string ManagedGitSkillCommitDirectory(string sourceName, string commit) + => Path.Combine(ManagedGitSkillDirectory(sourceName), "commits", commit); + public string ServerFeedDirectory(string feedName) => Path.Combine(ServerFeedsDirectory, feedName); @@ -185,6 +194,7 @@ private IEnumerable StandardDirectories() yield return SkillsDirectory; yield return SystemSkillsDirectory; yield return ServerFeedsDirectory; + yield return ManagedGitSkillsDirectory; yield return ProjectsDirectory; yield return ClientDirectory; yield return EnvironmentDirectory; diff --git a/src/Netclaw.Configuration/OperationalAlert.cs b/src/Netclaw.Configuration/OperationalAlert.cs index d3ad7a7cb..022856912 100644 --- a/src/Netclaw.Configuration/OperationalAlert.cs +++ b/src/Netclaw.Configuration/OperationalAlert.cs @@ -42,6 +42,7 @@ public enum AlertType // New values stay at the end so prior ordinal values remain stable. ReminderScheduleFailed, ChannelReconnected, + SkillPluginSecurityRejected, } /// diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index 86f84e8e7..a6ba197c4 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -627,6 +627,26 @@ "required": ["Name", "Url"], "additionalProperties": false } + }, + "Plugins": { + "type": "array", + "maxItems": 20, + "description": "Managed public GitHub skill plugins.", + "items": { + "type": "object", + "properties": { + "Name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 }, + "Repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "Format": { "type": "string", "enum": ["codex"], "default": "codex" }, + "Subdirectory": { "type": ["string", "null"], "minLength": 1, "maxLength": 512 }, + "ReferenceKind": { "type": "string", "enum": ["Branch", "Commit"] }, + "Reference": { "type": "string", "minLength": 1, "maxLength": 256 }, + "Enabled": { "type": "boolean", "default": true }, + "TimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300, "default": 60 } + }, + "required": ["Name", "Repository", "Format", "ReferenceKind", "Reference"], + "additionalProperties": false + } } }, "additionalProperties": false diff --git a/src/Netclaw.Configuration/SkillFeedsConfig.cs b/src/Netclaw.Configuration/SkillFeedsConfig.cs index e12ae5a62..13103bbb0 100644 --- a/src/Netclaw.Configuration/SkillFeedsConfig.cs +++ b/src/Netclaw.Configuration/SkillFeedsConfig.cs @@ -19,6 +19,9 @@ public sealed class SkillFeedsConfig /// public List Feeds { get; set; } = []; + /// Managed public GitHub skill plugins. + public List Plugins { get; set; } = []; + /// /// How often (in minutes) to re-check feeds for updated skills. /// Default: 60 (once per hour). Set to 0 to disable periodic sync @@ -27,6 +30,261 @@ public sealed class SkillFeedsConfig public int SyncIntervalMinutes { get; set; } = 60; } +/// The type of Git reference that a managed plugin follows. +public enum GitSkillPluginReferenceKind +{ + Branch, + Commit, +} + +/// A managed GitHub source for a content-only skill plugin. +public sealed class GitSkillPluginSource +{ + public string Name { get; set; } = ""; + public string Repository { get; set; } = ""; + public string Format { get; set; } = "codex"; + public string? Subdirectory { get; set; } + public GitSkillPluginReferenceKind ReferenceKind { get; set; } + public string Reference { get; set; } = ""; + public bool Enabled { get; set; } = true; + public int TimeoutSeconds { get; set; } = 60; +} + +/// Validates and canonicalizes managed GitHub plugin source data. +public static class GitSkillPluginSourceValidator +{ + public const int MaximumSourceCount = 20; + + public static bool TryValidateSources(IReadOnlyList sources, out string error) + { + error = ""; + if (sources.Count > MaximumSourceCount) + { + error = $"No more than {MaximumSourceCount} GitHub plugins can be configured."; + return false; + } + var duplicate = sources.GroupBy(source => source.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + error = $"Plugin name '{duplicate.Key}' occurs more than once."; + return false; + } + foreach (var source in sources) + { + if (!TryValidateSource(source, out error)) + return false; + } + return true; + } + + public static bool TryNormalizeRepository(string value, out string repository, out string error) + { + repository = ""; + error = ""; + if (string.IsNullOrWhiteSpace(value) || value.StartsWith("-", StringComparison.Ordinal)) + { + error = "The repository must be GitHub owner/repository shorthand or a canonical GitHub HTTPS URL."; + return false; + } + + var candidate = value.Trim(); + if (TryParseRepositoryParts(candidate, out var owner, out var name)) + { + repository = $"{owner}/{name}"; + return true; + } + + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) + || !uri.IsDefaultPort + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + error = "The repository URL must be canonical GitHub HTTPS without credentials, a port, a query, or a fragment."; + return false; + } + + var path = uri.AbsolutePath.Trim('/'); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path[..^4]; + if (!TryParseRepositoryParts(path, out owner, out name)) + { + error = "The GitHub URL must contain one owner and one repository."; + return false; + } + + repository = $"{owner}/{name}"; + return true; + } + + public static bool TryValidateSource(GitSkillPluginSource source, out string error) + { + ArgumentNullException.ThrowIfNull(source); + + if (!TryValidateName(source.Name, out error)) + return false; + if (!TryNormalizeRepository(source.Repository, out var repository, out error) + || !string.Equals(repository, source.Repository, StringComparison.Ordinal)) + { + error = error.Length > 0 ? error : "The repository is not canonical owner/repository form."; + return false; + } + if (!string.Equals(source.Format, "codex", StringComparison.Ordinal)) + { + error = "The plugin format must be 'codex'."; + return false; + } + if (!TryNormalizeRelativePath(source.Subdirectory, allowEmpty: true, out var subdirectory, out error)) + return false; + if (!string.Equals(source.Subdirectory, subdirectory, StringComparison.Ordinal)) + { + error = "The repository subdirectory must use its canonical relative form."; + return false; + } + if (!Enum.IsDefined(source.ReferenceKind)) + { + error = "The plugin reference type is not supported."; + return false; + } + if (!TryValidateReference(source.ReferenceKind, source.Reference, out error)) + return false; + if (source.TimeoutSeconds is < 1 or > 300) + { + error = "The plugin timeout must be from 1 through 300 seconds."; + return false; + } + return true; + } + + public static bool TryValidateName(string value, out string error) + { + error = ""; + if (string.IsNullOrEmpty(value) || value.Length > 64 || !IsLowerKebab(value)) + { + error = "The plugin name must use lowercase letters, numbers, and single hyphens."; + return false; + } + return true; + } + + public static bool TryValidateReference( + GitSkillPluginReferenceKind kind, + string value, + out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(value)) + { + error = "The reference is required."; + return false; + } + var candidate = value.Trim(); + if (!string.Equals(candidate, value, StringComparison.Ordinal) + || candidate.Length > 256 || candidate.StartsWith("-", StringComparison.Ordinal) + || candidate.EndsWith(".", StringComparison.Ordinal) || candidate.EndsWith("/", StringComparison.Ordinal) + || candidate.Contains("..", StringComparison.Ordinal) || candidate.Contains("//", StringComparison.Ordinal) + || candidate.Contains("@{", StringComparison.Ordinal) || candidate.EndsWith(".lock", StringComparison.OrdinalIgnoreCase) + || candidate.Any(static c => char.IsWhiteSpace(c) || char.IsControl(c) + || c is '~' or '^' or ':' or '?' or '*' or '[' or '\\')) + { + error = "The reference is not a safe Git branch or commit."; + return false; + } + if (kind == GitSkillPluginReferenceKind.Commit + && (candidate.Length is not (40 or 64) || !candidate.All(char.IsAsciiHexDigit))) + { + error = "A commit reference must be a full 40-character or 64-character hexadecimal identity."; + return false; + } + return true; + } + + public static bool TryNormalizeRelativePath( + string? value, + bool allowEmpty, + out string? path, + out string error) + { + path = null; + error = ""; + if (string.IsNullOrWhiteSpace(value)) + { + if (allowEmpty) + return true; + error = "The relative path is required."; + return false; + } + if (value.Contains('\\', StringComparison.Ordinal)) + { + error = "Repository paths cannot contain backslashes."; + return false; + } + var candidate = value.Trim(); + if (candidate.StartsWith("./", StringComparison.Ordinal)) + candidate = candidate[2..]; + candidate = candidate.Trim('/'); + var segments = candidate.Split('/'); + if (Path.IsPathRooted(value) || candidate.Length > 512 || candidate.Length == 0 + || segments.Any(static segment => segment.Length is 0 or > 255 || segment is "." or "..") + || candidate.Any(char.IsControl)) + { + error = "The repository subdirectory must be a safe relative path within the path limits."; + return false; + } + path = string.Join('/', segments); + return true; + } + + public static string Fingerprint(GitSkillPluginSource source) + { + var content = string.Join('\n', source.Repository, source.Subdirectory ?? "", source.Format, + source.ReferenceKind.ToString(), source.Reference); + return Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(content))); + } + + private static bool TryParseRepositoryParts(string value, out string owner, out string repository) + { + owner = ""; + repository = ""; + var parts = value.Split('/'); + if (parts.Length != 2 || parts.Any(static part => !IsGitHubName(part))) + return false; + owner = parts[0]; + repository = parts[1]; + return true; + } + + private static bool IsGitHubName(string value) + => value.Length > 0 && value.Length <= 100 + && value is not "." and not ".." + && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.'); + + private static bool IsLowerKebab(string value) + { + if (value[0] is '-' || value[^1] is '-') + return false; + var previousHyphen = false; + foreach (var c in value) + { + if (c is '-') + { + if (previousHyphen) + return false; + previousHyphen = true; + continue; + } + if (!char.IsAsciiLetterLower(c) && !char.IsAsciiDigit(c)) + return false; + previousHyphen = false; + } + return true; + } +} + /// /// A single skill server feed source. /// diff --git a/src/Netclaw.Configuration/SkillSyncResult.cs b/src/Netclaw.Configuration/SkillSyncResult.cs index 7085fe091..144dabdd6 100644 --- a/src/Netclaw.Configuration/SkillSyncResult.cs +++ b/src/Netclaw.Configuration/SkillSyncResult.cs @@ -39,6 +39,12 @@ public sealed class SourceRow : IWireType /// A safe operator message. This value never includes secrets. public string? Error { get; init; } + + /// The installed Git commit, when this row represents a Git plugin. + public string? Commit { get; init; } + + /// The declared plugin version, when available. + public string? Version { get; init; } } /// Result data for the inventory publication owned by this pass. diff --git a/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs new file mode 100644 index 000000000..642cd3d5c --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs @@ -0,0 +1,180 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Netclaw.Configuration; +using Netclaw.Daemon.Services; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Daemon.Tests.Services; + +public sealed class GitSkillPluginStateStoreTests : IDisposable +{ + private const string Commit = "13e26d39ed01d97ea592235d041304d289f4ba07"; + private const string LaterCommit = "23e26d39ed01d97ea592235d041304d289f4ba08"; + private readonly DisposableTempDir _temp = new(); + + public void Dispose() => _temp.Dispose(); + + [Fact] + public async Task Migration_and_store_preserve_receipts_and_scope_rejections_by_fingerprint() + { + var paths = new NetclawPaths(_temp.Path); + var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); + var migrator = new SchemaMigrator(paths, NullLogger.Instance); + await migrator.MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, time); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + + Assert.True(await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "bad content", true, + TestContext.Current.CancellationToken)); + Assert.Null(await store.GetRejectionAsync( + source.Name, new string('a', 64), Commit, + TestContext.Current.CancellationToken)); + + await store.SaveReceiptAsync(source, Commit, "1.0.0", TestContext.Current.CancellationToken); + + var receipt = Assert.Single(await store.LoadReceiptsAsync(TestContext.Current.CancellationToken)); + Assert.Equal(fingerprint, receipt.SourceFingerprint); + Assert.Equal(Commit, receipt.InstalledCommit); + Assert.Equal(Commit, receipt.LastObservedCommit); + Assert.NotNull(await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Last_observed_commit_changes_without_replacing_the_installed_commit() + { + var paths = new NetclawPaths(_temp.Path); + var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, time); + var source = Source(); + await store.SaveReceiptAsync(source, Commit, "1.0.0", TestContext.Current.CancellationToken); + var installed = await store.GetReceiptAsync(source.Name, TestContext.Current.CancellationToken); + Assert.NotNull(installed); + + time.Advance(TimeSpan.FromHours(1)); + Assert.True(await store.UpdateLastObservedCommitAsync( + source.Name, LaterCommit, TestContext.Current.CancellationToken)); + + var observed = await store.GetReceiptAsync(source.Name, TestContext.Current.CancellationToken); + Assert.NotNull(observed); + Assert.Equal(Commit, observed.InstalledCommit); + Assert.Equal(LaterCommit, observed.LastObservedCommit); + Assert.Equal(installed.InstalledAt, observed.InstalledAt); + Assert.False(await store.UpdateLastObservedCommitAsync( + "missing", LaterCommit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RemoveSourcesExcept_removes_rejection_only_sources() + { + var paths = new NetclawPaths(_temp.Path); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "bad content", false, + TestContext.Current.CancellationToken); + + await store.RemoveSourcesExceptAsync([], TestContext.Current.CancellationToken); + + Assert.Null(await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveRejection_sanitizes_and_limits_the_durable_reason() + { + var paths = new NetclawPaths(_temp.Path); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + var reason = "bad\r\n" + new string('x', GitSkillPluginStateStore.MaximumRejectionReasonLength + 100); + + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, reason, true, TestContext.Current.CancellationToken); + + var rejection = await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.Equal(GitSkillPluginStateStore.MaximumRejectionReasonLength, rejection.Reason.Length); + Assert.DoesNotContain('\r', rejection.Reason); + Assert.DoesNotContain('\n', rejection.Reason); + } + + [Fact] + public async Task SaveRejection_preserves_a_later_security_classification() + { + var paths = new NetclawPaths(_temp.Path); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "invalid metadata", false, + TestContext.Current.CancellationToken); + + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + + var rejection = await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.True(rejection.SecurityRejection); + Assert.Equal("security rejection", rejection.Reason); + } + + [Fact] + public async Task Security_alert_claim_succeeds_once_and_rejects_nonsecurity_records() + { + var paths = new NetclawPaths(_temp.Path); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new GitSkillPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "invalid metadata", false, + TestContext.Current.CancellationToken); + + Assert.False(await store.TryClaimSecurityAlertAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + + await store.SaveRejectionAsync( + source.Name, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + Assert.True(await store.TryClaimSecurityAlertAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + Assert.False(await store.TryClaimSecurityAlertAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); + + var rejection = await store.GetRejectionAsync( + source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.True(rejection.AlertEmitted); + } + + private static GitSkillPluginSource Source() => new() + { + Name = "fixture", + Repository = "owner/repository", + Format = "codex", + ReferenceKind = GitSkillPluginReferenceKind.Branch, + Reference = "main", + }; +} diff --git a/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs b/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs new file mode 100644 index 000000000..256e2d27c --- /dev/null +++ b/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs @@ -0,0 +1,309 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Data.Sqlite; +using Netclaw.Configuration; + +namespace Netclaw.Daemon.Services; + +internal sealed record GitSkillPluginReceipt( + string SourceName, + string Repository, + string Format, + string? Subdirectory, + GitSkillPluginReferenceKind ReferenceKind, + string Reference, + string SourceFingerprint, + string InstalledCommit, + string LastObservedCommit, + string? InstalledVersion, + DateTimeOffset InstalledAt); + +internal sealed record GitSkillPluginRejection( + string SourceName, + string SourceFingerprint, + string Commit, + string Reason, + bool SecurityRejection, + bool AlertEmitted, + DateTimeOffset RejectedAt); + +internal sealed class GitSkillPluginStateStore +{ + internal const int MaximumRejectionReasonLength = 1_024; + private readonly string _connectionString; + private readonly TimeProvider _timeProvider; + + public GitSkillPluginStateStore(NetclawPaths paths, TimeProvider timeProvider) + { + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = paths.SqliteDbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + }.ToString(); + _timeProvider = timeProvider; + } + + public async Task> LoadReceiptsAsync( + CancellationToken cancellationToken) + { + var receipts = new List(); + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT source_name, repository_url, plugin_format, plugin_subdirectory, + reference_kind, reference_value, source_fingerprint, installed_commit, + last_observed_commit, installed_version, installed_at + FROM git_skill_plugin_receipts + ORDER BY source_name; + """; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + receipts.Add(new GitSkillPluginReceipt( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + Enum.Parse(reader.GetString(4)), + reader.GetString(5), + reader.GetString(6), + reader.GetString(7), + reader.GetString(8), + reader.IsDBNull(9) ? null : reader.GetString(9), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(10)))); + } + + return receipts; + } + + public async Task GetReceiptAsync( + string sourceName, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT repository_url, plugin_format, plugin_subdirectory, + reference_kind, reference_value, source_fingerprint, installed_commit, + last_observed_commit, installed_version, installed_at + FROM git_skill_plugin_receipts + WHERE source_name = $source; + """; + command.Parameters.AddWithValue("$source", sourceName); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return new GitSkillPluginReceipt( + sourceName, + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + Enum.Parse(reader.GetString(3)), + reader.GetString(4), + reader.GetString(5), + reader.GetString(6), + reader.GetString(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(9))); + } + + public async Task SaveReceiptAsync( + GitSkillPluginSource source, + string commit, + string? version, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO git_skill_plugin_receipts ( + source_name, repository_url, plugin_format, plugin_subdirectory, + reference_kind, reference_value, source_fingerprint, installed_commit, + last_observed_commit, installed_version, installed_at) + VALUES ($source, $repository, $format, $subdirectory, + $referenceKind, $reference, $fingerprint, $commit, $commit, $version, $installedAt) + ON CONFLICT(source_name) DO UPDATE SET + repository_url = excluded.repository_url, + plugin_format = excluded.plugin_format, + plugin_subdirectory = excluded.plugin_subdirectory, + reference_kind = excluded.reference_kind, + reference_value = excluded.reference_value, + source_fingerprint = excluded.source_fingerprint, + installed_commit = excluded.installed_commit, + last_observed_commit = excluded.last_observed_commit, + installed_version = excluded.installed_version, + installed_at = excluded.installed_at; + """; + command.Parameters.AddWithValue("$source", source.Name); + command.Parameters.AddWithValue("$repository", source.Repository); + command.Parameters.AddWithValue("$format", source.Format); + command.Parameters.AddWithValue("$subdirectory", (object?)source.Subdirectory ?? DBNull.Value); + command.Parameters.AddWithValue("$referenceKind", source.ReferenceKind.ToString()); + command.Parameters.AddWithValue("$reference", source.Reference); + command.Parameters.AddWithValue("$fingerprint", GitSkillPluginSourceValidator.Fingerprint(source)); + command.Parameters.AddWithValue("$commit", commit); + command.Parameters.AddWithValue("$version", (object?)version ?? DBNull.Value); + command.Parameters.AddWithValue("$installedAt", _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task UpdateLastObservedCommitAsync( + string sourceName, + string commit, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + UPDATE git_skill_plugin_receipts + SET last_observed_commit = $commit + WHERE source_name = $source; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$commit", commit); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task GetRejectionAsync( + string sourceName, + string sourceFingerprint, + string commit, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT reason, security_rejection, alert_emitted, rejected_at + FROM git_skill_plugin_rejections + WHERE source_name = $source AND source_fingerprint = $fingerprint + AND commit_identity = $commit; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return new GitSkillPluginRejection( + sourceName, + sourceFingerprint, + commit, + reader.GetString(0), + reader.GetInt64(1) != 0, + reader.GetInt64(2) != 0, + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3))); + } + + public async Task SaveRejectionAsync( + string sourceName, + string sourceFingerprint, + string commit, + string reason, + bool securityRejection, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO git_skill_plugin_rejections ( + source_name, source_fingerprint, commit_identity, reason, security_rejection, + alert_emitted, rejected_at) + VALUES ($source, $fingerprint, $commit, $reason, $security, 0, $rejectedAt) + ON CONFLICT(source_name, source_fingerprint, commit_identity) DO UPDATE SET + reason = excluded.reason, + security_rejection = MAX( + git_skill_plugin_rejections.security_rejection, + excluded.security_rejection), + rejected_at = excluded.rejected_at; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + command.Parameters.AddWithValue("$reason", SanitizeReason(reason)); + command.Parameters.AddWithValue("$security", securityRejection ? 1 : 0); + command.Parameters.AddWithValue("$rejectedAt", _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task TryClaimSecurityAlertAsync( + string sourceName, + string sourceFingerprint, + string commit, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + UPDATE git_skill_plugin_rejections + SET alert_emitted = 1 + WHERE source_name = $source AND source_fingerprint = $fingerprint + AND commit_identity = $commit AND security_rejection = 1 + AND alert_emitted = 0; + """; + command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task RemoveSourcesExceptAsync( + IReadOnlyCollection sourceNames, + CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + var names = sourceNames.ToHashSet(StringComparer.Ordinal); + var stored = new HashSet(StringComparer.Ordinal); + await using (var read = connection.CreateCommand()) + { + read.Transaction = transaction; + read.CommandText = + "SELECT source_name FROM git_skill_plugin_receipts UNION SELECT source_name FROM git_skill_plugin_rejections;"; + await using var reader = await read.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + stored.Add(reader.GetString(0)); + } + + foreach (var source in stored.Where(name => !names.Contains(name))) + { + foreach (var table in new[] { "git_skill_plugin_receipts", "git_skill_plugin_rejections" }) + { + await using var delete = connection.CreateCommand(); + delete.Transaction = transaction; + delete.CommandText = $"DELETE FROM {table} WHERE source_name = $source;"; + delete.Parameters.AddWithValue("$source", source); + await delete.ExecuteNonQueryAsync(cancellationToken); + } + } + + await transaction.CommitAsync(cancellationToken); + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } + + private static string SanitizeReason(string reason) + { + var sanitized = new string(reason.Select(static character => char.IsControl(character) ? ' ' : character).ToArray()) + .Trim(); + return sanitized.Length <= MaximumRejectionReasonLength + ? sanitized + : sanitized[..MaximumRejectionReasonLength]; + } +} diff --git a/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql b/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql new file mode 100644 index 000000000..ca2ef3523 --- /dev/null +++ b/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql @@ -0,0 +1,30 @@ +-- Netclaw SQLite migration 007 +-- Stores managed Git plugin publications and rejected commit identities. + +CREATE TABLE IF NOT EXISTS git_skill_plugin_receipts ( + source_name TEXT NOT NULL PRIMARY KEY, + repository_url TEXT NOT NULL, + plugin_format TEXT NOT NULL, + plugin_subdirectory TEXT, + reference_kind TEXT NOT NULL, + reference_value TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + installed_commit TEXT NOT NULL, + last_observed_commit TEXT NOT NULL, + installed_version TEXT, + installed_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS git_skill_plugin_rejections ( + source_name TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + commit_identity TEXT NOT NULL, + reason TEXT NOT NULL, + security_rejection INTEGER NOT NULL DEFAULT 0, + alert_emitted INTEGER NOT NULL DEFAULT 0, + rejected_at INTEGER NOT NULL, + PRIMARY KEY (source_name, source_fingerprint, commit_identity) +); + +CREATE INDEX IF NOT EXISTS git_skill_plugin_rejections_source_idx + ON git_skill_plugin_rejections(source_name); diff --git a/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs b/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs index 4c7582945..746d50d6f 100644 --- a/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs +++ b/src/Netclaw.Security.Tests/Skills/RegexSkillContentScannerTests.cs @@ -74,10 +74,10 @@ public async Task ScanAsync_high_risk_rejected() Assert.NotNull(result.Reason); } - // -- Detector failure -> Rejected ------------------------------------- + // -- Detector failure -> Failed --------------------------------------- [Fact] - public async Task ScanAsync_detector_failure_rejects_with_scanning_failed() + public async Task ScanAsync_detector_failure_returns_failed_verdict() { var scanner = new RegexSkillContentScanner( new ThrowingPromptInjectionDetector(), @@ -85,7 +85,8 @@ public async Task ScanAsync_detector_failure_rejects_with_scanning_failed() var result = await scanner.ScanAsync("skill", "content", TestContext.Current.CancellationToken); - Assert.Equal(ScanVerdict.Rejected, result.Verdict); + Assert.Equal(ScanVerdict.Failed, result.Verdict); + Assert.False(result.IsAllowed); Assert.Equal("content scanning failed", result.Reason); } diff --git a/src/Netclaw.Security/Skills/ISkillContentScanner.cs b/src/Netclaw.Security/Skills/ISkillContentScanner.cs index d2d821cea..66411e135 100644 --- a/src/Netclaw.Security/Skills/ISkillContentScanner.cs +++ b/src/Netclaw.Security/Skills/ISkillContentScanner.cs @@ -29,7 +29,10 @@ public enum ScanVerdict Warning, /// Content was rejected due to a high-severity match. - Rejected + Rejected, + + /// The scanner could not establish a verdict. + Failed } /// @@ -39,10 +42,11 @@ public enum ScanVerdict /// Explanation when content triggers a warning or rejection; null when allowed. public sealed record SkillScanResult(ScanVerdict Verdict, string? Reason) { - /// Backward-compatible: true when is not . - public bool IsAllowed => Verdict != ScanVerdict.Rejected; + /// True only when the scanner established an allowed verdict. + public bool IsAllowed => Verdict is ScanVerdict.Allowed or ScanVerdict.Warning; public static SkillScanResult Allow() => new(ScanVerdict.Allowed, null); public static SkillScanResult Warn(string reason) => new(ScanVerdict.Warning, reason); public static SkillScanResult Reject(string reason) => new(ScanVerdict.Rejected, reason); + public static SkillScanResult Fail(string reason) => new(ScanVerdict.Failed, reason); } diff --git a/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs b/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs index eea67a801..336dfec59 100644 --- a/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs +++ b/src/Netclaw.Security/Skills/RegexSkillContentScanner.cs @@ -42,7 +42,7 @@ public async Task ScanAsync( catch (Exception ex) { _logger.LogWarning(ex, "Skill content scanning failed for '{SkillName}'", skillName); - return SkillScanResult.Reject("content scanning failed"); + return SkillScanResult.Fail("content scanning failed"); } if (detection.Risk == PromptInjectionRisk.None) From a474ea738a9d8a8c8205475c233ee3f70fed6bf6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 11 Sep 2026 20:46:30 -0500 Subject: [PATCH 2/4] Clear SQLite pools after plugin state tests --- .../Services/GitSkillPluginStateStoreTests.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs index 642cd3d5c..2810a07fa 100644 --- a/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs +++ b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs @@ -17,13 +17,20 @@ public sealed class GitSkillPluginStateStoreTests : IDisposable private const string Commit = "13e26d39ed01d97ea592235d041304d289f4ba07"; private const string LaterCommit = "23e26d39ed01d97ea592235d041304d289f4ba08"; private readonly DisposableTempDir _temp = new(); + private readonly NetclawPaths _paths; - public void Dispose() => _temp.Dispose(); + public GitSkillPluginStateStoreTests() => _paths = new NetclawPaths(_temp.Path); + + public void Dispose() + { + SqliteTestPools.Clear(_paths); + _temp.Dispose(); + } [Fact] public async Task Migration_and_store_preserve_receipts_and_scope_rejections_by_fingerprint() { - var paths = new NetclawPaths(_temp.Path); + var paths = _paths; var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); var migrator = new SchemaMigrator(paths, NullLogger.Instance); await migrator.MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); @@ -51,7 +58,7 @@ public async Task Migration_and_store_preserve_receipts_and_scope_rejections_by_ [Fact] public async Task Last_observed_commit_changes_without_replacing_the_installed_commit() { - var paths = new NetclawPaths(_temp.Path); + var paths = _paths; var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); await new SchemaMigrator(paths, NullLogger.Instance) .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); @@ -77,7 +84,7 @@ public async Task Last_observed_commit_changes_without_replacing_the_installed_c [Fact] public async Task RemoveSourcesExcept_removes_rejection_only_sources() { - var paths = new NetclawPaths(_temp.Path); + var paths = _paths; await new SchemaMigrator(paths, NullLogger.Instance) .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); var store = new GitSkillPluginStateStore(paths, TimeProvider.System); @@ -96,7 +103,7 @@ await store.SaveRejectionAsync( [Fact] public async Task SaveRejection_sanitizes_and_limits_the_durable_reason() { - var paths = new NetclawPaths(_temp.Path); + var paths = _paths; await new SchemaMigrator(paths, NullLogger.Instance) .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); var store = new GitSkillPluginStateStore(paths, TimeProvider.System); @@ -118,7 +125,7 @@ await store.SaveRejectionAsync( [Fact] public async Task SaveRejection_preserves_a_later_security_classification() { - var paths = new NetclawPaths(_temp.Path); + var paths = _paths; await new SchemaMigrator(paths, NullLogger.Instance) .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); var store = new GitSkillPluginStateStore(paths, TimeProvider.System); @@ -142,7 +149,7 @@ await store.SaveRejectionAsync( [Fact] public async Task Security_alert_claim_succeeds_once_and_rejects_nonsecurity_records() { - var paths = new NetclawPaths(_temp.Path); + var paths = _paths; await new SchemaMigrator(paths, NullLogger.Instance) .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); var store = new GitSkillPluginStateStore(paths, TimeProvider.System); From 3d9e7efce04ec3d7a977b84157f742cc1ffbcd3f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 12 Sep 2026 09:10:59 -0500 Subject: [PATCH 3/4] Reject nonportable plugin paths --- .../GitSkillPluginSourceValidatorTests.cs | 7 +++++ src/Netclaw.Configuration/SkillFeedsConfig.cs | 26 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs b/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs index 3a930f7b1..ae4c8e553 100644 --- a/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs +++ b/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs @@ -83,6 +83,13 @@ public void Collection_validation_rejects_more_than_twenty_sources() [InlineData("./plugin/")] [InlineData("/plugin")] [InlineData("plugin/../other")] + [InlineData("plugin/file:stream")] + [InlineData("plugin/CON.md")] + [InlineData("plugin/CON .md")] + [InlineData("plugin/COM¹.md")] + [InlineData("plugin/LPT³.md")] + [InlineData("plugin/trailing.")] + [InlineData("plugin/trailing ")] public void Source_validation_rejects_a_noncanonical_subdirectory(string subdirectory) { var source = Source(); diff --git a/src/Netclaw.Configuration/SkillFeedsConfig.cs b/src/Netclaw.Configuration/SkillFeedsConfig.cs index 13103bbb0..9952a9387 100644 --- a/src/Netclaw.Configuration/SkillFeedsConfig.cs +++ b/src/Netclaw.Configuration/SkillFeedsConfig.cs @@ -54,6 +54,15 @@ public sealed class GitSkillPluginSource public static class GitSkillPluginSourceValidator { public const int MaximumSourceCount = 20; + private static readonly char[] WindowsInvalidPathCharacters = ['<', '>', ':', '"', '|', '?', '*']; + private static readonly HashSet WindowsReservedPathNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "COM¹", "COM²", "COM³", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "LPT¹", "LPT²", "LPT³", + }; public static bool TryValidateSources(IReadOnlyList sources, out string error) { @@ -228,8 +237,7 @@ public static bool TryNormalizeRelativePath( candidate = candidate.Trim('/'); var segments = candidate.Split('/'); if (Path.IsPathRooted(value) || candidate.Length > 512 || candidate.Length == 0 - || segments.Any(static segment => segment.Length is 0 or > 255 || segment is "." or "..") - || candidate.Any(char.IsControl)) + || segments.Any(static segment => !IsPortablePathSegment(segment))) { error = "The repository subdirectory must be a safe relative path within the path limits."; return false; @@ -238,6 +246,20 @@ public static bool TryNormalizeRelativePath( return true; } + private static bool IsPortablePathSegment(string segment) + { + if (segment.Length is 0 or > 255 || segment is "." or ".." + || segment.EndsWith(' ') || segment.EndsWith('.') + || segment.Any(char.IsControl) + || segment.IndexOfAny(WindowsInvalidPathCharacters) >= 0) + { + return false; + } + + var stem = segment.Split('.', 2)[0].TrimEnd(' ', '.'); + return !WindowsReservedPathNames.Contains(stem); + } + public static string Fingerprint(GitSkillPluginSource source) { var content = string.Join('\n', source.Repository, source.Subdirectory ?? "", source.Format, From 9573027bf425f29882712ea36263351b2ffe872d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 12 Sep 2026 15:48:30 -0500 Subject: [PATCH 4/4] Adopt plugin-neutral source and state contracts --- docs/prd/PRD-004-cli-onboarding-and-config.md | 35 ++- docs/spec/GLOSSARY.md | 23 ++ docs/spec/SPEC-004-cli-contract.md | 106 ++++++- .../.openspec.yaml | 2 + .../design.md | 203 +++++++++++++ .../proposal.md | 48 ++++ .../specs/managed-agent-plugins/spec.md | 223 +++++++++++++++ .../specs/netclaw-cli/spec.md | 93 ++++++ .../specs/skill-tools/spec.md | 49 ++++ .../tasks.md | 58 ++++ .../Doctor/ConfigSchemaDoctorCheckTests.cs | 19 +- .../SkillSourcesConfigViewModelTests.cs | 9 +- .../Tui/Config/SkillSourcesConfigViewModel.cs | 4 +- ...s => ManagedPluginSourceValidatorTests.cs} | 60 ++-- src/Netclaw.Configuration/ManagedPluginApi.cs | 78 +++++ .../Schemas/netclaw-config.v1.schema.json | 8 +- src/Netclaw.Configuration/SkillFeedsConfig.cs | 56 ++-- .../Services/GitSkillPluginStateStoreTests.cs | 187 ------------ .../Services/ManagedPluginStateStoreTests.cs | 267 ++++++++++++++++++ ...ateStore.cs => ManagedPluginStateStore.cs} | 236 +++++++++++----- .../sqlite/007_git_skill_plugins.sql | 18 +- 21 files changed, 1454 insertions(+), 328 deletions(-) create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/.openspec.yaml create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/design.md create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/proposal.md create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/specs/managed-agent-plugins/spec.md create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/specs/netclaw-cli/spec.md create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/specs/skill-tools/spec.md create mode 100644 openspec/changes/adopt-agent-plugin-package-contract/tasks.md rename src/Netclaw.Configuration.Tests/{GitSkillPluginSourceValidatorTests.cs => ManagedPluginSourceValidatorTests.cs} (58%) create mode 100644 src/Netclaw.Configuration/ManagedPluginApi.cs delete mode 100644 src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs create mode 100644 src/Netclaw.Daemon.Tests/Services/ManagedPluginStateStoreTests.cs rename src/Netclaw.Daemon/Services/{GitSkillPluginStateStore.cs => ManagedPluginStateStore.cs} (56%) diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index 8ae52f1b8..54e6d1a04 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -106,10 +106,16 @@ Top-level domains: 9. `Security & Access` The daemon restores its system skills from the installed binary. The `Skill Sources` -domain configures user and private server sources. It does not configure system skill updates. +domain configures local folders and private server sources. It does not configure system skill updates. Operators can use `netclaw skill sync` to run the configured external source sync pass. The command does not add sources or write configuration. +The top-level `netclaw plugin` family manages package sources from public GitHub repositories. +Agent Plugins 1.0.0 is the primary package contract. +Host-specific package formats are explicit compatibility contracts. +The daemon can publish supported plugin skills into the existing skill inventory. +Package metadata cannot grant tool, subagent, shell, MCP, or filesystem authority. + Command ownership stays explicit: 1. `netclaw init` owns bootstrap and identity re-entry @@ -193,6 +199,14 @@ Command ownership stays explicit: - `netclaw test smoke [--provider ollama]` — end-to-end smoke test through daemon +### Managed Plugins (daemon required) + +- `netclaw plugin install ` — configure, acquire, validate, and publish a managed plugin +- `netclaw plugin list [--json]` — show configured sources and installed package state +- `netclaw plugin update |--all` — run the shared sync pass and report plugin results +- `netclaw plugin enable|disable ` — change source availability +- `netclaw plugin remove ` — remove a source and its durable sync state + ## Requirements ### CLI-001 Onboarding @@ -345,6 +359,25 @@ The daemon (`Netclaw.Daemon`) SHALL run as a standalone service with Slack Socke Mode adapter, Akka actor system, scheduled task timers, SignalR hub, and health endpoints. No TUI rendering. This is the primary production entry point. +### CLI-014 Managed Plugin Lifecycle + +The CLI SHALL expose managed package lifecycle operations through the top-level +`netclaw plugin` command. The CLI SHALL use the authenticated daemon API. +It SHALL not write daemon configuration directly. + +The default package format SHALL detect Agent Plugins 1.0.0 before supported +compatibility manifests. Explicit format selection SHALL inspect one format only. +A selected invalid manifest SHALL fail without a fallback to another format. + +`netclaw plugin list --json` SHALL emit one stable JSON document without prose. +Safe daemon problem details SHALL remain visible in CLI error output. +Invalid or unbounded daemon bodies SHALL produce a bounded status message. + +The first release supports public GitHub repositories, portable Agent Plugins, +and Codex compatibility manifests. It excludes marketplaces, private Git +credentials, plugin subagents, MCP activation, hooks, LSP configuration, and +host execution. + ## UX Requirements - human-readable output by default, machine-friendly JSON opt-in (`--json`) diff --git a/docs/spec/GLOSSARY.md b/docs/spec/GLOSSARY.md index 2c250976d..75e8b1f78 100644 --- a/docs/spec/GLOSSARY.md +++ b/docs/spec/GLOSSARY.md @@ -175,6 +175,29 @@ skill_read_resource("netclaw-operations", "SKILL.md") **Code anchors:** `SkillReadResourceTool`, `FileSkillSource` +### Agent plugin + +An agent plugin is a portable package that can contain supported agent components. +Agent Plugins 1.0.0 defines root `plugin.json`, skills under `skills/`, and optional MCP configuration. + +Netclaw initially publishes only the skill component type. +Unsupported components do not grant authority and remain inactive. + +Host-specific manifests are compatibility formats. +They do not change the portable Agent Plugins contract. + +**Code anchors:** `GitSkillPluginAcquirer`, `SkillInventoryRefresher` + +### Managed plugin source + +A managed plugin source is an operator-configured Git source for one plugin package. +Its source ID is the durable key for configuration, local paths, receipts, and CLI mutations. + +The package manifest name is separate installed metadata. +A package rename does not silently change the source ID. + +**Code anchors:** `ManagedPluginSource`, `ManagedPluginStateStore` + ### Workspace tool A workspace tool reads, lists, writes, edits, attaches, or selects files and diff --git a/docs/spec/SPEC-004-cli-contract.md b/docs/spec/SPEC-004-cli-contract.md index afef23b2a..1ba841ec3 100644 --- a/docs/spec/SPEC-004-cli-contract.md +++ b/docs/spec/SPEC-004-cli-contract.md @@ -107,7 +107,7 @@ An authenticated request can join a pass that the startup path, timer, or anothe `ServerFeedSkillSyncActor` owns the timer, active pass state, waiters, and lifetime token. This state is actor-local. The CLI owns only its call-local request wait. -`ServerFeedSkillSyncService` runs one pass and keeps no lifecycle state. +The external sync coordinator runs one pass through server-feed and managed plugin participants. The feed helpers retain the existing durable files and sync receipts. ```text @@ -142,7 +142,8 @@ An interval of zero disables periodic checks. Startup and manual checks remain a | The daemon returns HTTP 503 | Exit 1; report that the daemon cannot run the pass now | | The daemon returns another HTTP error | Exit 1; report the status code, distinct from a connection failure | -The response includes one pass ID, per-source counts, sidecar status, and the final inventory result. +The response includes one pass ID, per-source counts, source type, sidecar status, and the final inventory result. +The source type distinguishes a server feed from a Git plugin when both sources use the same name. The service assigns the pass ID before source work and includes it in its start and completion logs. The response contains no derived overall success field. The CLI computes its exit code from the source and inventory results. Overlapping callers receive the same pass ID. Source errors in this response do not include credentials or remote response bodies. @@ -157,6 +158,107 @@ For example, a healthy feed can update while another feed returns HTTP 500. The A rejected skill retains its prior bytes and receipt. Other accepted skills from that feed can still update. Download failures do not create security alerts. This change does not alter the existing scanner or alert policy. +### 8) Managed Agent Plugins + +The CLI manages public GitHub plugin packages through the paired daemon. +The daemon owns source validation, reference resolution, configuration writes, and installed state. +The CLI never writes plugin configuration on the client host. + +| Command | Behavior | +|---|---| +| `netclaw plugin install [options]` | Configure a source, restart the daemon, run a sync, and verify installation | +| `netclaw plugin list [--json]` | List configured sources and installed package state | +| `netclaw plugin update [--retry-rejected]` | Run the shared sync pass and report one source result | +| `netclaw plugin update --all [--retry-rejected]` | Run the shared sync pass and report all plugin results | +| `netclaw plugin enable ` | Enable a source and verify its installation | +| `netclaw plugin disable ` | Disable a source and remove it from the live inventory | +| `netclaw plugin remove ` | Remove a source and its durable sync state | +| `netclaw skill sync --retry-rejected` | Retry rejected commits during the requested sync pass | + +The install command accepts `--branch`, `--tag`, or `--commit`. +The operator can select only one reference option. +Each plugin mutation requires confirmation unless the operator supplies `--yes`. +The default format is `auto`. +The supported formats are `auto`, `agent-plugin`, and `codex`. +The daemon resolves an omitted reference to the repository's default branch. +The daemon stores the resolved branch name. +The daemon resolves a tag once and stores its exact commit. +The durable source format supports only `Branch` and `Commit`. + +The daemon applies this ordered flow: + +```text +CLI -> authenticated daemon route + validate the repository, source ID, format, path, reference, and timeout + resolve the default branch or tag when required + write the canonical source to SkillFeeds.Plugins + return the current restart generation +CLI -> wait for a later healthy daemon generation +CLI -> request one immediate skill sync +daemon -> download, inspect, scan, and publish the candidate +CLI -> read the plugin state and report success or failure +``` + +At daemon startup, the sync service publishes valid receipt-backed plugin directories before it starts remote work. + +The configuration file is durable state. +The SQLite receipt and rejection tables are durable state. +The sync actor owns active and queued pass state. +The CLI owns its request and restart wait state. + +The source ID is the stable operator key for CLI mutations and managed paths. +The package manifest name is a separate installed metadata field. +The list command shows both values when a manifest is available. +The JSON form uses `sourceId`, `manifestName`, `sourceFormat`, and `manifestFormat`. + +Auto format selection uses this order: + +1. Root `plugin.json` with the recognized Agent Plugins 1.0.0 schema. +2. `.codex-plugin/plugin.json` as a compatibility manifest. + +An explicit format inspects only that format. +Netclaw does not combine declarations from multiple manifests. +A selected invalid manifest fails without a fallback to another manifest. + +The portable adapter uses the standard root manifest and fixed `skills/` directory. +An invalid portable skill is skipped without blocking valid sibling skills. +An invalid portable manifest rejects the candidate. +A scanner security rejection rejects the complete candidate. + +The portable adapter accepts periods in package names. +It accepts any string version and does not require SemVer. +Unsupported components produce diagnostics and remain inactive. +Package metadata cannot grant Netclaw authority. +A portable package can install with zero supported skills. +The sync result reports excluded components and skipped skills as notices. + +The Codex adapter reads only `.codex-plugin/plugin.json`. +It keeps its existing declared skill-root and whole-candidate syntax rules. + +A valid source remains configured when a download or candidate check fails. +The plugin then has the `NotInstalled` state when no prior receipt exists. +A failed source change keeps the prior installed content active. +An invalid source or unresolved tag fails before configuration persistence. +All configuration writes preserve unrelated JSON and existing `SkillFeeds` data. +The configuration TUI must preserve `SkillFeeds.Plugins` during each load and save cycle. + +An ordinary pass skips a known rejected commit. +An explicit retry pass tests that commit again. +A retry request waits behind an active ordinary pass. +An ordinary request can join either active pass. +A successful retry removes the matching durable rejection. + +For example, `--tag v1.2.0` can resolve to commit `13e26d39...`. +The daemon stores that commit before its restart and acquires the content after restart. + +For a negative example, an unknown tag returns an error before the daemon changes the configuration. +A scanner rejection keeps the source configured and records the rejected commit. + +The daemon exposes plugin lifecycle routes at `/api/plugins`. +Each route uses the existing authenticated daemon policy. +The daemon returns safe RFC 9457 problem details for expected failures. +The CLI shows a valid safe detail and otherwise shows a bounded status message. + ## Output and Exit Codes - default output: human readable text diff --git a/openspec/changes/adopt-agent-plugin-package-contract/.openspec.yaml b/openspec/changes/adopt-agent-plugin-package-contract/.openspec.yaml new file mode 100644 index 000000000..2b596d13d --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/adopt-agent-plugin-package-contract/design.md b/openspec/changes/adopt-agent-plugin-package-contract/design.md new file mode 100644 index 000000000..7525973ec --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/design.md @@ -0,0 +1,203 @@ +## Context + +The five-PR stack adds secure Git acquisition for Codex skill packages. +It also adds durable source state, daemon routes, sync publication, and CLI operations. + +The current `GitSkillPluginAcquirer` owns Git transport, archive checks, Codex manifest rules, extraction, content scans, and candidate creation. +The current `ServerFeedSkillSyncService` also owns the managed plugin state machine. + +This shape makes a Codex compatibility format the core domain. +It also makes each future package format increase two existing complexity hotspots. + +This design uses [the engineering glossary](../../../docs/spec/GLOSSARY.md) for durable state, authority, and skill resource terms. + +## Goals / Non-Goals + +**Goals:** + +- Make Agent Plugins 1.0.0 the primary package contract. +- Keep Codex support as an explicit compatibility format. +- Preserve the current Git and file security controls. +- Keep one actor, scheduler, registry, and inventory refresh path. +- Give the CLI a plugin-level lifecycle contract. +- Isolate package rules from Git transport rules. +- Preserve the previous complete package after a failed update. + +**Non-Goals:** + +- Add marketplace discovery or registration. +- Add private Git authentication or non-GitHub hosts. +- Activate MCP servers, hooks, LSP configuration, commands, or subagents. +- Execute repository files during acquisition. +- Add a second plugin runtime or a second skill registry. +- Preserve the unshipped `skill plugin` CLI or `/api/skills/plugins` route. + +## Decisions + +### Use a plugin-neutral source model + +`ManagedPluginSource.Id` will identify the operator-managed source. +The source ID will control CLI mutations, configuration entries, durable paths, and receipt keys. + +The package manifest will supply `PluginName` after acquisition. +The receipt and list result will expose that name separately. + +This split permits a valid source record before the first package download succeeds. +It also prevents a repository-derived alias from replacing the standard manifest identity. + +The configuration will keep the existing `SkillFeeds` owner for this slice. +That owner already supplies the external sync interval and config watcher path. +A second plugin configuration root would duplicate this data. + +Alternative: Use the manifest name as the source key. +That option requires package acquisition before config persistence and makes a package rename change the lifecycle key. + +### Separate package rules from Git transport rules + +The Git acquirer will retain reference resolution, HTTP policy, archive limits, safe entry enumeration, selected extraction, content scans, and immutable candidate creation. +Package adapters will own manifest location, schema rules, metadata rules, and skill-root selection. + +Schematic flow: + +```text +ManagedPluginSource + -> GitPluginAcquirer resolves one commit and downloads one bounded archive + -> PluginPackageSelector chooses exactly one package adapter + -> AgentPluginPackageAdapter + -> CodexPluginPackageAdapter + -> adapter returns PluginPackageSelection + -> acquirer extracts only selected skill trees + -> scanner validates each selected skill and text resource + -> acquirer seals one immutable candidate directory +``` + +`PluginPackageSelection` will contain the package name, optional version, selected skill roots, notices, and excluded component diagnostics. +The type will contain no filesystem authority and no executable directive. + +Alternative: Create one acquirer for each package format. +That option duplicates Git transport, archive security, resource limits, and immutable publication logic. + +### Select one format without fallback after selection + +The supported format values will be `auto`, `agent-plugin`, and `codex`. +New sources will default to `auto`. + +Auto selection will use this order: + +1. A root `plugin.json` with the recognized Agent Plugins schema. +2. A `.codex-plugin/plugin.json` compatibility manifest. + +An explicit format will inspect only that format. +After a format is selected, a validation failure will reject that format. +Netclaw will not fall through to another manifest. + +This rule prevents a malformed portable manifest from silently selecting a weaker compatibility contract. + +Alternative: Merge declarations from all manifests. +That option creates unclear authority and permits one package view to bypass another package view. + +### Apply Agent Plugins failure isolation + +An invalid portable manifest will reject the plugin candidate. +An invalid discovered portable skill will produce a diagnostic and will not block valid sibling skills. + +A scanner security rejection will still reject the complete candidate. +The scanner decision is a Netclaw publication boundary, not a package syntax decision. + +A Codex compatibility candidate will keep its existing whole-candidate validation rule. +Compatibility behavior will not redefine portable behavior. + +Unsupported Agent Plugins components will produce diagnostics. +They will not make an otherwise valid package fail. + +Alternative: Reject every candidate that contains one invalid skill. +That option conflicts with Agent Plugins failure isolation. + +### Keep package versions as metadata + +The portable adapter will accept any string value for `version`. +It will not require SemVer. + +The existing update policy will compare version strings for exact equality. +If no version exists, the policy will compare commit identities. + +The adapter will accept portable names with lowercase letters, numbers, hyphens, and periods. +Source IDs will retain safe portable path rules for managed directories. + +Alternative: Require SemVer for every format. +That option conflicts with the Agent Plugins metadata contract. + +### Keep one sync actor and add one managed plugin participant + +`ServerFeedSkillSyncActor` will continue to own the active pass, waiters, timer, and lifetime token. +The actor will call one external sync coordinator. + +The coordinator will retain server-feed processing and call the managed plugin participant. +The plugin participant will return source rows and resolved inventory sources. + +The managed plugin participant will own startup publication, update eligibility, durable rejections, alerts, cleanup, and plugin source results. +The coordinator will own the final inventory refresh and complete response. + +Alternative: Add a separate plugin actor and timer. +That option duplicates pass coordination and can expose inconsistent inventory snapshots. + +### Use a plugin-level CLI and daemon API + +The CLI will expose `netclaw plugin install|list|update|enable|disable|remove`. +`netclaw skill sync` will remain the command for the complete external skill sync pass. + +The daemon will expose `/api/plugins` for plugin lifecycle operations. +The existing authenticated daemon policy will protect every route. + +`plugin update` will request the shared sync pass. +It will then report the selected source result. +`plugin update --all` will report all managed plugin source results. + +The CLI will parse safe RFC 9457 problem details from daemon failures. +It will use the status code only when the response has no valid safe detail. + +Alternative: Keep `netclaw skill plugin`. +That option makes one supported component define the package lifecycle namespace. + +### Preserve authority and state ownership + +| Decision or data | Owner | Lifetime | +|---|---|---| +| CLI arguments, confirmation, and wait state | CLI command | Call-local | +| Route authorization and source validation | Daemon API and management service | Call-local | +| Active pass, waiter set, and lifetime token | Sync actor | Actor-local | +| Source configuration | Configuration store | Durable | +| Receipts and rejected commits | Plugin state store | Durable | +| Immutable package revisions | Managed plugin directory | Durable | +| Current accepted skills and prompt index | Inventory refresher | Process-local snapshot | + +Manifest metadata cannot grant tool, subagent, shell, MCP, or file authority. +The runtime will expose accepted skills only through existing logical skill tools and audience policy. + +## Risks / Trade-offs + +- **Risk: A format adapter weakens archive checks.** → The Git acquirer will remain the sole archive and extraction authority. +- **Risk: Portable failure isolation publishes an incomplete skill set.** → The list and sync results will report each skipped skill. +- **Risk: A package rename confuses operators.** → The stable source ID and current manifest name will appear as separate fields. +- **Risk: New API names break a stack consumer.** → The old routes and commands have not shipped, so this change removes them before release. +- **Risk: The coordinator split changes sync order.** → Integration tests will preserve one pass ID, source independence, and one final inventory refresh. +- **Risk: Auto selection hides ambiguity.** → The selector uses fixed precedence and never falls through after selection. +- **Risk: A no-skill portable package appears installed.** → The result will report zero supported skills and all excluded components. + +## Migration Plan + +1. Update PRD-004, SPEC-004, the glossary, and this OpenSpec change. +2. Rename unshipped configuration, state, API, and CLI contracts to plugin-neutral terms. +3. Add the package selector and the Agent Plugins adapter. +4. Convert Codex rules into a compatibility adapter. +5. Extract the managed plugin sync participant from the server-feed service. +6. Update system skills and website issue #119. +7. Run contract, integration, CLI process, eval, native smoke, and repository checks. + +A source revert can restore the old stack because no release contains its config or database schema. +After release, future changes must preserve the plugin source and receipt wire contracts. + +## Open Questions + +No question blocks the first implementation slice. +Marketplace source ownership remains a later product decision. diff --git a/openspec/changes/adopt-agent-plugin-package-contract/proposal.md b/openspec/changes/adopt-agent-plugin-package-contract/proposal.md new file mode 100644 index 000000000..01f726fb4 --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/proposal.md @@ -0,0 +1,48 @@ +## Why + +PRD-004 makes the CLI the primary operator interface for external capabilities. +The current stack exposes Codex skill packages as the core plugin model. + +Netclaw must use the portable Agent Plugins package as its primary contract. +Host-specific manifests must remain explicit compatibility formats. + +## What Changes + +- **BREAKING**: Replace `netclaw skill plugin` with the top-level `netclaw plugin` command family before release. +- **BREAKING**: Replace `/api/skills/plugins` with `/api/plugins` before release. +- Add portable Agent Plugins 1.0.0 package support through root `plugin.json`. +- Keep Codex package support through an explicit compatibility adapter. +- Separate Git archive acquisition from package format interpretation. +- Publish supported skills through the existing skill inventory and logical skill tools. +- Keep one external sync actor and add a focused managed plugin sync participant. +- Add JSON output for plugin list operations. +- Preserve safe daemon problem details in CLI failures. +- Align issues #2134, #2135, and website issue #119 with the portable contract. + +This change supports public GitHub repositories only. +This change does not add a marketplace, private Git credentials, plugin subagents, MCP activation, hooks, LSP configuration, or host execution. + +## Capabilities + +### New Capabilities + +- `managed-agent-plugins`: Define package formats, source lifecycle, durable state, secure acquisition, sync, publication, and failure isolation. + +### Modified Capabilities + +- `netclaw-cli`: Add the top-level plugin command family and its stable text, JSON, error, confirmation, and exit contracts. +- `skill-tools`: Add managed plugin skills to inventory precedence without physical path disclosure or extra authority. + +## Impact + +This change affects PRD-001, PRD-002, and PRD-004 behavior. +It affects configuration, JSON schema, SQLite state, daemon routes, CLI routes, sync services, skill inventory, tests, and system skills. + +The daemon remains the authority for validation, reference resolution, configuration writes, acquisition, and publication. +The CLI remains a thin presentation client. + +The existing archive limits, path containment checks, scanner contract, immutable revision directories, and managed-file protection remain required. +Manifest fields cannot grant Netclaw tool or subagent authority. + +Operators will see a new top-level command family and a new portable package format. +Existing stack commands and routes have no release compatibility guarantee because they have not shipped. diff --git a/openspec/changes/adopt-agent-plugin-package-contract/specs/managed-agent-plugins/spec.md b/openspec/changes/adopt-agent-plugin-package-contract/specs/managed-agent-plugins/spec.md new file mode 100644 index 000000000..afdd3e31c --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/specs/managed-agent-plugins/spec.md @@ -0,0 +1,223 @@ +## ADDED Requirements + +### Requirement: Managed plugin source contract + +The system SHALL store public GitHub plugin sources under the existing external skill source configuration. +Each source SHALL have a stable operator source ID that is separate from the package manifest name. + +The source SHALL contain a canonical repository, package format, optional subdirectory, resolved branch or commit, enabled state, and timeout. +The configuration schema SHALL reject unknown or invalid source properties. + +#### Scenario: Valid source persists in canonical form + +- **GIVEN** an operator supplies a canonical public GitHub repository and a valid source ID +- **WHEN** the daemon accepts the install request +- **THEN** the daemon persists one canonical managed plugin source +- **AND** the runtime source validator accepts the persisted representation + +#### Scenario: Invalid source fails before persistence + +- **GIVEN** an install request has an unsafe source ID, path, reference, format, or repository +- **WHEN** the daemon validates the request +- **THEN** the daemon returns a safe validation error +- **AND** the configuration remains byte-identical + +### Requirement: Deterministic package format selection + +The system SHALL support `auto`, `agent-plugin`, and `codex` package formats. +New managed sources SHALL default to `auto`. + +Auto selection SHALL prefer a recognized Agent Plugins root manifest over a Codex compatibility manifest. +An explicit format SHALL inspect only its selected manifest. +The system SHALL NOT fall through to another format after it selects a manifest that fails validation. + +#### Scenario: Auto selects the portable root + +- **GIVEN** an archive has a valid Agent Plugins root manifest and a Codex compatibility manifest +- **WHEN** the source uses `auto` +- **THEN** the selector uses only the Agent Plugins root manifest +- **AND** it does not merge Codex declarations + +#### Scenario: Invalid selected root does not fall through + +- **GIVEN** an archive has a recognized but invalid Agent Plugins root manifest +- **AND** the archive has a valid Codex compatibility manifest +- **WHEN** the source uses `auto` +- **THEN** the candidate fails with the portable manifest error +- **AND** the selector does not use the Codex manifest + +### Requirement: Agent Plugins manifest compliance + +The portable adapter SHALL require root `plugin.json` with the recognized Agent Plugins 1.0.0 schema. +It SHALL apply the closed manifest rules and the standard non-fatal exceptions. + +The adapter SHALL accept a portable name with lowercase letters, numbers, hyphens, and periods. +It SHALL treat `version` as optional string metadata and SHALL NOT require SemVer. +It SHALL ignore supported `extensions` entries that Netclaw does not implement. + +#### Scenario: Portable package with a dotted name loads + +- **GIVEN** a root manifest declares the recognized schema and name `acme.tools` +- **AND** its `skills/` directory contains one valid skill +- **WHEN** the portable adapter inspects the package +- **THEN** the adapter accepts `acme.tools` as the plugin name +- **AND** it selects the valid skill + +#### Scenario: Non-SemVer version remains valid + +- **GIVEN** a valid portable manifest declares version `release-2026-09` +- **WHEN** the portable adapter validates the manifest +- **THEN** the adapter accepts the version string +- **AND** the update policy compares it as exact metadata + +#### Scenario: Invalid required manifest field rejects the plugin + +- **GIVEN** a root manifest lacks the recognized schema or a valid name +- **WHEN** the portable adapter validates the manifest +- **THEN** the adapter rejects the plugin candidate +- **AND** no package file reaches the active inventory + +### Requirement: Portable component failure isolation + +The portable adapter SHALL discover skills only under immediate children of `skills/`. +It SHALL skip an invalid discovered skill and continue with valid sibling skills. + +The adapter SHALL report unsupported or invalid component types without activating them. +A portable package with no supported components SHALL remain a valid installed package. +A content scanner security rejection SHALL reject the complete candidate. + +#### Scenario: Invalid skill does not block a valid sibling + +- **GIVEN** a portable package has one valid skill and one invalid skill +- **WHEN** the daemon builds the candidate +- **THEN** the candidate includes the valid skill +- **AND** the result reports the skipped invalid skill + +#### Scenario: Unsupported MCP component does not activate + +- **GIVEN** a portable package has valid skills and an `mcp.json` file +- **WHEN** Netclaw imports the package +- **THEN** Netclaw publishes the valid skills +- **AND** Netclaw reports that MCP activation is unsupported +- **AND** Netclaw does not start an MCP process or connection + +#### Scenario: Scanner security rejection blocks publication + +- **GIVEN** the scanner rejects one selected skill or text resource +- **WHEN** the daemon builds the candidate +- **THEN** the daemon rejects the complete candidate +- **AND** the previous complete package remains active + +### Requirement: Codex compatibility isolation + +The Codex adapter SHALL read `.codex-plugin/plugin.json` only. +It SHALL preserve the existing declared skill-root behavior and whole-candidate syntax validation. + +Codex-only fields SHALL NOT change portable Agent Plugins behavior. +Executable component declarations SHALL NOT grant runtime authority. + +#### Scenario: Explicit Codex format reads only the compatibility manifest + +- **GIVEN** an archive has a valid Codex compatibility manifest +- **WHEN** the source selects `codex` +- **THEN** the Codex adapter selects only its declared skill roots +- **AND** it does not require a portable root manifest + +#### Scenario: Codex executable declaration grants no authority + +- **GIVEN** a Codex manifest declares a hook, command, agent, or MCP server +- **WHEN** Netclaw imports the package +- **THEN** Netclaw does not activate that declaration +- **AND** the result reports the excluded component + +### Requirement: Secure Git package acquisition + +The daemon SHALL acquire packages only from canonical public GitHub HTTPS sources. +It SHALL keep the existing redirect, archive, entry, path, link, file type, count, and byte limits. + +The daemon SHALL extract only package files that the selected adapter authorizes. +It SHALL NOT execute hooks, filters, submodules, installers, scripts, or package files during acquisition. + +#### Scenario: Safe archive produces a bounded candidate + +- **GIVEN** a supported package stays within every acquisition limit +- **WHEN** the daemon acquires its resolved commit +- **THEN** the daemon extracts only the selected skill trees +- **AND** it seals one immutable candidate directory + +#### Scenario: Escaped or special entry rejects the candidate + +- **GIVEN** an archive contains traversal, a link, a special file, or an escaped selected path +- **WHEN** the daemon inspects the archive +- **THEN** the daemon rejects the candidate before publication +- **AND** it does not write outside the managed plugin root + +### Requirement: Durable update and recovery policy + +The system SHALL keep the installed version, exact commit, observed commit, source fingerprint, package name, and rejection state in durable storage. +An exact equal declared version SHALL suppress an update. +An absent version SHALL use commit identity for update eligibility. + +Branch sources SHALL follow their configured branch. +Tag and commit sources SHALL remain pinned to the resolved commit. +The previous immutable package SHALL remain active after a failed update. + +#### Scenario: Equal version suppresses a new commit + +- **GIVEN** the installed receipt has version `1.2.0` +- **AND** a new commit declares version `1.2.0` +- **WHEN** an ordinary sync runs +- **THEN** the installed package remains unchanged +- **AND** the receipt records the new observed commit + +#### Scenario: Failed update preserves the prior package + +- **GIVEN** a source has a complete installed package +- **AND** its next candidate fails validation or a security scan +- **WHEN** sync processes that candidate +- **THEN** the prior immutable package remains active +- **AND** the daemon records the rejected commit when the failure is deterministic + +### Requirement: Single coordinated publication path + +The existing external sync actor SHALL own the timer, active pass, waiter set, and lifetime token. +A managed plugin sync participant SHALL own plugin startup publication, source updates, durable rejections, alerts, and cleanup. + +The external sync coordinator SHALL refresh the complete skill inventory once after source participants finish. +Concurrent readers SHALL see one complete previous or new inventory snapshot. + +#### Scenario: One plugin failure does not block another source + +- **GIVEN** one managed plugin fails and another external source succeeds +- **WHEN** one external sync pass runs +- **THEN** the pass reports both source results +- **AND** the successful source reaches the final inventory refresh + +#### Scenario: Concurrent caller joins the active pass + +- **GIVEN** a managed plugin update pass is active +- **WHEN** another authenticated caller requests an external sync +- **THEN** the actor adds the caller to the active waiter set +- **AND** both callers receive the same pass ID + +### Requirement: Plugin skill authority remains unchanged + +Managed plugin skills SHALL enter the existing logical skill inventory. +The existing audience policy, skill disable state, path policy, and logical resource tools SHALL control access. + +Package metadata SHALL NOT grant tool, subagent, shell, MCP, or filesystem authority. +Managed package files SHALL remain protected from agent writes. + +#### Scenario: Accepted skill uses logical access + +- **GIVEN** a managed plugin publishes an accepted skill and resource +- **WHEN** an authorized session uses `skill_load` and `skill_read_resource` +- **THEN** the runtime resolves both through the logical skill name +- **AND** it does not expose the managed physical root + +#### Scenario: Manifest tool field cannot grant a tool + +- **GIVEN** a compatibility manifest or skill header names a Netclaw tool +- **WHEN** the package enters the inventory +- **THEN** the current audience tool policy remains authoritative +- **AND** the package does not broaden tool exposure or execution authority diff --git a/openspec/changes/adopt-agent-plugin-package-contract/specs/netclaw-cli/spec.md b/openspec/changes/adopt-agent-plugin-package-contract/specs/netclaw-cli/spec.md new file mode 100644 index 000000000..b9c1abd5a --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/specs/netclaw-cli/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Operator CLI for managed plugins + +The CLI SHALL expose `netclaw plugin` as the top-level managed plugin command. +It SHALL provide `install`, `list`, `update`, `enable`, `disable`, and `remove` actions. + +Mutations SHALL require confirmation unless the operator supplies `--yes`. +The CLI SHALL NOT expose the unshipped `netclaw skill plugin` command. + +#### Scenario: Top-level help shows plugin actions + +- **WHEN** the operator runs `netclaw plugin --help` +- **THEN** the command exits with code 0 +- **AND** the output lists install, list, update, enable, disable, and remove + +#### Scenario: Old nested command is absent + +- **WHEN** the operator runs `netclaw skill plugin list` +- **THEN** the CLI returns a usage error +- **AND** it does not contact the daemon + +#### Scenario: Mutation requires confirmation + +- **GIVEN** the operator does not supply `--yes` +- **WHEN** the operator requests plugin removal +- **THEN** the CLI asks for confirmation before it contacts the daemon + +### Requirement: Managed plugin JSON output + +`netclaw plugin list --json` SHALL emit one stable JSON document and no prose. +Each row SHALL include the source ID, manifest name, repository, format, reference, status, installed commit, observed commit, and version. + +The command SHALL emit JSON `null` for unavailable installed values. +It SHALL exit with code 0 after a successful empty or non-empty response. + +#### Scenario: JSON list emits stable fields + +- **GIVEN** the daemon returns one installed plugin +- **WHEN** the operator runs `netclaw plugin list --json` +- **THEN** stdout contains one valid JSON document with the required fields +- **AND** stdout contains no progress or recovery prose + +#### Scenario: Empty JSON list succeeds + +- **GIVEN** no plugin source exists +- **WHEN** the operator runs `netclaw plugin list --json` +- **THEN** stdout contains an empty plugin array +- **AND** the command exits with code 0 + +### Requirement: Managed plugin daemon errors remain actionable + +The daemon SHALL return safe RFC 9457 problem details for plugin request failures. +The CLI SHALL show the safe detail when it receives a valid problem response. + +The CLI SHALL use a bounded status message when no valid safe detail exists. +It SHALL NOT print remote response bodies, credentials, stack traces, or unbounded text. + +#### Scenario: Missing tag shows daemon detail + +- **GIVEN** the daemon returns a safe problem detail for an unknown tag +- **WHEN** the CLI receives that response +- **THEN** the CLI prints the safe detail +- **AND** it exits with code 1 + +#### Scenario: Invalid error body stays bounded + +- **GIVEN** the daemon returns an error with an invalid or oversized body +- **WHEN** the CLI handles the response +- **THEN** the CLI prints a bounded HTTP status message +- **AND** it does not print the response body + +### Requirement: Plugin update uses the shared sync operation + +`netclaw plugin update ` SHALL request the existing external sync pass and report that managed source result. +`netclaw plugin update --all` SHALL report every managed plugin source result. + +An explicit rejected-commit retry SHALL use `--retry-rejected`. +The command SHALL NOT create another scheduler or sync endpoint. + +#### Scenario: Named update reports one source + +- **GIVEN** source `team-tools` is configured +- **WHEN** the operator runs `netclaw plugin update team-tools` +- **THEN** the CLI requests the shared external sync pass +- **AND** it reports the result for `team-tools` + +#### Scenario: Unknown source fails clearly + +- **GIVEN** no source has ID `missing` +- **WHEN** the operator runs `netclaw plugin update missing` +- **THEN** the CLI reports that the source does not exist +- **AND** it exits with code 1 diff --git a/openspec/changes/adopt-agent-plugin-package-contract/specs/skill-tools/spec.md b/openspec/changes/adopt-agent-plugin-package-contract/specs/skill-tools/spec.md new file mode 100644 index 000000000..54c945eed --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/specs/skill-tools/spec.md @@ -0,0 +1,49 @@ +## MODIFIED Requirements + +### Requirement: Authoritative skill inventory refresh + +Every in-process skill inventory refresh SHALL resolve the current enabled native, server-feed, managed-plugin, and external sources. +It SHALL use native greater than server-feed greater than managed-plugin greater than external precedence. +It SHALL update the registry and generated index from the same accepted result. +Concurrent refresh requests SHALL NOT expose a partially rebuilt registry. + +#### Scenario: Skill management preserves server-feed inventory + +- **GIVEN** a server-feed skill and a native skill are registered +- **WHEN** `skill_manage` successfully mutates the native skill inventory +- **THEN** the refresh retains the server-feed skill +- **AND** the generated index contains both logical skill names + +#### Scenario: Newly available feed directory participates in refresh + +- **GIVEN** an enabled configured server feed whose managed directory appears after daemon startup +- **WHEN** any inventory refresh occurs +- **THEN** the current feed directory is included in the scan + +#### Scenario: Native skill shadows server-feed skill + +- **GIVEN** native and server-feed skills have the same logical name +- **WHEN** the inventory is refreshed +- **THEN** the native skill is registered +- **AND** the shadowed server-feed skill is reported through existing scan diagnostics + +#### Scenario: Server-feed skill shadows managed plugin skill + +- **GIVEN** server-feed and managed plugin skills have the same logical name +- **WHEN** the inventory is refreshed +- **THEN** the server-feed skill is registered +- **AND** the shadowed managed plugin skill is reported through existing scan diagnostics + +#### Scenario: Managed plugin skill shadows local external skill + +- **GIVEN** managed plugin and local external skills have the same logical name +- **WHEN** the inventory is refreshed +- **THEN** the managed plugin skill is registered +- **AND** the shadowed local external skill is reported through existing scan diagnostics + +#### Scenario: Concurrent readers see a complete inventory snapshot + +- **GIVEN** sessions can read the skill registry while a background refresh occurs +- **WHEN** the refreshed inventory replaces the previous inventory +- **THEN** each reader observes either the complete previous snapshot or the complete new snapshot +- **AND** no reader observes the registry between clear and repopulation diff --git a/openspec/changes/adopt-agent-plugin-package-contract/tasks.md b/openspec/changes/adopt-agent-plugin-package-contract/tasks.md new file mode 100644 index 000000000..a3e05ef04 --- /dev/null +++ b/openspec/changes/adopt-agent-plugin-package-contract/tasks.md @@ -0,0 +1,58 @@ +## 1. Contract and tracker alignment + +- [x] 1.1 Update PRD-004 and SPEC-004 with the plugin-level lifecycle, portable format, compatibility format, and failure rules. +- [x] 1.2 Add shared plugin and source identity terms to the engineering glossary. +- [x] 1.3 Update Netclaw issues #2134 and #2135, and website issue #119, with the approved contract and PR stack links. + +## 2. Plugin-neutral source and state contracts + +- [x] 2.1 Replace Git skill plugin configuration types with managed plugin source types and source IDs. +- [x] 2.2 Update the JSON schema and configuration round-trip tests for `auto`, `agent-plugin`, and `codex` formats. +- [x] 2.3 Replace Git skill plugin API and durable state contracts with plugin-neutral names and manifest identity fields. +- [x] 2.4 Update SQLite migration, receipt, rejection, restart, and stale-state tests for the canonical representation. + +## 3. Portable package interpretation + +- [x] 3.1 Add a package selector that chooses one explicit format and does not fall through after selection. +- [x] 3.2 Add an Agent Plugins 1.0.0 adapter for root manifest validation and fixed `skills/` discovery. +- [x] 3.3 Move Codex manifest rules into a compatibility adapter without duplicate Git or archive policy. +- [x] 3.4 Add valid, invalid, multi-manifest, dotted-name, non-SemVer, and unsupported-component fixtures. +- [x] 3.5 Add portable skill failure-isolation tests and complete-candidate scanner rejection tests. + +## 4. Managed plugin sync participant + +- [x] 4.1 Extract startup publication, update policy, rejection state, alerts, and cleanup into a managed plugin sync participant. +- [x] 4.2 Keep the existing sync actor as the sole pass and waiter owner. +- [x] 4.3 Preserve one final inventory refresh and the documented source precedence. +- [x] 4.4 Run restart, interleaving, failure isolation, and previous-package recovery tests. + +## 5. Plugin daemon API + +- [x] 5.1 Replace `/api/skills/plugins` with authenticated `/api/plugins` routes. +- [x] 5.2 Return source ID and manifest name as separate list fields. +- [x] 5.3 Preserve bounded RFC 9457 details for validation, conflict, acquisition, scan, and timeout failures. +- [x] 5.4 Update endpoint authorization, persistence-block, and error contract tests. + +## 6. Plugin CLI + +- [x] 6.1 Replace `netclaw skill plugin` with `netclaw plugin` and add the update action. +- [x] 6.2 Add stable `netclaw plugin list --json` output with no prose. +- [x] 6.3 Parse safe daemon problem details and keep a bounded status fallback. +- [x] 6.4 Update CLI unit tests, real-process tests, help text, exit codes, and confirmation tests. + +## 7. Guidance and verification + +- [x] 7.1 Update `skill-authoring` and `netclaw-operations`, and increment each changed skill version. +- [x] 7.2 Run targeted configuration, package, sync, endpoint, CLI, and process tests. +- [x] 7.3 Run the full .NET test suite, Slopwatch, file-header verification, and `git diff --check`. +- [ ] 7.4 Run the behavioral eval suite and the native smoke harness with the required environment. +- [x] 7.5 Run OpenSpec verification and record all remaining evidence limits before archive. + +## Verification evidence limits + +- The full .NET suite passed 8,543 tests and skipped 22 environment-specific tests. +- The plugin management and public repository smoke scenarios passed all eight checks. +- The eval suite lacks the required provider type, endpoint, and model variables. +- The native tapes cannot run because Chromium reports `No usable sandbox` in this container. +- OpenSpec verification found no implementation or design divergence. +- Task 7.4 remains incomplete until an eligible environment runs both blocked gates. diff --git a/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs index e60e12cad..dbe01fb9e 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs @@ -482,21 +482,23 @@ await File.WriteAllTextAsync(paths.NetclawConfigPath, } [Fact] - public async Task ReturnsPass_WhenGitSkillPluginSourceIsValid() + public async Task ReturnsPass_WhenManagedPluginFormatsAreValid() { var basePath = CreateTempBasePath(); var paths = new NetclawPaths(basePath); paths.EnsureDirectoriesExist(); - await File.WriteAllTextAsync(paths.NetclawConfigPath, - """ + foreach (var format in new[] { "auto", "agent-plugin", "codex" }) + { + await File.WriteAllTextAsync(paths.NetclawConfigPath, + $$""" { "configVersion": 1, "SkillFeeds": { "Plugins": [{ - "Name": "dotnet-skills", + "Id": "dotnet-skills", "Repository": "Aaronontheweb/dotnet-skills", - "Format": "codex", + "Format": "{{format}}", "ReferenceKind": "Commit", "Reference": "13e26d39ed01d97ea592235d041304d289f4ba07", "Enabled": true, @@ -506,10 +508,11 @@ await File.WriteAllTextAsync(paths.NetclawConfigPath, } """, TestContext.Current.CancellationToken); - var check = new ConfigSchemaDoctorCheck(paths); - var result = await check.RunAsync(TestContext.Current.CancellationToken); + var check = new ConfigSchemaDoctorCheck(paths); + var result = await check.RunAsync(TestContext.Current.CancellationToken); - Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Equal(DoctorSeverity.Pass, result.Severity); + } } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs index a04e8cbbf..911b466eb 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs @@ -73,7 +73,7 @@ public void Toggle_feed_preserves_managed_Git_plugins() File.WriteAllText( _paths.NetclawConfigPath, """ - {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Name":"dotnet-skills","Repository":"owner/repository","Format":"codex","Subdirectory":"packages/plugin","ReferenceKind":"Branch","Reference":"main","Enabled":true,"TimeoutSeconds":90}]}} + {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Id":"dotnet-skills","Repository":"owner/repository","Format":"agent-plugin","Subdirectory":"packages/plugin","ReferenceKind":"Branch","Reference":"main","Enabled":true,"TimeoutSeconds":90}]}} """); using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); @@ -83,6 +83,8 @@ public void Toggle_feed_preserves_managed_Git_plugins() var section = document.RootElement.GetProperty("SkillFeeds"); Assert.False(section.GetProperty("Feeds")[0].GetProperty("Enabled").GetBoolean()); var plugin = Assert.Single(section.GetProperty("Plugins").EnumerateArray()); + Assert.Equal("dotnet-skills", plugin.GetProperty("Id").GetString()); + Assert.Equal("agent-plugin", plugin.GetProperty("Format").GetString()); Assert.Equal("owner/repository", plugin.GetProperty("Repository").GetString()); Assert.Equal("packages/plugin", plugin.GetProperty("Subdirectory").GetString()); Assert.Equal("Branch", plugin.GetProperty("ReferenceKind").GetString()); @@ -95,7 +97,7 @@ public void Remove_last_feed_preserves_managed_Git_plugins() File.WriteAllText( _paths.NetclawConfigPath, """ - {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Name":"dotnet-skills","Repository":"owner/repository","Format":"codex","ReferenceKind":"Commit","Reference":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","Enabled":false,"TimeoutSeconds":60}]}} + {"configVersion":1,"SkillFeeds":{"Feeds":[{"Name":"custom-feed","Url":"https://feed.example.test","Enabled":true}],"Plugins":[{"Id":"dotnet-skills","Repository":"owner/repository","Format":"auto","ReferenceKind":"Commit","Reference":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","Enabled":false,"TimeoutSeconds":60}]}} """); using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); @@ -105,7 +107,8 @@ public void Remove_last_feed_preserves_managed_Git_plugins() var section = document.RootElement.GetProperty("SkillFeeds"); Assert.Empty(section.GetProperty("Feeds").EnumerateArray()); var plugin = Assert.Single(section.GetProperty("Plugins").EnumerateArray()); - Assert.Equal("dotnet-skills", plugin.GetProperty("Name").GetString()); + Assert.Equal("dotnet-skills", plugin.GetProperty("Id").GetString()); + Assert.Equal("auto", plugin.GetProperty("Format").GetString()); Assert.Equal("Commit", plugin.GetProperty("ReferenceKind").GetString()); Assert.False(plugin.GetProperty("Enabled").GetBoolean()); } diff --git a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs index 105b6adf2..21bf6306d 100644 --- a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs @@ -2526,7 +2526,7 @@ private static Dictionary BuildSkillFeedsSection(SkillFeedsConfi { var item = new Dictionary { - ["Name"] = plugin.Name, + ["Id"] = plugin.Id, ["Repository"] = plugin.Repository, ["Format"] = plugin.Format, ["ReferenceKind"] = plugin.ReferenceKind.ToString(), @@ -2636,7 +2636,7 @@ private sealed class SkillFeedsConfigDocument public List Feeds { get; set; } = []; - public List Plugins { get; set; } = []; + public List Plugins { get; set; } = []; } private sealed class SkillFeedConfigEntry diff --git a/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs b/src/Netclaw.Configuration.Tests/ManagedPluginSourceValidatorTests.cs similarity index 58% rename from src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs rename to src/Netclaw.Configuration.Tests/ManagedPluginSourceValidatorTests.cs index ae4c8e553..4fa464be3 100644 --- a/src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs +++ b/src/Netclaw.Configuration.Tests/ManagedPluginSourceValidatorTests.cs @@ -1,5 +1,5 @@ // ----------------------------------------------------------------------- -// +// // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- @@ -7,7 +7,7 @@ namespace Netclaw.Configuration.Tests; -public sealed class GitSkillPluginSourceValidatorTests +public sealed class ManagedPluginSourceValidatorTests { [Theory] [InlineData("Aaronontheweb/dotnet-skills", "Aaronontheweb/dotnet-skills")] @@ -15,7 +15,7 @@ public sealed class GitSkillPluginSourceValidatorTests [InlineData("https://github.com/Aaronontheweb/dotnet-skills.git", "Aaronontheweb/dotnet-skills")] public void Repository_normalization_accepts_public_GitHub_forms(string value, string expected) { - Assert.True(GitSkillPluginSourceValidator.TryNormalizeRepository(value, out var actual, out _)); + Assert.True(ManagedPluginSourceValidator.TryNormalizeRepository(value, out var actual, out _)); Assert.Equal(expected, actual); } @@ -29,17 +29,17 @@ public void Repository_normalization_accepts_public_GitHub_forms(string value, s [InlineData("--upload-pack=bad")] public void Repository_normalization_rejects_unsafe_transport_forms(string value) { - Assert.False(GitSkillPluginSourceValidator.TryNormalizeRepository(value, out _, out _)); + Assert.False(ManagedPluginSourceValidator.TryNormalizeRepository(value, out _, out _)); } [Fact] public void Source_validation_requires_a_full_commit_identity() { var source = Source(); - source.ReferenceKind = GitSkillPluginReferenceKind.Commit; + source.ReferenceKind = ManagedPluginReferenceKind.Commit; source.Reference = "abc123"; - Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out var error)); + Assert.False(ManagedPluginSourceValidator.TryValidateSource(source, out var error)); Assert.Contains("full 40-character", error); } @@ -47,21 +47,21 @@ public void Source_validation_requires_a_full_commit_identity() public void Fingerprint_changes_for_each_source_semantic() { var source = Source(); - var original = GitSkillPluginSourceValidator.Fingerprint(source); + var original = ManagedPluginSourceValidator.Fingerprint(source); source.Subdirectory = "plugin"; - Assert.NotEqual(original, GitSkillPluginSourceValidator.Fingerprint(source)); + Assert.NotEqual(original, ManagedPluginSourceValidator.Fingerprint(source)); } [Fact] - public void Collection_validation_rejects_duplicate_names() + public void Collection_validation_rejects_duplicate_ids() { var first = Source(); var second = Source(); second.Repository = "owner/other"; - Assert.False(GitSkillPluginSourceValidator.TryValidateSources([first, second], out var error)); + Assert.False(ManagedPluginSourceValidator.TryValidateSources([first, second], out var error)); Assert.Contains("occurs more than once", error); } @@ -71,11 +71,11 @@ public void Collection_validation_rejects_more_than_twenty_sources() var sources = Enumerable.Range(0, 21).Select(index => { var source = Source(); - source.Name = $"source-{index}"; + source.Id = $"source-{index}"; return source; }).ToArray(); - Assert.False(GitSkillPluginSourceValidator.TryValidateSources(sources, out var error)); + Assert.False(ManagedPluginSourceValidator.TryValidateSources(sources, out var error)); Assert.Contains("No more than 20", error); } @@ -95,7 +95,7 @@ public void Source_validation_rejects_a_noncanonical_subdirectory(string subdire var source = Source(); source.Subdirectory = subdirectory; - Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out _)); + Assert.False(ManagedPluginSourceValidator.TryValidateSource(source, out _)); } [Theory] @@ -104,24 +104,46 @@ public void Source_validation_rejects_a_noncanonical_subdirectory(string subdire [InlineData("./repository")] public void Repository_normalization_rejects_dot_components(string repository) { - Assert.False(GitSkillPluginSourceValidator.TryNormalizeRepository(repository, out _, out _)); + Assert.False(ManagedPluginSourceValidator.TryNormalizeRepository(repository, out _, out _)); } [Fact] public void Source_validation_rejects_an_unknown_reference_type() { var source = Source(); - source.ReferenceKind = (GitSkillPluginReferenceKind)99; + source.ReferenceKind = (ManagedPluginReferenceKind)99; - Assert.False(GitSkillPluginSourceValidator.TryValidateSource(source, out _)); + Assert.False(ManagedPluginSourceValidator.TryValidateSource(source, out _)); } - private static GitSkillPluginSource Source() => new() + [Theory] + [InlineData(ManagedPluginSourceValidator.AutoFormat)] + [InlineData(ManagedPluginSourceValidator.AgentPluginFormat)] + [InlineData(ManagedPluginSourceValidator.CodexFormat)] + public void Source_validation_accepts_supported_formats(string format) + { + var source = Source(); + source.Format = format; + + Assert.True(ManagedPluginSourceValidator.TryValidateSource(source, out _)); + } + + [Fact] + public void Source_validation_rejects_an_unknown_format() + { + var source = Source(); + source.Format = "claude"; + + Assert.False(ManagedPluginSourceValidator.TryValidateSource(source, out var error)); + Assert.Contains("agent-plugin", error); + } + + private static ManagedPluginSource Source() => new() { - Name = "dotnet-skills", + Id = "dotnet-skills", Repository = "Aaronontheweb/dotnet-skills", Format = "codex", - ReferenceKind = GitSkillPluginReferenceKind.Branch, + ReferenceKind = ManagedPluginReferenceKind.Branch, Reference = "main", }; } diff --git a/src/Netclaw.Configuration/ManagedPluginApi.cs b/src/Netclaw.Configuration/ManagedPluginApi.cs new file mode 100644 index 000000000..c111931b5 --- /dev/null +++ b/src/Netclaw.Configuration/ManagedPluginApi.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +namespace Netclaw.Configuration; + +/// Wire contracts for managed plugin operations. +public static class ManagedPluginApi +{ + public enum InstallReferenceKind + { + DefaultBranch, + Branch, + Tag, + Commit, + } + + public enum PluginStatus + { + Disabled, + Installed, + NotInstalled, + Stale, + } + + public sealed class InstallRequest : IWireType + { + public required string Repository { get; init; } + public string? SourceId { get; init; } + public string Format { get; init; } = ManagedPluginSourceValidator.AutoFormat; + public string? Subdirectory { get; init; } + public InstallReferenceKind ReferenceKind { get; init; } + public string? Reference { get; init; } + public int TimeoutSeconds { get; init; } = 60; + } + + public sealed class InstallResponse : IWireType + { + public required int RestartGeneration { get; init; } + public required PluginRow Plugin { get; init; } + } + + public sealed class ListResponse : IWireType + { + public required List Plugins { get; init; } + } + + public sealed class PluginRow : IWireType + { + public required string SourceId { get; init; } + public string? ManifestName { get; init; } + public required string Repository { get; init; } + public required string SourceFormat { get; init; } + public string? ManifestFormat { get; init; } + public string? Subdirectory { get; init; } + public required ManagedPluginReferenceKind ReferenceKind { get; init; } + public required string Reference { get; init; } + public required bool Enabled { get; init; } + public required PluginStatus Status { get; init; } + public string? InstalledCommit { get; init; } + public string? LastObservedCommit { get; init; } + public string? InstalledVersion { get; init; } + } + + public sealed class SetEnabledRequest : IWireType + { + public required bool Enabled { get; init; } + } + + public sealed class MutationResponse : IWireType + { + public required int RestartGeneration { get; init; } + public required string SourceId { get; init; } + public required bool Changed { get; init; } + } +} diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index a6ba197c4..e5a517544 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -631,20 +631,20 @@ "Plugins": { "type": "array", "maxItems": 20, - "description": "Managed public GitHub skill plugins.", + "description": "Managed plugin packages from public GitHub repositories.", "items": { "type": "object", "properties": { - "Name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 }, + "Id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 }, "Repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, - "Format": { "type": "string", "enum": ["codex"], "default": "codex" }, + "Format": { "type": "string", "enum": ["auto", "agent-plugin", "codex"], "default": "auto" }, "Subdirectory": { "type": ["string", "null"], "minLength": 1, "maxLength": 512 }, "ReferenceKind": { "type": "string", "enum": ["Branch", "Commit"] }, "Reference": { "type": "string", "minLength": 1, "maxLength": 256 }, "Enabled": { "type": "boolean", "default": true }, "TimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300, "default": 60 } }, - "required": ["Name", "Repository", "Format", "ReferenceKind", "Reference"], + "required": ["Id", "Repository", "Format", "ReferenceKind", "Reference"], "additionalProperties": false } } diff --git a/src/Netclaw.Configuration/SkillFeedsConfig.cs b/src/Netclaw.Configuration/SkillFeedsConfig.cs index 9952a9387..a3bb94eae 100644 --- a/src/Netclaw.Configuration/SkillFeedsConfig.cs +++ b/src/Netclaw.Configuration/SkillFeedsConfig.cs @@ -19,8 +19,8 @@ public sealed class SkillFeedsConfig /// public List Feeds { get; set; } = []; - /// Managed public GitHub skill plugins. - public List Plugins { get; set; } = []; + /// Managed plugin packages from public GitHub repositories. + public List Plugins { get; set; } = []; /// /// How often (in minutes) to re-check feeds for updated skills. @@ -31,28 +31,31 @@ public sealed class SkillFeedsConfig } /// The type of Git reference that a managed plugin follows. -public enum GitSkillPluginReferenceKind +public enum ManagedPluginReferenceKind { Branch, Commit, } -/// A managed GitHub source for a content-only skill plugin. -public sealed class GitSkillPluginSource +/// A managed plugin source from a public GitHub repository. +public sealed class ManagedPluginSource { - public string Name { get; set; } = ""; + public string Id { get; set; } = ""; public string Repository { get; set; } = ""; - public string Format { get; set; } = "codex"; + public string Format { get; set; } = ManagedPluginSourceValidator.AutoFormat; public string? Subdirectory { get; set; } - public GitSkillPluginReferenceKind ReferenceKind { get; set; } + public ManagedPluginReferenceKind ReferenceKind { get; set; } public string Reference { get; set; } = ""; public bool Enabled { get; set; } = true; public int TimeoutSeconds { get; set; } = 60; } /// Validates and canonicalizes managed GitHub plugin source data. -public static class GitSkillPluginSourceValidator +public static class ManagedPluginSourceValidator { + public const string AgentPluginFormat = "agent-plugin"; + public const string AutoFormat = "auto"; + public const string CodexFormat = "codex"; public const int MaximumSourceCount = 20; private static readonly char[] WindowsInvalidPathCharacters = ['<', '>', ':', '"', '|', '?', '*']; private static readonly HashSet WindowsReservedPathNames = new(StringComparer.OrdinalIgnoreCase) @@ -64,7 +67,7 @@ public static class GitSkillPluginSourceValidator "LPT¹", "LPT²", "LPT³", }; - public static bool TryValidateSources(IReadOnlyList sources, out string error) + public static bool TryValidateSources(IReadOnlyList sources, out string error) { error = ""; if (sources.Count > MaximumSourceCount) @@ -72,11 +75,11 @@ public static bool TryValidateSources(IReadOnlyList source error = $"No more than {MaximumSourceCount} GitHub plugins can be configured."; return false; } - var duplicate = sources.GroupBy(source => source.Name, StringComparer.OrdinalIgnoreCase) + var duplicate = sources.GroupBy(source => source.Id, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); if (duplicate is not null) { - error = $"Plugin name '{duplicate.Key}' occurs more than once."; + error = $"Plugin source ID '{duplicate.Key}' occurs more than once."; return false; } foreach (var source in sources) @@ -129,11 +132,11 @@ public static bool TryNormalizeRepository(string value, out string repository, o return true; } - public static bool TryValidateSource(GitSkillPluginSource source, out string error) + public static bool TryValidateSource(ManagedPluginSource source, out string error) { ArgumentNullException.ThrowIfNull(source); - if (!TryValidateName(source.Name, out error)) + if (!TryValidateId(source.Id, out error)) return false; if (!TryNormalizeRepository(source.Repository, out var repository, out error) || !string.Equals(repository, source.Repository, StringComparison.Ordinal)) @@ -141,9 +144,9 @@ public static bool TryValidateSource(GitSkillPluginSource source, out string err error = error.Length > 0 ? error : "The repository is not canonical owner/repository form."; return false; } - if (!string.Equals(source.Format, "codex", StringComparison.Ordinal)) + if (source.Format is not AutoFormat and not AgentPluginFormat and not CodexFormat) { - error = "The plugin format must be 'codex'."; + error = "The plugin format must be 'auto', 'agent-plugin', or 'codex'."; return false; } if (!TryNormalizeRelativePath(source.Subdirectory, allowEmpty: true, out var subdirectory, out error)) @@ -168,19 +171,30 @@ public static bool TryValidateSource(GitSkillPluginSource source, out string err return true; } - public static bool TryValidateName(string value, out string error) + public static bool TryValidateId(string value, out string error) + { + error = ""; + if (string.IsNullOrEmpty(value) || value.Length > 64 || !IsLowerKebab(value)) + { + error = "The plugin source ID must use lowercase letters, numbers, and single hyphens."; + return false; + } + return true; + } + + public static bool TryValidateCodexPackageName(string value, out string error) { error = ""; if (string.IsNullOrEmpty(value) || value.Length > 64 || !IsLowerKebab(value)) { - error = "The plugin name must use lowercase letters, numbers, and single hyphens."; + error = "The Codex package name must use lowercase letters, numbers, and single hyphens."; return false; } return true; } public static bool TryValidateReference( - GitSkillPluginReferenceKind kind, + ManagedPluginReferenceKind kind, string value, out string error) { @@ -202,7 +216,7 @@ public static bool TryValidateReference( error = "The reference is not a safe Git branch or commit."; return false; } - if (kind == GitSkillPluginReferenceKind.Commit + if (kind == ManagedPluginReferenceKind.Commit && (candidate.Length is not (40 or 64) || !candidate.All(char.IsAsciiHexDigit))) { error = "A commit reference must be a full 40-character or 64-character hexadecimal identity."; @@ -260,7 +274,7 @@ private static bool IsPortablePathSegment(string segment) return !WindowsReservedPathNames.Contains(stem); } - public static string Fingerprint(GitSkillPluginSource source) + public static string Fingerprint(ManagedPluginSource source) { var content = string.Join('\n', source.Repository, source.Subdirectory ?? "", source.Format, source.ReferenceKind.ToString(), source.Reference); diff --git a/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs b/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs deleted file mode 100644 index 2810a07fa..000000000 --- a/src/Netclaw.Daemon.Tests/Services/GitSkillPluginStateStoreTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; -using Netclaw.Configuration; -using Netclaw.Daemon.Services; -using Netclaw.Tests.Utilities; -using Xunit; - -namespace Netclaw.Daemon.Tests.Services; - -public sealed class GitSkillPluginStateStoreTests : IDisposable -{ - private const string Commit = "13e26d39ed01d97ea592235d041304d289f4ba07"; - private const string LaterCommit = "23e26d39ed01d97ea592235d041304d289f4ba08"; - private readonly DisposableTempDir _temp = new(); - private readonly NetclawPaths _paths; - - public GitSkillPluginStateStoreTests() => _paths = new NetclawPaths(_temp.Path); - - public void Dispose() - { - SqliteTestPools.Clear(_paths); - _temp.Dispose(); - } - - [Fact] - public async Task Migration_and_store_preserve_receipts_and_scope_rejections_by_fingerprint() - { - var paths = _paths; - var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); - var migrator = new SchemaMigrator(paths, NullLogger.Instance); - await migrator.MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); - var store = new GitSkillPluginStateStore(paths, time); - var source = Source(); - var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); - - Assert.True(await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, "bad content", true, - TestContext.Current.CancellationToken)); - Assert.Null(await store.GetRejectionAsync( - source.Name, new string('a', 64), Commit, - TestContext.Current.CancellationToken)); - - await store.SaveReceiptAsync(source, Commit, "1.0.0", TestContext.Current.CancellationToken); - - var receipt = Assert.Single(await store.LoadReceiptsAsync(TestContext.Current.CancellationToken)); - Assert.Equal(fingerprint, receipt.SourceFingerprint); - Assert.Equal(Commit, receipt.InstalledCommit); - Assert.Equal(Commit, receipt.LastObservedCommit); - Assert.NotNull(await store.GetRejectionAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Last_observed_commit_changes_without_replacing_the_installed_commit() - { - var paths = _paths; - var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); - await new SchemaMigrator(paths, NullLogger.Instance) - .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); - var store = new GitSkillPluginStateStore(paths, time); - var source = Source(); - await store.SaveReceiptAsync(source, Commit, "1.0.0", TestContext.Current.CancellationToken); - var installed = await store.GetReceiptAsync(source.Name, TestContext.Current.CancellationToken); - Assert.NotNull(installed); - - time.Advance(TimeSpan.FromHours(1)); - Assert.True(await store.UpdateLastObservedCommitAsync( - source.Name, LaterCommit, TestContext.Current.CancellationToken)); - - var observed = await store.GetReceiptAsync(source.Name, TestContext.Current.CancellationToken); - Assert.NotNull(observed); - Assert.Equal(Commit, observed.InstalledCommit); - Assert.Equal(LaterCommit, observed.LastObservedCommit); - Assert.Equal(installed.InstalledAt, observed.InstalledAt); - Assert.False(await store.UpdateLastObservedCommitAsync( - "missing", LaterCommit, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task RemoveSourcesExcept_removes_rejection_only_sources() - { - var paths = _paths; - await new SchemaMigrator(paths, NullLogger.Instance) - .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); - var store = new GitSkillPluginStateStore(paths, TimeProvider.System); - var source = Source(); - var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); - await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, "bad content", false, - TestContext.Current.CancellationToken); - - await store.RemoveSourcesExceptAsync([], TestContext.Current.CancellationToken); - - Assert.Null(await store.GetRejectionAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task SaveRejection_sanitizes_and_limits_the_durable_reason() - { - var paths = _paths; - await new SchemaMigrator(paths, NullLogger.Instance) - .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); - var store = new GitSkillPluginStateStore(paths, TimeProvider.System); - var source = Source(); - var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); - var reason = "bad\r\n" + new string('x', GitSkillPluginStateStore.MaximumRejectionReasonLength + 100); - - await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, reason, true, TestContext.Current.CancellationToken); - - var rejection = await store.GetRejectionAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); - Assert.NotNull(rejection); - Assert.Equal(GitSkillPluginStateStore.MaximumRejectionReasonLength, rejection.Reason.Length); - Assert.DoesNotContain('\r', rejection.Reason); - Assert.DoesNotContain('\n', rejection.Reason); - } - - [Fact] - public async Task SaveRejection_preserves_a_later_security_classification() - { - var paths = _paths; - await new SchemaMigrator(paths, NullLogger.Instance) - .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); - var store = new GitSkillPluginStateStore(paths, TimeProvider.System); - var source = Source(); - var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); - await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, "invalid metadata", false, - TestContext.Current.CancellationToken); - - await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, "security rejection", true, - TestContext.Current.CancellationToken); - - var rejection = await store.GetRejectionAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); - Assert.NotNull(rejection); - Assert.True(rejection.SecurityRejection); - Assert.Equal("security rejection", rejection.Reason); - } - - [Fact] - public async Task Security_alert_claim_succeeds_once_and_rejects_nonsecurity_records() - { - var paths = _paths; - await new SchemaMigrator(paths, NullLogger.Instance) - .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); - var store = new GitSkillPluginStateStore(paths, TimeProvider.System); - var source = Source(); - var fingerprint = GitSkillPluginSourceValidator.Fingerprint(source); - await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, "invalid metadata", false, - TestContext.Current.CancellationToken); - - Assert.False(await store.TryClaimSecurityAlertAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); - - await store.SaveRejectionAsync( - source.Name, fingerprint, Commit, "security rejection", true, - TestContext.Current.CancellationToken); - Assert.True(await store.TryClaimSecurityAlertAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); - Assert.False(await store.TryClaimSecurityAlertAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken)); - - var rejection = await store.GetRejectionAsync( - source.Name, fingerprint, Commit, TestContext.Current.CancellationToken); - Assert.NotNull(rejection); - Assert.True(rejection.AlertEmitted); - } - - private static GitSkillPluginSource Source() => new() - { - Name = "fixture", - Repository = "owner/repository", - Format = "codex", - ReferenceKind = GitSkillPluginReferenceKind.Branch, - Reference = "main", - }; -} diff --git a/src/Netclaw.Daemon.Tests/Services/ManagedPluginStateStoreTests.cs b/src/Netclaw.Daemon.Tests/Services/ManagedPluginStateStoreTests.cs new file mode 100644 index 000000000..2e1606b1d --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Services/ManagedPluginStateStoreTests.cs @@ -0,0 +1,267 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Data.Sqlite; +using Netclaw.Configuration; +using Netclaw.Daemon.Services; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Daemon.Tests.Services; + +public sealed class ManagedPluginStateStoreTests : IDisposable +{ + private const string Commit = "13e26d39ed01d97ea592235d041304d289f4ba07"; + private const string LaterCommit = "23e26d39ed01d97ea592235d041304d289f4ba08"; + private readonly DisposableTempDir _temp = new(); + private readonly NetclawPaths _paths; + + public ManagedPluginStateStoreTests() => _paths = new NetclawPaths(_temp.Path); + + public void Dispose() + { + SqliteTestPools.Clear(_paths); + _temp.Dispose(); + } + + [Fact] + public async Task Migration_and_store_preserve_receipts_and_scope_rejections_by_fingerprint() + { + var paths = _paths; + var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); + var migrator = new SchemaMigrator(paths, NullLogger.Instance); + await migrator.MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new ManagedPluginStateStore(paths, time); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + + Assert.True(await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "bad content", true, + TestContext.Current.CancellationToken)); + Assert.Null(await store.GetRejectionAsync( + source.Id, new string('a', 64), Commit, + TestContext.Current.CancellationToken)); + + await store.SaveReceiptAsync(source, Candidate(Commit, "1.0.0"), TestContext.Current.CancellationToken); + + var receipt = Assert.Single(await store.LoadReceiptsAsync(TestContext.Current.CancellationToken)); + Assert.Equal(fingerprint, receipt.SourceFingerprint); + Assert.Equal(Commit, receipt.InstalledCommit); + Assert.Equal(Commit, receipt.LastObservedCommit); + Assert.Equal("fixture-package", receipt.ManifestName); + Assert.Equal(ManagedPluginSourceValidator.CodexFormat, receipt.ManifestFormat); + Assert.Equal(ManagedPluginSourceValidator.CodexFormat, receipt.SourceFormat); + Assert.NotNull(await store.GetRejectionAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Last_observed_commit_changes_without_replacing_the_installed_commit() + { + var paths = _paths; + var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 11, 12, 0, 0, TimeSpan.Zero)); + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new ManagedPluginStateStore(paths, time); + var source = Source(); + await store.SaveReceiptAsync(source, Candidate(Commit, "1.0.0"), TestContext.Current.CancellationToken); + var installed = await store.GetReceiptAsync(source.Id, TestContext.Current.CancellationToken); + Assert.NotNull(installed); + + time.Advance(TimeSpan.FromHours(1)); + Assert.True(await store.UpdateLastObservedCommitAsync( + source.Id, LaterCommit, TestContext.Current.CancellationToken)); + + var observed = await store.GetReceiptAsync(source.Id, TestContext.Current.CancellationToken); + Assert.NotNull(observed); + Assert.Equal(Commit, observed.InstalledCommit); + Assert.Equal(LaterCommit, observed.LastObservedCommit); + Assert.Equal(installed.InstalledAt, observed.InstalledAt); + Assert.False(await store.UpdateLastObservedCommitAsync( + "missing", LaterCommit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RemoveSourcesExcept_removes_rejection_only_sources() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new ManagedPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "bad content", false, + TestContext.Current.CancellationToken); + + await store.RemoveSourcesExceptAsync([], TestContext.Current.CancellationToken); + + Assert.Null(await store.GetRejectionAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveRejection_sanitizes_and_limits_the_durable_reason() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new ManagedPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + var reason = "bad\r\n" + new string('x', ManagedPluginStateStore.MaximumRejectionReasonLength + 100); + + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, reason, true, TestContext.Current.CancellationToken); + + var rejection = await store.GetRejectionAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.Equal(ManagedPluginStateStore.MaximumRejectionReasonLength, rejection.Reason.Length); + Assert.DoesNotContain('\r', rejection.Reason); + Assert.DoesNotContain('\n', rejection.Reason); + } + + [Fact] + public async Task SaveRejection_preserves_a_later_security_classification() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new ManagedPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "invalid metadata", false, + TestContext.Current.CancellationToken); + + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + + var rejection = await store.GetRejectionAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.True(rejection.SecurityRejection); + Assert.Equal("security rejection", rejection.Reason); + } + + [Fact] + public async Task Security_alert_claim_succeeds_once_and_rejects_nonsecurity_records() + { + var paths = _paths; + await new SchemaMigrator(paths, NullLogger.Instance) + .MigrateAsync(paths.SqliteDbPath, TestContext.Current.CancellationToken); + var store = new ManagedPluginStateStore(paths, TimeProvider.System); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "invalid metadata", false, + TestContext.Current.CancellationToken); + + Assert.False(await store.TryClaimSecurityAlertAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken)); + + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + Assert.True(await store.TryClaimSecurityAlertAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken)); + Assert.False(await store.TryClaimSecurityAlertAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken)); + + var rejection = await store.GetRejectionAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken); + Assert.NotNull(rejection); + Assert.True(rejection.AlertEmitted); + } + + [Fact] + public async Task Retry_receipt_and_rejection_delete_use_one_transaction() + { + await MigrateAsync(); + var store = new ManagedPluginStateStore(_paths, TimeProvider.System); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + await store.SaveRejectionAsync( + source.Id, fingerprint, Commit, "security rejection", true, + TestContext.Current.CancellationToken); + await CreateRejectDeleteTriggerAsync(); + + await Assert.ThrowsAsync(() => store.SaveReceiptAfterRetryAsync( + source, Candidate(Commit, "1.0.0"), TestContext.Current.CancellationToken)); + + Assert.Null(await store.GetReceiptAsync(source.Id, TestContext.Current.CancellationToken)); + Assert.NotNull(await store.GetRejectionAsync( + source.Id, fingerprint, Commit, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Retry_observed_commit_and_rejection_delete_use_one_transaction() + { + await MigrateAsync(); + var store = new ManagedPluginStateStore(_paths, TimeProvider.System); + var source = Source(); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + await store.SaveReceiptAsync(source, Candidate(Commit, "1.0.0"), TestContext.Current.CancellationToken); + await store.SaveRejectionAsync( + source.Id, fingerprint, LaterCommit, "security rejection", true, + TestContext.Current.CancellationToken); + await CreateRejectDeleteTriggerAsync(); + + await Assert.ThrowsAsync(() => store.UpdateLastObservedCommitAfterRetryAsync( + source.Id, fingerprint, LaterCommit, TestContext.Current.CancellationToken)); + + var receipt = await store.GetReceiptAsync(source.Id, TestContext.Current.CancellationToken); + Assert.NotNull(receipt); + Assert.Equal(Commit, receipt.LastObservedCommit); + Assert.NotNull(await store.GetRejectionAsync( + source.Id, fingerprint, LaterCommit, TestContext.Current.CancellationToken)); + } + + private async Task MigrateAsync() + => await new SchemaMigrator(_paths, NullLogger.Instance) + .MigrateAsync(_paths.SqliteDbPath, TestContext.Current.CancellationToken); + + private async Task CreateRejectDeleteTriggerAsync() + { + await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = _paths.SqliteDbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + }.ToString()); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + CREATE TRIGGER reject_plugin_rejection_delete + BEFORE DELETE ON managed_plugin_rejections + BEGIN + SELECT RAISE(ABORT, 'delete blocked'); + END; + """; + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static ManagedPluginSource Source() => new() + { + Id = "fixture", + Repository = "owner/repository", + Format = "codex", + ReferenceKind = ManagedPluginReferenceKind.Branch, + Reference = "main", + }; + + private static ManagedPluginCandidate Candidate(string commit, string? version) => new( + commit, + ManagedPluginSourceValidator.CodexFormat, + "fixture-package", + version, + "unused", + [], + []); +} diff --git a/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs b/src/Netclaw.Daemon/Services/ManagedPluginStateStore.cs similarity index 56% rename from src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs rename to src/Netclaw.Daemon/Services/ManagedPluginStateStore.cs index 256e2d27c..ad149e7ef 100644 --- a/src/Netclaw.Daemon/Services/GitSkillPluginStateStore.cs +++ b/src/Netclaw.Daemon/Services/ManagedPluginStateStore.cs @@ -1,28 +1,40 @@ // ----------------------------------------------------------------------- -// +// // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- using Microsoft.Data.Sqlite; +using Netclaw.Actors.Skills; using Netclaw.Configuration; namespace Netclaw.Daemon.Services; -internal sealed record GitSkillPluginReceipt( - string SourceName, - string Repository, +internal sealed record ManagedPluginCandidate( + string Commit, string Format, + string Name, + string? Version, + string Directory, + IReadOnlyList Skills, + IReadOnlyList Notices); + +internal sealed record ManagedPluginReceipt( + string SourceId, + string Repository, + string SourceFormat, string? Subdirectory, - GitSkillPluginReferenceKind ReferenceKind, + ManagedPluginReferenceKind ReferenceKind, string Reference, string SourceFingerprint, string InstalledCommit, string LastObservedCommit, + string ManifestName, + string ManifestFormat, string? InstalledVersion, DateTimeOffset InstalledAt); -internal sealed record GitSkillPluginRejection( - string SourceName, +internal sealed record ManagedPluginRejection( + string SourceId, string SourceFingerprint, string Commit, string Reason, @@ -30,13 +42,13 @@ internal sealed record GitSkillPluginRejection( bool AlertEmitted, DateTimeOffset RejectedAt); -internal sealed class GitSkillPluginStateStore +internal sealed class ManagedPluginStateStore { internal const int MaximumRejectionReasonLength = 1_024; private readonly string _connectionString; private readonly TimeProvider _timeProvider; - public GitSkillPluginStateStore(NetclawPaths paths, TimeProvider timeProvider) + public ManagedPluginStateStore(NetclawPaths paths, TimeProvider timeProvider) { _connectionString = new SqliteConnectionStringBuilder { @@ -46,134 +58,190 @@ public GitSkillPluginStateStore(NetclawPaths paths, TimeProvider timeProvider) _timeProvider = timeProvider; } - public async Task> LoadReceiptsAsync( + public async Task> LoadReceiptsAsync( CancellationToken cancellationToken) { - var receipts = new List(); + var receipts = new List(); await using var connection = await OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ - SELECT source_name, repository_url, plugin_format, plugin_subdirectory, + SELECT source_id, repository_url, source_format, plugin_subdirectory, reference_kind, reference_value, source_fingerprint, installed_commit, - last_observed_commit, installed_version, installed_at - FROM git_skill_plugin_receipts - ORDER BY source_name; + last_observed_commit, manifest_name, manifest_format, installed_version, installed_at + FROM managed_plugin_receipts + ORDER BY source_id; """; await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { - receipts.Add(new GitSkillPluginReceipt( + receipts.Add(new ManagedPluginReceipt( reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.IsDBNull(3) ? null : reader.GetString(3), - Enum.Parse(reader.GetString(4)), + Enum.Parse(reader.GetString(4)), reader.GetString(5), reader.GetString(6), reader.GetString(7), reader.GetString(8), - reader.IsDBNull(9) ? null : reader.GetString(9), - DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(10)))); + reader.GetString(9), + reader.GetString(10), + reader.IsDBNull(11) ? null : reader.GetString(11), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(12)))); } return receipts; } - public async Task GetReceiptAsync( - string sourceName, + public async Task GetReceiptAsync( + string sourceId, CancellationToken cancellationToken) { await using var connection = await OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ - SELECT repository_url, plugin_format, plugin_subdirectory, + SELECT repository_url, source_format, plugin_subdirectory, reference_kind, reference_value, source_fingerprint, installed_commit, - last_observed_commit, installed_version, installed_at - FROM git_skill_plugin_receipts - WHERE source_name = $source; + last_observed_commit, manifest_name, manifest_format, installed_version, installed_at + FROM managed_plugin_receipts + WHERE source_id = $source; """; - command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$source", sourceId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) return null; - return new GitSkillPluginReceipt( - sourceName, + return new ManagedPluginReceipt( + sourceId, reader.GetString(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2), - Enum.Parse(reader.GetString(3)), + Enum.Parse(reader.GetString(3)), reader.GetString(4), reader.GetString(5), reader.GetString(6), reader.GetString(7), - reader.IsDBNull(8) ? null : reader.GetString(8), - DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(9))); + reader.GetString(8), + reader.GetString(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(11))); } - public async Task SaveReceiptAsync( - GitSkillPluginSource source, - string commit, - string? version, + public Task SaveReceiptAsync( + ManagedPluginSource source, + ManagedPluginCandidate candidate, + CancellationToken cancellationToken) + => SaveReceiptCoreAsync(source, candidate, clearRejection: false, cancellationToken); + + public Task SaveReceiptAfterRetryAsync( + ManagedPluginSource source, + ManagedPluginCandidate candidate, + CancellationToken cancellationToken) + => SaveReceiptCoreAsync(source, candidate, clearRejection: true, cancellationToken); + + private async Task SaveReceiptCoreAsync( + ManagedPluginSource source, + ManagedPluginCandidate candidate, + bool clearRejection, CancellationToken cancellationToken) { await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); await using var command = connection.CreateCommand(); + command.Transaction = transaction; command.CommandText = """ - INSERT INTO git_skill_plugin_receipts ( - source_name, repository_url, plugin_format, plugin_subdirectory, + INSERT INTO managed_plugin_receipts ( + source_id, repository_url, source_format, plugin_subdirectory, reference_kind, reference_value, source_fingerprint, installed_commit, - last_observed_commit, installed_version, installed_at) + last_observed_commit, manifest_name, manifest_format, installed_version, installed_at) VALUES ($source, $repository, $format, $subdirectory, - $referenceKind, $reference, $fingerprint, $commit, $commit, $version, $installedAt) - ON CONFLICT(source_name) DO UPDATE SET + $referenceKind, $reference, $fingerprint, $commit, $commit, + $manifestName, $manifestFormat, $version, $installedAt) + ON CONFLICT(source_id) DO UPDATE SET repository_url = excluded.repository_url, - plugin_format = excluded.plugin_format, + source_format = excluded.source_format, plugin_subdirectory = excluded.plugin_subdirectory, reference_kind = excluded.reference_kind, reference_value = excluded.reference_value, source_fingerprint = excluded.source_fingerprint, installed_commit = excluded.installed_commit, last_observed_commit = excluded.last_observed_commit, + manifest_name = excluded.manifest_name, + manifest_format = excluded.manifest_format, installed_version = excluded.installed_version, installed_at = excluded.installed_at; """; - command.Parameters.AddWithValue("$source", source.Name); + command.Parameters.AddWithValue("$source", source.Id); command.Parameters.AddWithValue("$repository", source.Repository); command.Parameters.AddWithValue("$format", source.Format); command.Parameters.AddWithValue("$subdirectory", (object?)source.Subdirectory ?? DBNull.Value); command.Parameters.AddWithValue("$referenceKind", source.ReferenceKind.ToString()); command.Parameters.AddWithValue("$reference", source.Reference); - command.Parameters.AddWithValue("$fingerprint", GitSkillPluginSourceValidator.Fingerprint(source)); - command.Parameters.AddWithValue("$commit", commit); - command.Parameters.AddWithValue("$version", (object?)version ?? DBNull.Value); + var fingerprint = ManagedPluginSourceValidator.Fingerprint(source); + command.Parameters.AddWithValue("$fingerprint", fingerprint); + command.Parameters.AddWithValue("$commit", candidate.Commit); + command.Parameters.AddWithValue("$manifestName", candidate.Name); + command.Parameters.AddWithValue("$manifestFormat", candidate.Format); + command.Parameters.AddWithValue("$version", (object?)candidate.Version ?? DBNull.Value); command.Parameters.AddWithValue("$installedAt", _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()); await command.ExecuteNonQueryAsync(cancellationToken); + if (clearRejection) + { + await DeleteRejectionAsync( + connection, transaction, source.Id, fingerprint, candidate.Commit, cancellationToken); + } + await transaction.CommitAsync(cancellationToken); } - public async Task UpdateLastObservedCommitAsync( - string sourceName, + public Task UpdateLastObservedCommitAsync( + string sourceId, + string commit, + CancellationToken cancellationToken) + => UpdateLastObservedCommitCoreAsync( + sourceId, sourceFingerprint: null, commit, clearRejection: false, cancellationToken); + + public Task UpdateLastObservedCommitAfterRetryAsync( + string sourceId, + string sourceFingerprint, + string commit, + CancellationToken cancellationToken) + => UpdateLastObservedCommitCoreAsync( + sourceId, sourceFingerprint, commit, clearRejection: true, cancellationToken); + + private async Task UpdateLastObservedCommitCoreAsync( + string sourceId, + string? sourceFingerprint, string commit, + bool clearRejection, CancellationToken cancellationToken) { await using var connection = await OpenAsync(cancellationToken); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); await using var command = connection.CreateCommand(); + command.Transaction = transaction; command.CommandText = """ - UPDATE git_skill_plugin_receipts + UPDATE managed_plugin_receipts SET last_observed_commit = $commit - WHERE source_name = $source; + WHERE source_id = $source; """; - command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$source", sourceId); command.Parameters.AddWithValue("$commit", commit); - return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + var updated = await command.ExecuteNonQueryAsync(cancellationToken) > 0; + if (updated && clearRejection) + { + await DeleteRejectionAsync( + connection, transaction, sourceId, sourceFingerprint!, commit, cancellationToken); + } + await transaction.CommitAsync(cancellationToken); + return updated; } - public async Task GetRejectionAsync( - string sourceName, + public async Task GetRejectionAsync( + string sourceId, string sourceFingerprint, string commit, CancellationToken cancellationToken) @@ -183,19 +251,19 @@ UPDATE git_skill_plugin_receipts command.CommandText = """ SELECT reason, security_rejection, alert_emitted, rejected_at - FROM git_skill_plugin_rejections - WHERE source_name = $source AND source_fingerprint = $fingerprint + FROM managed_plugin_rejections + WHERE source_id = $source AND source_fingerprint = $fingerprint AND commit_identity = $commit; """; - command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$source", sourceId); command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); command.Parameters.AddWithValue("$commit", commit); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) return null; - return new GitSkillPluginRejection( - sourceName, + return new ManagedPluginRejection( + sourceId, sourceFingerprint, commit, reader.GetString(0), @@ -205,7 +273,7 @@ FROM git_skill_plugin_rejections } public async Task SaveRejectionAsync( - string sourceName, + string sourceId, string sourceFingerprint, string commit, string reason, @@ -216,18 +284,18 @@ public async Task SaveRejectionAsync( await using var command = connection.CreateCommand(); command.CommandText = """ - INSERT INTO git_skill_plugin_rejections ( - source_name, source_fingerprint, commit_identity, reason, security_rejection, + INSERT INTO managed_plugin_rejections ( + source_id, source_fingerprint, commit_identity, reason, security_rejection, alert_emitted, rejected_at) VALUES ($source, $fingerprint, $commit, $reason, $security, 0, $rejectedAt) - ON CONFLICT(source_name, source_fingerprint, commit_identity) DO UPDATE SET + ON CONFLICT(source_id, source_fingerprint, commit_identity) DO UPDATE SET reason = excluded.reason, security_rejection = MAX( - git_skill_plugin_rejections.security_rejection, + managed_plugin_rejections.security_rejection, excluded.security_rejection), rejected_at = excluded.rejected_at; """; - command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$source", sourceId); command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); command.Parameters.AddWithValue("$commit", commit); command.Parameters.AddWithValue("$reason", SanitizeReason(reason)); @@ -236,8 +304,30 @@ ON CONFLICT(source_name, source_fingerprint, commit_identity) DO UPDATE SET return await command.ExecuteNonQueryAsync(cancellationToken) > 0; } + private static async Task DeleteRejectionAsync( + SqliteConnection connection, + SqliteTransaction transaction, + string sourceId, + string sourceFingerprint, + string commit, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + """ + DELETE FROM managed_plugin_rejections + WHERE source_id = $source AND source_fingerprint = $fingerprint + AND commit_identity = $commit; + """; + command.Parameters.AddWithValue("$source", sourceId); + command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); + command.Parameters.AddWithValue("$commit", commit); + await command.ExecuteNonQueryAsync(cancellationToken); + } + public async Task TryClaimSecurityAlertAsync( - string sourceName, + string sourceId, string sourceFingerprint, string commit, CancellationToken cancellationToken) @@ -246,31 +336,31 @@ public async Task TryClaimSecurityAlertAsync( await using var command = connection.CreateCommand(); command.CommandText = """ - UPDATE git_skill_plugin_rejections + UPDATE managed_plugin_rejections SET alert_emitted = 1 - WHERE source_name = $source AND source_fingerprint = $fingerprint + WHERE source_id = $source AND source_fingerprint = $fingerprint AND commit_identity = $commit AND security_rejection = 1 AND alert_emitted = 0; """; - command.Parameters.AddWithValue("$source", sourceName); + command.Parameters.AddWithValue("$source", sourceId); command.Parameters.AddWithValue("$fingerprint", sourceFingerprint); command.Parameters.AddWithValue("$commit", commit); return await command.ExecuteNonQueryAsync(cancellationToken) > 0; } public async Task RemoveSourcesExceptAsync( - IReadOnlyCollection sourceNames, + IReadOnlyCollection sourceIds, CancellationToken cancellationToken) { await using var connection = await OpenAsync(cancellationToken); await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); - var names = sourceNames.ToHashSet(StringComparer.Ordinal); + var names = sourceIds.ToHashSet(StringComparer.Ordinal); var stored = new HashSet(StringComparer.Ordinal); await using (var read = connection.CreateCommand()) { read.Transaction = transaction; read.CommandText = - "SELECT source_name FROM git_skill_plugin_receipts UNION SELECT source_name FROM git_skill_plugin_rejections;"; + "SELECT source_id FROM managed_plugin_receipts UNION SELECT source_id FROM managed_plugin_rejections;"; await using var reader = await read.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) stored.Add(reader.GetString(0)); @@ -278,11 +368,11 @@ public async Task RemoveSourcesExceptAsync( foreach (var source in stored.Where(name => !names.Contains(name))) { - foreach (var table in new[] { "git_skill_plugin_receipts", "git_skill_plugin_rejections" }) + foreach (var table in new[] { "managed_plugin_receipts", "managed_plugin_rejections" }) { await using var delete = connection.CreateCommand(); delete.Transaction = transaction; - delete.CommandText = $"DELETE FROM {table} WHERE source_name = $source;"; + delete.CommandText = $"DELETE FROM {table} WHERE source_id = $source;"; delete.Parameters.AddWithValue("$source", source); await delete.ExecuteNonQueryAsync(cancellationToken); } diff --git a/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql b/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql index ca2ef3523..29ae94f67 100644 --- a/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql +++ b/src/Netclaw.Daemon/migrations/sqlite/007_git_skill_plugins.sql @@ -1,30 +1,32 @@ -- Netclaw SQLite migration 007 -- Stores managed Git plugin publications and rejected commit identities. -CREATE TABLE IF NOT EXISTS git_skill_plugin_receipts ( - source_name TEXT NOT NULL PRIMARY KEY, +CREATE TABLE IF NOT EXISTS managed_plugin_receipts ( + source_id TEXT NOT NULL PRIMARY KEY, repository_url TEXT NOT NULL, - plugin_format TEXT NOT NULL, + source_format TEXT NOT NULL, plugin_subdirectory TEXT, reference_kind TEXT NOT NULL, reference_value TEXT NOT NULL, source_fingerprint TEXT NOT NULL, installed_commit TEXT NOT NULL, last_observed_commit TEXT NOT NULL, + manifest_name TEXT NOT NULL, + manifest_format TEXT NOT NULL, installed_version TEXT, installed_at INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS git_skill_plugin_rejections ( - source_name TEXT NOT NULL, +CREATE TABLE IF NOT EXISTS managed_plugin_rejections ( + source_id TEXT NOT NULL, source_fingerprint TEXT NOT NULL, commit_identity TEXT NOT NULL, reason TEXT NOT NULL, security_rejection INTEGER NOT NULL DEFAULT 0, alert_emitted INTEGER NOT NULL DEFAULT 0, rejected_at INTEGER NOT NULL, - PRIMARY KEY (source_name, source_fingerprint, commit_identity) + PRIMARY KEY (source_id, source_fingerprint, commit_identity) ); -CREATE INDEX IF NOT EXISTS git_skill_plugin_rejections_source_idx - ON git_skill_plugin_rejections(source_name); +CREATE INDEX IF NOT EXISTS managed_plugin_rejections_source_idx + ON managed_plugin_rejections(source_id);