From 3da66b8813c03a7b1d87616889dd3ff343dcde1a Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:46:39 +0800 Subject: [PATCH 1/3] Add backend Skill service recommendations --- .../skill-service-recommendations.md | 105 +++++++++++++ .../Aevatar.AI.Abstractions.csproj | 1 + .../Skills/ISkillServiceRecommendations.cs | 20 +++ .../skill_service_recommendations.proto | 56 +++++++ .../SkillServiceRecommendationService.cs | 72 +++++++++ .../NyxIdApiClient.cs | 4 +- .../OrnnSkillServiceDiscoverySource.cs | 129 ++++++++++++++++ .../ServiceCollectionExtensions.cs | 4 + .../Hosting/MainnetHostBuilderExtensions.cs | 1 + .../SkillServiceRecommendationEndpoints.cs | 78 ++++++++++ .../SkillServiceRecommendationTests.cs | 142 ++++++++++++++++++ ...SkillServiceRecommendationEndpointTests.cs | 82 ++++++++++ 12 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 docs/contracts/skill-service-recommendations.md create mode 100644 src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs create mode 100644 src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto create mode 100644 src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs create mode 100644 src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs create mode 100644 src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs create mode 100644 test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs create mode 100644 test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs diff --git a/docs/contracts/skill-service-recommendations.md b/docs/contracts/skill-service-recommendations.md new file mode 100644 index 0000000000..4c76346a7c --- /dev/null +++ b/docs/contracts/skill-service-recommendations.md @@ -0,0 +1,105 @@ +# Skill service recommendations + +Skill-to-service discovery is backend behavior shared by all clients. Channels +consumes the result and owns only display, selection and explicit save. Clients +must not download Skill instructions or recreate dependency inference. + +The backend implementation targets `feature/integrate`. The Channels consumer +is delivered separately in [console PR #3679](https://github.com/aevatarAI/aevatar/pull/3679), +targeting `feat/2026-08-04_workflow-activity-vnext`. Deploy this endpoint before +enabling the complete recommendation experience in the console. Until then, +the consumer reports discovery as unavailable and preserves manual selection. + +## Endpoint + +`GET /api/skills/service-recommendations?skillName=` requires an +authenticated caller and their NyxID bearer credential. It is served by Mainnet +and returns `Cache-Control: no-store`. The name is the same Skill name stored by +channel registration, limited to 128 characters and excluding dot path segments. + +```json +{ + "skillName": "support", + "suggestions": [{ + "slug": "api-github", + "label": "GitHub", + "evidence": "linked", + "instances": [{ + "id": "user-service-example", + "slug": "api-github", + "label": "Team GitHub", + "active": true, + "allowed": true, + "source": "organization", + "organizationName": "Example team" + }] + }] +} +``` + +`evidence` is `linked`, `catalog`, or `mention`. These are advisory sources, +not required/optional dependency declarations. Empty `instances` means no +matching connection in the caller's account inventory. `allowed` reports +account-level access only; it does not grant access to the current session or +channel. `source` is `personal`, `organization`, or `unknown`. Existing +channel authorization still uses explicitly selected exact UserService IDs. +Catalog IDs and Skill association IDs must never become authorization IDs. +Inventory availability is not credential-validity evidence. + +Successful empty discovery returns an empty `suggestions` array. Missing +authentication is 401; malformed names return 400 with +`{"code":"invalid_skill_name"}`; inaccessible, malformed, mismatched or +unavailable upstream data returns 502 with +`{"code":"skill_service_discovery_unavailable"}`. Failures never become +successful empty or partial recommendations and never expose upstream errors, +private instructions, file contents, or credentials. Cancellation propagates. + +## Ownership and source contracts + +- `Aevatar.AI.Abstractions.Skills`: protobuf input/output, evidence and instance + contracts; caller credential parameters remain outside serialized data. +- `Aevatar.AI.Core.Skills.SkillServiceRecommendationService`: stateless discovery + policy behind `ISkillServiceDiscoverySource`. No Host/HTTP dependency. +- `Aevatar.AI.ToolProviders.Ornn.OrnnSkillServiceDiscoverySource`: external + adaptation through existing NyxID/Ornn clients. JSON is decoded at this boundary. +- `Aevatar.Mainnet.Host.Api.Skills.SkillServiceRecommendationEndpoints`: + authentication, HTTP result/error mapping, and response DTOs only. +- Console `channelSkillServicesApi`: response validation only; the UI cannot + invent recommendations or evidence categories. + +The adapter resolves the selected name using Ornn `GET /api/v1/skills/:name`, +verifies identity, then loads `GET /api/v1/skills/:guid/json`. It reads the +description, root `SKILL.md`, and optional `nyxidServiceSlug`. It reads NyxID +`GET /api/v1/catalog?include_all=true` and `GET /api/v1/user-services` using +the same invocation's caller credential. Existing source contracts were +compared with `feature/integrate`; no external-product changes are required. +The adapter uses the configured Ornn per-call timeout as the discovery budget, +and rejects root instructions above 200,000 characters. + +Evidence priority is explicit Skill association, then exact catalog +`recommended_skills` association, then whole service-name/slug mention in the +description or root instructions. Names shorter than three characters are +excluded from text inference. Catalog and inventory candidates are combined; +all exact same-slug instances remain distinct. Skill instructions are data, +never executed. No LLM inference, recursive Skill loading, grant mutation, +credential creation, or channel update occurs during discovery. + +These APIs provide no exhaustive mandatory-dependency contract. Literal matching +can miss aliases or include incidental mentions. Every result is advisory and +may be incomplete. Adding authoritative required dependencies later must extend +the typed contract and source evidence, rather than infer a requirement from +text. Platform-required services retain their current separate contract. + +All collections are invocation-local. There is no shared credential/result +cache, actor state, persisted derived dependency list, event replay, projection +priming or query-time materialization. This is transient discovery over external +caller-visible catalog facts, not a new authority for channel configuration. + +## Verification + +Provider integration tests exercise real policy and adapters against an HTTP +boundary: evidence precedence, bounded name matching, distinct instance IDs, +sanitized failures, identity mismatches, cancellation and caller isolation. +Host tests cover response mapping, authentication, validation, cancellation +forwarding and retryable errors. Console tests cover API decoding, stale-response +isolation, manual selection, exact save IDs and recovery. diff --git a/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj b/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj index 4fed2ecbfc..41189f1179 100644 --- a/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj +++ b/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj @@ -22,5 +22,6 @@ + diff --git a/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs b/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs new file mode 100644 index 0000000000..412723a45e --- /dev/null +++ b/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs @@ -0,0 +1,20 @@ +namespace Aevatar.AI.Abstractions.Skills; + +// Caller credentials are per invocation, never retained in service state or protobuf data. +public interface ISkillServiceDiscoverySource +{ + Task ReadAsync( + string accessToken, string skillName, CancellationToken ct = default); +} + +public interface ISkillServiceRecommendationService +{ + Task RecommendAsync( + string accessToken, string skillName, CancellationToken ct = default); +} + +public sealed class SkillServiceDiscoveryException : Exception +{ + public SkillServiceDiscoveryException() + : base("Skill service discovery is unavailable.") { } +} diff --git a/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto b/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto new file mode 100644 index 0000000000..6a5d016ec3 --- /dev/null +++ b/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package aevatar.ai.skills; +option csharp_namespace = "Aevatar.AI.Abstractions.Skills"; + +// Advisory discovery evidence; none of these sources declares a mandatory dependency. +enum SkillServiceEvidence { + SKILL_SERVICE_EVIDENCE_UNSPECIFIED = 0; + SKILL_SERVICE_EVIDENCE_LINKED = 1; + SKILL_SERVICE_EVIDENCE_CATALOG = 2; + SKILL_SERVICE_EVIDENCE_MENTION = 3; +} + +enum SkillServiceCredentialSource { + SKILL_SERVICE_CREDENTIAL_SOURCE_UNSPECIFIED = 0; + SKILL_SERVICE_CREDENTIAL_SOURCE_PERSONAL = 1; + SKILL_SERVICE_CREDENTIAL_SOURCE_ORGANIZATION = 2; +} + +message SkillServiceInstance { + string user_service_id = 1; + string slug = 2; + string label = 3; + bool active = 4; + bool account_access_allowed = 5; + SkillServiceCredentialSource credential_source = 6; + string organization_name = 7; +} + +message SkillServiceCatalogEntry { + string slug = 1; + string name = 2; + repeated string recommended_skill_names = 3; +} + +// Transient typed input from external adapters. Never persisted or returned to clients. +message SkillServiceDiscoveryInput { + string skill_name = 1; + string description = 2; + string instructions = 3; + string linked_service_slug = 4; + repeated SkillServiceCatalogEntry catalog = 5; + repeated SkillServiceInstance instances = 6; +} + +message SkillServiceRecommendation { + string slug = 1; + string label = 2; + SkillServiceEvidence evidence = 3; + repeated SkillServiceInstance instances = 4; +} + +message SkillServiceRecommendations { + string skill_name = 1; + repeated SkillServiceRecommendation suggestions = 2; +} diff --git a/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs b/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs new file mode 100644 index 0000000000..854cd16321 --- /dev/null +++ b/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs @@ -0,0 +1,72 @@ +using System.Text.RegularExpressions; +using Aevatar.AI.Abstractions.Skills; + +namespace Aevatar.AI.Core.Skills; + +// Stateless discovery over caller-visible external facts. Suggestions are not grants +// or persisted dependencies; channel authorization still requires explicit exact IDs. +public sealed class SkillServiceRecommendationService(ISkillServiceDiscoverySource source) + : ISkillServiceRecommendationService +{ + public async Task RecommendAsync( + string accessToken, string skillName, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(accessToken)) + throw new ArgumentException("Caller credentials are required.", nameof(accessToken)); + if (string.IsNullOrWhiteSpace(skillName) || skillName.Length > 128 || + skillName != skillName.Trim() || skillName is "." or "..") + throw new ArgumentException("Invalid skill name.", nameof(skillName)); + + var input = await source.ReadAsync(accessToken, skillName, ct); + if (!string.Equals(input.SkillName, skillName, StringComparison.Ordinal)) + throw new SkillServiceDiscoveryException(); + + var candidates = input.Catalog.ToDictionary(entry => entry.Slug, StringComparer.Ordinal); + foreach (var instance in input.Instances) + candidates.TryAdd(instance.Slug, new SkillServiceCatalogEntry + { + Slug = instance.Slug, + Name = instance.Label, + }); + if (input.LinkedServiceSlug.Length > 0) + candidates.TryAdd(input.LinkedServiceSlug, new SkillServiceCatalogEntry + { + Slug = input.LinkedServiceSlug, + Name = input.LinkedServiceSlug, + }); + + var result = new SkillServiceRecommendations { SkillName = skillName }; + var text = input.Description + "\n" + input.Instructions; + foreach (var entry in candidates.Values) + { + ct.ThrowIfCancellationRequested(); + var evidence = entry.Slug == input.LinkedServiceSlug + ? SkillServiceEvidence.Linked + : entry.RecommendedSkillNames.Contains(skillName) + ? SkillServiceEvidence.Catalog + : Mentions(text, entry.Slug) || Mentions(text, entry.Name) + ? SkillServiceEvidence.Mention + : SkillServiceEvidence.Unspecified; + if (evidence == SkillServiceEvidence.Unspecified) + continue; + var suggestion = new SkillServiceRecommendation + { + Slug = entry.Slug, + Label = entry.Name, + Evidence = evidence, + }; + suggestion.Instances.Add(input.Instances + .Where(instance => instance.Slug == entry.Slug) + .Select(instance => instance.Clone())); + result.Suggestions.Add(suggestion); + } + return result; + } + + private static bool Mentions(string text, string term) => + term.Trim().Length >= 3 && Regex.IsMatch( + text, + @"(? SearchAdminUsersAsync(string token, string email, Cancellati // ─── Catalog ─── - public Task ListCatalogAsync(string token, CancellationToken ct) => - GetAsync(token, "/api/v1/catalog", ct); + public Task ListCatalogAsync(string token, CancellationToken ct, bool includeAll = false) => + GetAsync(token, includeAll ? "/api/v1/catalog?include_all=true" : "/api/v1/catalog", ct); public Task GetCatalogEntryAsync(string token, string slug, CancellationToken ct) => GetAsync(token, $"/api/v1/catalog/{Uri.EscapeDataString(slug)}", ct); diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs new file mode 100644 index 0000000000..e0ecf7a138 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.Skills; +using Aevatar.AI.ToolProviders.NyxId; +using Microsoft.Extensions.Logging; + +namespace Aevatar.AI.ToolProviders.Ornn; + +// External JSON is decoded only at this adapter boundary. The policy consumes +// protobuf values and never sees raw error bodies or arbitrary files. Credentials +// pass through the service as invocation parameters only. +public sealed class OrnnSkillServiceDiscoverySource( + OrnnSkillClient skills, + NyxIdApiClient nyx, + OrnnOptions options, + ILogger logger) : ISkillServiceDiscoverySource +{ + public async Task ReadAsync( + string accessToken, string skillName, CancellationToken ct = default) + { + using var timeout = new CancellationTokenSource(options.PerCallTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + try + { + using var detail = Parse(await nyx.ProxyRequestAsync( + accessToken, options.NyxIdSlug, + $"/api/v1/skills/{Uri.EscapeDataString(skillName)}", "GET", + null, null, linked.Token)); + var data = detail.RootElement.GetProperty("data"); + var guid = RequiredString(data, "guid"); + if (guid is "." or ".." || RequiredString(data, "name") != skillName) + throw new SkillServiceDiscoveryException(); + var package = await skills.GetSkillJsonAsync(accessToken, guid, linked.Token); + if (package?.Name != skillName || + package.Files is null || !package.Files.TryGetValue("SKILL.md", out var instructions) || + instructions is null || instructions.Length > 200_000) + throw new SkillServiceDiscoveryException(); + + using var catalog = Parse(await nyx.ListCatalogAsync(accessToken, linked.Token, includeAll: true)); + var inventory = NyxIdApiAccessResponseParser.ParseUserServices( + await nyx.ListUserServicesAsync(accessToken, linked.Token)); + if (!inventory.Succeeded) + throw new SkillServiceDiscoveryException(); + + var result = new SkillServiceDiscoveryInput + { + SkillName = skillName, + Description = OptionalString(data, "description"), + Instructions = instructions, + LinkedServiceSlug = OptionalString(data, "nyxidServiceSlug").Trim(), + }; + var slugs = new HashSet(StringComparer.Ordinal); + foreach (var entry in catalog.RootElement.GetProperty("entries").EnumerateArray()) + { + var candidate = new SkillServiceCatalogEntry + { + Slug = RequiredString(entry, "slug"), + Name = RequiredString(entry, "name"), + }; + if (!slugs.Add(candidate.Slug)) + throw new SkillServiceDiscoveryException(); + if (entry.TryGetProperty("recommended_skills", out var recommended) && + recommended.ValueKind != JsonValueKind.Null) + foreach (var name in recommended.EnumerateArray()) + candidate.RecommendedSkillNames.Add(name.GetString() + ?? throw new SkillServiceDiscoveryException()); + result.Catalog.Add(candidate); + } + foreach (var service in inventory.Value!.Services) + { + var source = service.CredentialSource; + result.Instances.Add(new SkillServiceInstance + { + UserServiceId = service.Id, + Slug = service.Slug, + Label = FirstLabel(service.Label, service.CatalogServiceName, service.Slug), + Active = service.IsActive, + AccountAccessAllowed = source.Kind == NyxIdUserServiceCredentialSourceKind.Personal || + source.Kind == NyxIdUserServiceCredentialSourceKind.Organization && source.Allowed, + CredentialSource = source.Kind switch + { + NyxIdUserServiceCredentialSourceKind.Personal => SkillServiceCredentialSource.Personal, + NyxIdUserServiceCredentialSourceKind.Organization => SkillServiceCredentialSource.Organization, + _ => SkillServiceCredentialSource.Unspecified, + }, + OrganizationName = source.OrganizationName ?? string.Empty, + }); + } + return result; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Do not log upstream messages, credential material, or private instructions. + logger.LogWarning("Skill service discovery failed ({FailureType})", ex.GetType().Name); + throw new SkillServiceDiscoveryException(); + } + } + + private static JsonDocument Parse(string response) + { + var document = JsonDocument.Parse(response); + if (document.RootElement.ValueKind == JsonValueKind.Object && + (!document.RootElement.TryGetProperty("error", out var error) || + error.ValueKind is JsonValueKind.Null or JsonValueKind.False)) + return document; + document.Dispose(); + throw new SkillServiceDiscoveryException(); + } + + private static string RequiredString(JsonElement row, string property) + { + var value = row.GetProperty(property).GetString(); + return !string.IsNullOrWhiteSpace(value) && value == value.Trim() + ? value + : throw new SkillServiceDiscoveryException(); + } + + private static string OptionalString(JsonElement row, string property) => + !row.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null + ? string.Empty + : value.GetString() ?? string.Empty; + + private static string FirstLabel(string? label, string? catalogName, string slug) => + !string.IsNullOrWhiteSpace(label) ? label.Trim() + : !string.IsNullOrWhiteSpace(catalogName) ? catalogName.Trim() : slug; +} diff --git a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs index f9e690c401..722e18b448 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ +using Aevatar.AI.Abstractions.Skills; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.Core.AgentProfiles; +using Aevatar.AI.Core.Skills; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.Ornn.Publishing; using Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay; @@ -68,6 +70,8 @@ public static IServiceCollection AddOrnnSkillClient( sp.GetService>()); }); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(sp => sp.GetRequiredService()); return services; diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index 9ee2d2cee8..f414d8354f 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -785,6 +785,7 @@ public static WebApplication MapAevatarMainnetHost(this WebApplication app) app.MapProjectionVersionRegressionRepairAdminEndpoints(); app.MapManagedCodexCredentialEndpoints(); app.MapWorkflowSkillsEndpoints(); + app.MapSkillServiceRecommendations(); app.MapStatusEndpoints(); // Voice service registration is conditional on a configured provider diff --git a/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs b/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs new file mode 100644 index 0000000000..a71db6ec79 --- /dev/null +++ b/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs @@ -0,0 +1,78 @@ +using Aevatar.AI.Abstractions.Skills; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace Aevatar.Mainnet.Host.Api.Skills; + +internal static class SkillServiceRecommendationEndpoints +{ + public static IEndpointRouteBuilder MapSkillServiceRecommendations(this IEndpointRouteBuilder app) + { + app.MapGet("/api/skills/service-recommendations", GetRecommendations) + .WithTags("Skills") + .WithName("GetSkillServiceRecommendations") + .WithSummary("Discover advisory services for a caller-visible Skill without changing authorization.") + .RequireAuthorization(); + return app; + } + + internal static async Task GetRecommendations( + HttpContext http, + [FromServices] ISkillServiceRecommendationService service, + string skillName, + CancellationToken ct = default) + { + var authorization = http.Request.Headers.Authorization.ToString(); + const string prefix = "Bearer "; + if (http.User.Identity?.IsAuthenticated != true) + return Results.Unauthorized(); + if (!authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(authorization[prefix.Length..])) + return Results.Unauthorized(); + http.Response.Headers.CacheControl = "no-store"; + try + { + var result = await service.RecommendAsync(authorization[prefix.Length..].Trim(), skillName, ct); + return Results.Json(new SkillServiceRecommendationsResponse( + result.SkillName, + result.Suggestions.Select(item => new SkillServiceSuggestionResponse( + item.Slug, item.Label, + item.Evidence switch + { + SkillServiceEvidence.Linked => "linked", + SkillServiceEvidence.Catalog => "catalog", + SkillServiceEvidence.Mention => "mention", + _ => throw new SkillServiceDiscoveryException(), + }, + item.Instances.Select(instance => new SkillServiceInstanceResponse( + instance.UserServiceId, instance.Slug, instance.Label, + instance.Active, instance.AccountAccessAllowed, + instance.CredentialSource switch + { + SkillServiceCredentialSource.Personal => "personal", + SkillServiceCredentialSource.Organization => "organization", + _ => "unknown", + }, + instance.OrganizationName)).ToArray())).ToArray())); + } + catch (ArgumentException) + { + return Results.BadRequest(new SkillServiceRecommendationError("invalid_skill_name")); + } + catch (SkillServiceDiscoveryException) + { + return Results.Json(new SkillServiceRecommendationError("skill_service_discovery_unavailable"), + statusCode: StatusCodes.Status502BadGateway); + } + } +} + +public sealed record SkillServiceRecommendationsResponse( + string SkillName, IReadOnlyList Suggestions); +public sealed record SkillServiceSuggestionResponse( + string Slug, string Label, string Evidence, IReadOnlyList Instances); +public sealed record SkillServiceInstanceResponse( + string Id, string Slug, string Label, bool Active, bool Allowed, string Source, string OrganizationName); +public sealed record SkillServiceRecommendationError(string Code); diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs new file mode 100644 index 0000000000..139b4d5f4e --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using Aevatar.AI.Abstractions.Skills; +using Aevatar.AI.Core.Skills; +using Aevatar.AI.ToolProviders.NyxId; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.AI.ToolProviders.Ornn.Tests; + +public sealed class SkillServiceRecommendationTests +{ + private const string Detail = """ + {"data":{"guid":"guid+support","name":"support","description":"Use Slack for updates.", + "nyxidServiceSlug":"api-github","nyxidServiceId":"catalog-not-a-user-service"}} + """; + private const string Package = """ + {"data":{"name":"support","files":{"SKILL.md":"Use Linear. gmail and mail-helper are different. TEST_ONLY_PRIVATE", + "private.txt":"TEST_ONLY_SECRET"}}} + """; + private const string Catalog = """ + {"entries":[ + {"slug":"api-github","name":"GitHub"}, + {"slug":"slack","name":"Slack"}, + {"slug":"linear","name":"Linear"}, + {"slug":"drive","name":"Drive","recommended_skills":["support"]}, + {"slug":"mail","name":"Mail"}, + {"slug":"unused","name":"Unused","recommended_skills":["support-extra"]} + ]} + """; + private const string Inventory = """ + {"services":[ + {"id":"us-personal","slug":"api-github","label":"Personal GitHub","is_active":true, + "credential_source":{"type":"personal"},"api_key":"TEST_ONLY_SECRET"}, + {"id":"us-org","slug":"api-github","label":"Team GitHub","is_active":false, + "credential_source":{"type":"org","org_id":"org-1","org_name":"Acme","role":"viewer","allowed":false}} + ]} + """; + + [Fact] + public async Task RegisteredService_DiscoversEvidenceAndExactInstances_WithoutLeakingInputsOrMutatingGrants() + { + var handler = Responses(Detail, Package, Catalog, Inventory); + using var http = new HttpClient(handler); + var nyx = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, http); + var services = new ServiceCollection(); + services.AddSingleton(nyx); + services.AddSingleton>(NullLogger.Instance); + services.AddOrnnSkillClient(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + var result = await service.RecommendAsync("caller-token", "support"); + + result.SkillName.Should().Be("support"); + result.Suggestions.Select(item => (item.Slug, item.Evidence)).Should().Equal( + ("api-github", SkillServiceEvidence.Linked), + ("slack", SkillServiceEvidence.Mention), + ("linear", SkillServiceEvidence.Mention), + ("drive", SkillServiceEvidence.Catalog)); + var instances = result.Suggestions[0].Instances; + instances.Select(item => item.UserServiceId).Should().Equal("us-personal", "us-org"); + instances[0].CredentialSource.Should().Be(SkillServiceCredentialSource.Personal); + instances[1].CredentialSource.Should().Be(SkillServiceCredentialSource.Organization); + instances[1].Active.Should().BeFalse(); + instances[1].AccountAccessAllowed.Should().BeFalse(); + result.Suggestions[1].Instances.Should().BeEmpty(); + result.ToString().Should().NotContain("TEST_ONLY").And.NotContain("catalog-not-a-user-service"); + handler.Requests.Should().OnlyContain(request => + request.Method == HttpMethod.Get && request.Authorization!.Parameter == "caller-token"); + handler.Requests.Select(request => request.RequestUri!.PathAndQuery).Should().Equal( + "/api/v1/proxy/s/ornn-api/api/v1/skills/support", + "/api/v1/proxy/s/ornn-api/api/v1/skills/guid%2Bsupport/json", + "/api/v1/catalog?include_all=true", + "/api/v1/user-services"); + } + + [Theory] + [InlineData("""{"data":{"name":"another-skill","files":{"SKILL.md":"Slack"}}}""")] + [InlineData("""{"data":{"name":"support","files":{}}}""")] + public async Task Discovery_RejectsMismatchedOrMissingSkillContents(string package) + { + var handler = Responses(Detail, package); + var service = Create(handler); + var action = () => service.RecommendAsync("caller-token", "support"); + await action.Should().ThrowAsync() + .WithMessage("Skill service discovery is unavailable."); + handler.Requests.Should().HaveCount(2); + } + + [Fact] + public async Task Discovery_FailsClosedForUnavailableCatalog_AndDoesNotReturnPartialSuccess() + { + var handler = Responses(Detail, Package, """{"error":true,"status":403,"body":"TEST_ONLY_SECRET"}"""); + var action = () => Create(handler).RecommendAsync("caller-token", "support"); + await action.Should().ThrowAsync() + .WithMessage("Skill service discovery is unavailable."); + handler.Requests.Should().HaveCount(3); + } + + [Fact] + public async Task Discovery_PropagatesCancellation_AndDoesNotContinueReading() + { + var handler = OrnnTestHttpMessageHandler.HangingUntilCanceled(); + using var cancellation = new CancellationTokenSource(); + var task = Create(handler).RecommendAsync("caller-token", "support", cancellation.Token); + await handler.RequestStarted; + await cancellation.CancelAsync(); + var action = async () => await task; + await action.Should().ThrowAsync(); + handler.Requests.Should().ContainSingle(); + } + + [Fact] + public async Task Discovery_IsolatesCallers_AndRefreshesExternalFactsWithoutSharedState() + { + var handler = Responses(Detail, Package, Catalog, Inventory, + Detail, Package, """{"entries":[]}""", """{"services":[]}"""); + var service = Create(handler); + var first = await service.RecommendAsync("first-caller", "support"); + var second = await service.RecommendAsync("second-caller", "support"); + first.Suggestions[0].Instances.Should().HaveCount(2); + second.Suggestions.Should().ContainSingle().Which.Instances.Should().BeEmpty(); + handler.Requests.Skip(4).Should().OnlyContain(request => + request.Authorization!.Parameter == "second-caller"); + } + + private static OrnnTestHttpMessageHandler Responses(params string[] responses) => + new(responses.Select>( + response => _ => OrnnTestHttpMessageHandler.JsonResponse(response, HttpStatusCode.OK)).ToArray()); + + private static SkillServiceRecommendationService Create(OrnnTestHttpMessageHandler handler) + { + var nyx = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, new HttpClient(handler)); + var options = new OrnnOptions(); + return new SkillServiceRecommendationService(new OrnnSkillServiceDiscoverySource( + new OrnnSkillClient(options, nyx), nyx, options, + NullLogger.Instance)); + } +} diff --git a/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs b/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs new file mode 100644 index 0000000000..515b792de9 --- /dev/null +++ b/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs @@ -0,0 +1,82 @@ +using System.Security.Claims; +using Aevatar.AI.Abstractions.Skills; +using Aevatar.AI.Core.Skills; +using Aevatar.Mainnet.Host.Api.Skills; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace Aevatar.Capabilities.Tests; + +public sealed class SkillServiceRecommendationEndpointTests +{ + [Fact] + public async Task Endpoint_MapsTypedRecommendations_AndForwardsCallerAndCancellation() + { + var source = new RecordingSource(); + var http = Context(); + using var cancellation = new CancellationTokenSource(); + var response = await SkillServiceRecommendationEndpoints.GetRecommendations( + http, new SkillServiceRecommendationService(source), "support", cancellation.Token); + + var body = response.Should().BeOfType>() + .Which.Value!; + body.SkillName.Should().Be("support"); + body.Suggestions.Should().ContainSingle().Which.Evidence.Should().Be("linked"); + body.Suggestions[0].Instances.Should().BeEmpty(); + source.Token.Should().Be("caller-token"); + source.Cancellation.Should().Be(cancellation.Token); + http.Response.Headers.CacheControl.ToString().Should().Be("no-store"); + } + + [Fact] + public async Task Endpoint_RejectsUnauthenticatedOrInvalidInput_BeforeExternalReads() + { + var source = new RecordingSource(); + var service = new SkillServiceRecommendationService(source); + var http = Context(); + http.User = new ClaimsPrincipal(); + (await SkillServiceRecommendationEndpoints.GetRecommendations(http, service, "support")) + .Should().BeOfType(); + (await SkillServiceRecommendationEndpoints.GetRecommendations(Context(), service, "..")) + .Should().BeOfType>(); + source.Token.Should().BeNull(); + } + + [Fact] + public async Task Endpoint_ReturnsRetryableFailure_WithoutPretendingNoDependencies() + { + var source = new RecordingSource { Fail = true }; + var response = await SkillServiceRecommendationEndpoints.GetRecommendations( + Context(), new SkillServiceRecommendationService(source), "support"); + var result = response.Should().BeOfType>().Subject; + result.StatusCode.Should().Be(502); + result.Value!.Code.Should().Be("skill_service_discovery_unavailable"); + } + + private static DefaultHttpContext Context() + { + var http = new DefaultHttpContext(); + http.Request.Headers.Authorization = "Bearer caller-token"; + http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "user-alpha")], "test")); + return http; + } + + private sealed class RecordingSource : ISkillServiceDiscoverySource + { + public bool Fail { get; init; } + public string? Token { get; private set; } + public CancellationToken Cancellation { get; private set; } + public Task ReadAsync(string accessToken, string skillName, CancellationToken ct) + { + Token = accessToken; + Cancellation = ct; + if (Fail) throw new SkillServiceDiscoveryException(); + return Task.FromResult(new SkillServiceDiscoveryInput + { + SkillName = skillName, + LinkedServiceSlug = "api-github", + }); + } + } +} From e37bc498e0e02aca2e0b24a6ae38ca55f1992dd3 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:56:48 +0800 Subject: [PATCH 2/3] Refresh conformance pin for Skill service endpoint registration --- docs/contracts/nyxid-assistant-conformance/v1/sources.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 1c92ba1839..17f9c1aad9 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,8 +2,8 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "5c59e325c3742bc5a795d0dccda72063a5517067", - "contract_files_sha256": "72f39797c2a5d8106cde3f01e9ee2112088cdf5906b7c3a1ddf571d11d103143", + "revision": "3da66b8813c03a7b1d87616889dd3ff343dcde1a", + "contract_files_sha256": "bc5e5b2b0483421ba62f55a20d55cf889624ac3b0e05ba5fe6bd4de7783830cc", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263", @@ -17,7 +17,7 @@ "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "e99f2de69d0eb9e0b9dc235e2d568fc66d9dfb79cb0bb1364e01cc210a8c626f", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "18212bb64644cfbca401065bccce439ea5fa00316deff57d730a0d9ac2650e53", - "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "06cf36bdd746d520d3432a0a5a1ea9dbfa88c65af947b4ecf614e32b680f7e6c" + "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "09b74d9ff7056ce0b7d41a15be6ced8f8b99a37eb24d7f3c232fe895bcc7e458" } }, "nyxid": { From 2c31b0bc2378f53e7d4130341f1a44001e979f29 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:24:44 +0800 Subject: [PATCH 3/3] Refresh reviewed NyxID wire conformance baseline --- .../v1/README.md | 26 +++++++++++++ .../v1/sources.json | 37 +++++++++++++------ .../skill-service-recommendations.md | 4 +- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/docs/contracts/nyxid-code-execution-conformance/v1/README.md b/docs/contracts/nyxid-code-execution-conformance/v1/README.md index 7d47f7f826..c0af3439b0 100644 --- a/docs/contracts/nyxid-code-execution-conformance/v1/README.md +++ b/docs/contracts/nyxid-code-execution-conformance/v1/README.md @@ -12,6 +12,32 @@ The guard validates whole-file SHA-256 digests and semantic markers for: - catalog identity propagation, including the no-op same-value transition and customized-row count; - the `/keys` to `unified_key_service::create_key` credential-validation path. +## Reviewed upstream revision + +The current baseline reviews NyxID +[`301fbe732a0f20a3c674184e7ea3408ab62968ab`](https://github.com/ChronoAIProject/NyxID/commit/301fbe732a0f20a3c674184e7ea3408ab62968ab), +including the following changes since `cdd0e3fdad4b45365dc7da3effdb1c1447de8286`: + +| Upstream surface | Reviewed behavior and Aevatar impact | +| --- | --- | +| `handlers/api_keys.rs` | Optional conversation information and service-history storage were added. General/scheduled purpose, scheduled-write capability and durable-grant response semantics remain intact; Aevatar's security-class parser still rejects malformed or unexpected authority. | +| `handlers/keys.rs`, `handlers/user_services_handler.rs` | Additive icon, authorship and skill-revision fields do not change the fields consumed by Aevatar. API-key inventory reads now enforce the key's service scope and exclude Viewer organization rows; `/keys` uses read-only listing for API keys. Aevatar consumes caller-visible exact IDs and fails closed for missing/denied routes. It must not infer account-wide absence from scoped inventory. | +| `services/unified_key_service.rs` | `validate_token_exchange_catalog_credential` became `validate_catalog_credential` and also validates IFTTT credentials. The token-exchange branch still calls `provider_token_exchange_service::parse_credential` with the catalog's declared credential fields. The manifest checks the new call and that retained validation path. Other changes concern provisioning eligibility, history and deletion; read-only key listing remains distinct from provisioning. | +| `services/catalog_identity_service.rs` | Storage calls use the service-history collection. Same-value transitions remain no-ops and customized-row accounting still uses matched rows. Identity propagation field semantics are unchanged. | +| `handlers/proxy.rs`, `services/proxy_service.rs` | Proxy changes add destination routing, curation service-account restrictions and billing behavior. Exact instance routing, delegation-token injection and scheduled durable-operation headers remain enforced. Direct and node HTTP bearer forwarding now use `forwarded_caller_token`, which preserves an existing server-owned Authorization header. Its implementation is additionally digest-pinned so the forwarding contract is checked at its new owner. | + +The corresponding Aevatar consumers are `NyxIdApiAccessResponseParser`, +`NyxIdApiClient`, `NyxIdCodeExecutionRouteAdmissionPreparer`, +`NyxIdCodeExecutionPort` and `NyxIdDurableCodeExecutionPort`. Their consumed +fields and authorization invariants remain compatible; no production adapter +change is needed for this baseline refresh. Existing focused tests cover +unknown response fields, exact selection, scoped/denied inventory, forwarding +and delegation requirements, and durable headers. Guard self-tests exercise +digest drift, missing markers and unchanged descendant revisions. This is +source-contract review and local regression evidence, not a live deployment test. + +## Updating and checking the baseline + `nyxid.reviewed_revision` records the commit at which the hashes were reviewed. Validation does not require the checkout `HEAD` to equal that commit: `HEAD` may be that revision or a descendant on `main`. Any tracked source change still fails because its digest changes, so an upstream contract diff --git a/docs/contracts/nyxid-code-execution-conformance/v1/sources.json b/docs/contracts/nyxid-code-execution-conformance/v1/sources.json index 8cecef7011..23869bc169 100644 --- a/docs/contracts/nyxid-code-execution-conformance/v1/sources.json +++ b/docs/contracts/nyxid-code-execution-conformance/v1/sources.json @@ -3,17 +3,18 @@ "nyxid": { "repository": "https://github.com/ChronoAIProject/NyxID.git", "tracked_ref": "main", - "reviewed_revision": "cdd0e3fdad4b45365dc7da3effdb1c1447de8286" + "reviewed_revision": "301fbe732a0f20a3c674184e7ea3408ab62968ab" }, "wire_contract": { "revision": "nyxid-code-execution-wire.v1", "files": { - "backend/src/handlers/api_keys.rs": "820577ddec0da09cb0ab813c3c8e8e15c6caf2deb9d8667ee5b7e85b5232dc52", - "backend/src/handlers/keys.rs": "188140f2282305fc23ea944885c38e0d66e78edfd44253f05221597c04c761a7", - "backend/src/handlers/proxy.rs": "2320a918a05fce25959f30d5ee8247ae8cca25a8a6eeecef4cc87763f91d10df", - "backend/src/handlers/user_services_handler.rs": "f0afeb1315b581d597e3f076bef35795f967fc014d950ebd56e6dfad20843309", - "backend/src/services/catalog_identity_service.rs": "bf27e10c89a6d419910db20f39fe535d3f8b987e6e1f57fc306ee78a151e6979", - "backend/src/services/unified_key_service.rs": "b75932c6fb74a13cf875a9fe28be3156d2dbfbd8cfdb7bc2b660c9a0aae4da1a" + "backend/src/handlers/api_keys.rs": "3b9bca75b2ae0ad2b1c2e329d73fe91c4f2432d5d70a74749cb4f43c5063154a", + "backend/src/handlers/keys.rs": "217df762b1c1ce3ad3f2e90f2af096a7fc95be7cfb40530c0f3d2d0b5abdf1f4", + "backend/src/handlers/proxy.rs": "7f62c570306be81e4b85ba876107d6d3a6e1149c57af65a626f53274c94d882c", + "backend/src/handlers/user_services_handler.rs": "bd10cdfe9f0e8bbcb561e542beb98d2a947688340ed4094a66c2cb50c2ef99f7", + "backend/src/services/catalog_identity_service.rs": "ad46e5583784041ba358cfdcc1ae86972955e672d6959755a845e6472dc65ef7", + "backend/src/services/proxy_service.rs": "55eff78ffaeb6b18671a247c81180f2b17c9af859d1b25a046b14a53610ae0ca", + "backend/src/services/unified_key_service.rs": "23edabcfae267f7c8c83d32b97c4ae574e123322f21251bd07de40d495c6b06e" }, "required_markers": { "backend/src/handlers/api_keys.rs": [ @@ -28,20 +29,25 @@ "pub auto_connected: bool,", "pub credential: Option,", "let credential = body.credential.as_deref().unwrap_or(\"\");", - "unified_key_service::create_key(" + "unified_key_service::create_key(", + "unified_key_service::list_keys_read_only_with_grants(", + "crate::services::key_service::ensure_api_key_service_scope(" ], "backend/src/handlers/proxy.rs": [ "if target.service.inject_delegation_token {", "&target.service.delegation_token_scope,", "X-NyxID-Durable-Grant-Id is required for scheduled_invocation keys", "X-NyxID-Operation-Id is required for scheduled_invocation keys", - "if target.service.forward_access_token" + "if target.service.forward_access_token", + "proxy_service::forwarded_caller_token(" ], "backend/src/handlers/user_services_handler.rs": [ "pub catalog_service_id: Option,", "pub forward_access_token: bool,", "pub inject_delegation_token: bool,", - "pub delegation_token_scope: String," + "pub delegation_token_scope: String,", + "let scope = auth_user.api_key_service_scope();", + "scope.is_none_or(|ids| ids.contains(&item.service.id))" ], "backend/src/services/catalog_identity_service.rs": [ "if changes.is_empty() {", @@ -53,7 +59,16 @@ "backend/src/services/unified_key_service.rs": [ "if credential.is_empty()", "Credential is required for direct routing (or select a node)", - "validate_token_exchange_catalog_credential(&svc, credential)?;" + "validate_catalog_credential(&svc, credential)?;", + "if svc.auth_method != \"token_exchange\"", + "crate::services::provider_token_exchange_service::parse_credential(", + "&exchange_config.credential_fields," + ], + "backend/src/services/proxy_service.rs": [ + "pub(crate) fn forwarded_caller_token<'a>(", + "if target.service.forward_access_token", + "name.eq_ignore_ascii_case(\"authorization\")", + "if let Some(token) = forwarded_caller_token(target, caller_token, &extra_outbound_headers) {" ] }, "forbidden_markers": { diff --git a/docs/contracts/skill-service-recommendations.md b/docs/contracts/skill-service-recommendations.md index 4c76346a7c..3f9f5c915a 100644 --- a/docs/contracts/skill-service-recommendations.md +++ b/docs/contracts/skill-service-recommendations.md @@ -39,7 +39,9 @@ channel registration, limited to 128 characters and excluding dot path segments. `evidence` is `linked`, `catalog`, or `mention`. These are advisory sources, not required/optional dependency declarations. Empty `instances` means no -matching connection in the caller's account inventory. `allowed` reports +matching connection in the caller-visible inventory. Restricted Agent Keys may +see only their granted services, so absence is not proof that the account has no +connection. `allowed` reports account-level access only; it does not grant access to the current session or channel. `source` is `personal`, `organization`, or `unknown`. Existing channel authorization still uses explicitly selected exact UserService IDs.