Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Netclaw.Cli.Tests/Doctor/ConfigSchemaDoctorCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// </copyright>
// -----------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using Netclaw.Cli.Tui.Config;
using Netclaw.Configuration;
using Netclaw.Configuration.Secrets;
Expand Down Expand Up @@ -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()
{
Expand Down
22 changes: 21 additions & 1 deletion src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -2522,6 +2522,24 @@ private static Dictionary<string, object> BuildSkillFeedsSection(SkillFeedsConfi

return (object)item;
}).ToArray(),
["Plugins"] = config.Plugins.Select(static plugin =>
{
var item = new Dictionary<string, object>
{
["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)
Expand Down Expand Up @@ -2617,6 +2635,8 @@ private sealed class SkillFeedsConfigDocument
public int SyncIntervalMinutes { get; set; } = 60;

public List<SkillFeedConfigEntry> Feeds { get; set; } = [];

public List<GitSkillPluginSource> Plugins { get; set; } = [];
}

private sealed class SkillFeedConfigEntry
Expand Down
120 changes: 120 additions & 0 deletions src/Netclaw.Configuration.Tests/GitSkillPluginSourceValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// -----------------------------------------------------------------------
// <copyright file="GitSkillPluginSourceValidatorTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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",
};
}
10 changes: 10 additions & 0 deletions src/Netclaw.Configuration/NetclawPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -185,6 +194,7 @@ private IEnumerable<string> StandardDirectories()
yield return SkillsDirectory;
yield return SystemSkillsDirectory;
yield return ServerFeedsDirectory;
yield return ManagedGitSkillsDirectory;
yield return ProjectsDirectory;
yield return ClientDirectory;
yield return EnvironmentDirectory;
Expand Down
1 change: 1 addition & 0 deletions src/Netclaw.Configuration/OperationalAlert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public enum AlertType
// New values stay at the end so prior ordinal values remain stable.
ReminderScheduleFailed,
ChannelReconnected,
SkillPluginSecurityRejected,
}

/// <summary>
Expand Down
20 changes: 20 additions & 0 deletions src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading