From c923ae7bcfd50efca7505b934a7a1da68bd437d6 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 19 Aug 2026 13:03:34 +0800 Subject: [PATCH 1/3] Pin NyxID assistant registry v8 and add dormant service.reauthorize action path Pin registry revision nyxid-assistant-actions.v8 (service.connect, key.create, key.rotate, service.reauthorize) ahead of NyxID#1400 with the assumed exact descriptor in registry-v8.json, and add the typed service.reauthorize machinery: producer tool class, shared browser-action helpers, registry mapper with strict identity/scope validation, blocker sub-message, AG-UI branch, projection fields. The verb stays unadvertised (no tool mount, intent candidate, or prompt line) while NyxID production serves v7; a follow-up branch advertises it after v8 ships. Co-Authored-By: Claude Fable 5 --- .../NyxIdChatTurnIntentClassifier.cs | 1 + .../NyxIdAssistantActionRegistry.cs | 183 ++++++++++-- .../NyxIdChatBrowserActions.cs | 28 +- .../NyxIdChatConversationAguiFrameBuilder.cs | 6 +- .../NyxIdChatConversationGAgent.cs | 2 + .../NyxIdChatTurnOperationExecutor.cs | 5 +- .../protos/nyxid_chat_task.proto | 8 +- docs/canon/nyxid-chat-api.md | 7 +- .../nyxid-assistant-conformance/v1/README.md | 31 ++ .../v1/registry-v8.json | 166 +++++++++++ .../v1/sources.json | 9 +- src/Aevatar.AI.Abstractions/ai_messages.proto | 6 + .../NyxIdBrowserActionRequestToolHelpers.cs | 96 ++++++ .../Tools/NyxIdRequestKeyCreateTool.cs | 69 +---- .../Tools/NyxIdRequestKeyRotateTool.cs | 79 +---- .../NyxIdRequestServiceReauthorizeTool.cs | 247 ++++++++++++++++ .../INyxIdChatConversationStateQueryPort.cs | 6 + ...tionNyxIdChatConversationStateQueryPort.cs | 5 + ...IdChatConversationCurrentStateProjector.cs | 9 + .../studio_projection_readmodels.proto | 6 + .../NyxIdAssistantActionRegistryTests.cs | 226 +++++++++++++- .../NyxIdChatAguiSseEventWriterTests.cs | 8 +- .../NyxIdChatBrowserActionTests.cs | 275 +++++++++++++++++- .../NyxIdChatConversationGAgentTests.cs | 107 ++++++- .../NyxIdConformanceManifestTests.cs | 23 +- ...NyxIdRequestServiceReauthorizeToolTests.cs | 258 ++++++++++++++++ ...tConversationCurrentStateProjectorTests.cs | 44 +++ ...yxIdChatConversationStateQueryPortTests.cs | 45 +++ .../ci/tests/test_nyxid_conformance_guard.py | 1 + 29 files changed, 1761 insertions(+), 195 deletions(-) create mode 100644 docs/contracts/nyxid-assistant-conformance/v1/registry-v8.json create mode 100644 src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs create mode 100644 src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs create mode 100644 test/Aevatar.AI.Tests/NyxIdRequestServiceReauthorizeToolTests.cs diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/NyxIdChatTurnIntentClassifier.cs b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/NyxIdChatTurnIntentClassifier.cs index 853a087767..0784756e0c 100644 --- a/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/NyxIdChatTurnIntentClassifier.cs +++ b/agents/Aevatar.GAgents.NyxidChat/AgentProfiles/NyxIdChatTurnIntentClassifier.cs @@ -29,6 +29,7 @@ public sealed class NyxIdChatTurnIntentClassifier : INyxIdChatTurnIntentClassifi internal const string KeyRotateIntentId = "key_rotate"; internal const string KeyRotateRoutingDescription = "Rotate one exact caller-visible NyxID API key through the browser-owned secure journey."; + internal const string ServiceReauthorizeIntentId = "service_reauthorize"; private static readonly TimeSpan ClassificationTimeout = TimeSpan.FromSeconds(15); internal static AgentProfileTurnClassificationCandidate ServiceConnectCandidate { get; } = new( diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs index 69d9bb77ff..151d83932b 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs @@ -31,7 +31,8 @@ public sealed class NyxIdAssistantActionRegistry public const string LegacyRegistryRevision = "nyxid-assistant-actions.v4"; public const string WaveOneDraftRegistryRevision = "nyxid-assistant-actions.v5"; public const string LeastScopeRegistryRevision = "nyxid-assistant-actions.v6"; - public const string SupportedRegistryRevision = "nyxid-assistant-actions.v7"; + public const string KeyRotationRegistryRevision = "nyxid-assistant-actions.v7"; + public const string SupportedRegistryRevision = "nyxid-assistant-actions.v8"; public const string ServiceAccessReviewRegistryRevision = "aevatar-nyxid-actions.v1"; @@ -45,6 +46,13 @@ public sealed class NyxIdAssistantActionRegistry private const string PolicyCallerOwned = "NYXID_ACTION_POLICY_CALLER_OWNED"; private const string RegistryInvalid = "NYXID_ACTION_REGISTRY_INVALID"; + private const string ServiceReauthorizeIdentityInvalidMessage = + "The service reauthorization identity is invalid."; + private const string ServiceReauthorizeScopeCountInvalidMessage = + "Service reauthorization requires an exact nonempty scope set."; + private const string ServiceReauthorizeScopesInvalidMessage = + "The service reauthorization scopes are invalid."; + private const string ServiceConnectParamsSchema = """ { "oneOf": [ @@ -223,11 +231,18 @@ public sealed class NyxIdAssistantActionRegistry "service.connect", "key.create", }.ToFrozenSet(StringComparer.Ordinal), + [KeyRotationRegistryRevision] = new[] + { + "service.connect", + "key.create", + "key.rotate", + }.ToFrozenSet(StringComparer.Ordinal), [SupportedRegistryRevision] = new[] { "service.connect", "key.create", "key.rotate", + "service.reauthorize", }.ToFrozenSet(StringComparer.Ordinal), }.ToFrozenDictionary(StringComparer.Ordinal); @@ -243,7 +258,15 @@ public sealed class NyxIdAssistantActionRegistry .ToFrozenSet(StringComparer.Ordinal), [LeastScopeRegistryRevision] = new[] { "service.connect", "key.create" } .ToFrozenSet(StringComparer.Ordinal), - [SupportedRegistryRevision] = new[] { "service.connect", "key.create", "key.rotate" } + [KeyRotationRegistryRevision] = new[] { "service.connect", "key.create", "key.rotate" } + .ToFrozenSet(StringComparer.Ordinal), + [SupportedRegistryRevision] = new[] + { + "service.connect", + "key.create", + "key.rotate", + "service.reauthorize", + } .ToFrozenSet(StringComparer.Ordinal), }.ToFrozenDictionary(StringComparer.Ordinal); @@ -309,6 +332,7 @@ internal static bool IsActionExecutable( NyxIdAssistantActionKind.ServiceConnect => "service.connect", NyxIdAssistantActionKind.KeyCreate => "key.create", NyxIdAssistantActionKind.KeyRotate => "key.rotate", + NyxIdAssistantActionKind.ServiceReauthorize => "service.reauthorize", _ => null, }; return wireAction is not null && @@ -582,22 +606,13 @@ public NyxIdAssistantActionValidation ResolveKeyCreate( var name = NormalizeString(requirement.Name, 256, required: true); var platform = NormalizeString(requirement.Platform, 128, required: true); - if (requirement.AllowedServiceIds.Count is < 1 or > 64) - throw Error(ParamsInvalid, "Key creation requires an exact nonempty service set."); - - var allowedServiceIds = new List(requirement.AllowedServiceIds.Count); - var distinct = new HashSet(StringComparer.Ordinal); - foreach (var serviceId in requirement.AllowedServiceIds) - { - var normalized = NormalizeString(serviceId, 256, required: true); - if (!string.Equals(serviceId, normalized, StringComparison.Ordinal) || - !distinct.Add(normalized)) - { - throw Error(ParamsInvalid, "The key creation service identities are invalid."); - } - - allowedServiceIds.Add(normalized); - } + var allowedServiceIds = NormalizeDistinctSet( + requirement.AllowedServiceIds, + minCount: 1, + maxCount: 64, + maxItemLength: 256, + countInvalidMessage: "Key creation requires an exact nonempty service set.", + itemInvalidMessage: "The key creation service identities are invalid."); var value = new NyxIdKeyCreateParams { @@ -621,13 +636,10 @@ public NyxIdAssistantActionValidation ResolveKeyRotate( throw Error(ActionUnsupported, "Key rotation is not present in the pinned registry."); } - var keyId = NormalizeString(requirement.KeyId, 256, required: true); - if (!string.Equals(requirement.KeyId, keyId, StringComparison.Ordinal) || - keyId.Any(char.IsWhiteSpace) || - keyId.Any(static character => character is '/' or '\\' or '?' or '#')) - { - throw Error(ParamsInvalid, "The key rotation identity is invalid."); - } + var keyId = NormalizeSafeIdentity( + requirement.KeyId, + 256, + "The key rotation identity is invalid."); return new NyxIdAssistantActionValidation( entry.Definition.Clone(), @@ -637,6 +649,38 @@ public NyxIdAssistantActionValidation ResolveKeyRotate( }); } + public NyxIdAssistantActionValidation ResolveServiceReauthorize( + NyxIdServiceReauthorizeActionRequirement requirement) + { + ArgumentNullException.ThrowIfNull(requirement); + if (!_entries.TryGetValue("service.reauthorize", out var entry) || + !_executableActions.Contains("service.reauthorize") || + entry.Definition.Action != NyxIdAssistantActionKind.ServiceReauthorize) + { + throw Error( + ActionUnsupported, + "Service reauthorization is not present in the pinned registry."); + } + + var userServiceId = NormalizeSafeIdentity( + requirement.UserServiceId, + 256, + ServiceReauthorizeIdentityInvalidMessage); + var requestedScopes = NormalizeDistinctSet( + requirement.RequestedScopes, + minCount: 1, + maxCount: 64, + maxItemLength: 256, + countInvalidMessage: ServiceReauthorizeScopeCountInvalidMessage, + itemInvalidMessage: ServiceReauthorizeScopesInvalidMessage); + + var value = new NyxIdServiceReauthorizeParams { UserServiceId = userServiceId }; + value.RequestedScopes.Add(requestedScopes); + return new NyxIdAssistantActionValidation( + entry.Definition.Clone(), + new NyxIdAssistantActionParams { ServiceReauthorize = value }); + } + private static NyxIdAssistantActionParams ParseServiceConnect(JsonElement root) { EnsureOnlyProperties(root, "catalogService", "customService"); @@ -698,11 +742,25 @@ private static NyxIdAssistantActionParams ParseServiceConnect(JsonElement root) internal static NyxIdAssistantActionParams ParseServiceReauthorize(JsonElement root) { EnsureOnlyProperties(root, "userServiceId", "requestedScopes"); + var requestedScopes = ReadStringArray( + root, + "requestedScopes", + 64, + 256, + rejectDuplicates: true, + rejectNormalizationChanges: true); + if (requestedScopes.Count == 0) + throw Error(ParamsInvalid, ServiceReauthorizeScopeCountInvalidMessage); + var value = new NyxIdServiceReauthorizeParams { - UserServiceId = ReadRequiredString(root, "userServiceId", 256), + UserServiceId = ReadSafeIdentity( + root, + "userServiceId", + 256, + ServiceReauthorizeIdentityInvalidMessage), }; - value.RequestedScopes.AddRange(ReadStringArray(root, "requestedScopes", 64, 256)); + value.RequestedScopes.AddRange(requestedScopes); return new NyxIdAssistantActionParams { ServiceReauthorize = value }; } @@ -883,7 +941,9 @@ private static void ValidatePinnedContract( NyxIdAssistantActionRisk risk, bool rememberEligible) { - var pinnedParamsSchema = revision is LeastScopeRegistryRevision or SupportedRegistryRevision && + var pinnedParamsSchema = revision is LeastScopeRegistryRevision + or KeyRotationRegistryRevision + or SupportedRegistryRevision && contract.Action == NyxIdAssistantActionKind.KeyCreate ? LeastScopeKeyCreateParamsSchema : contract.PinnedParamsSchema; @@ -1151,6 +1211,73 @@ private static string ReadEnumString( : throw Error(ParamsInvalid, "An action enum value is invalid."); } + private static string ReadSafeIdentity( + JsonElement element, + string name, + int maxLength, + string invalidMessage) + { + if (!element.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.String) + { + throw Error(ParamsInvalid, "A required action string is missing."); + } + + return NormalizeSafeIdentity(property.GetString(), maxLength, invalidMessage); + } + + /// + /// Identity values travel verbatim into NyxID resource paths, so they must + /// already be canonical (no surrounding whitespace) and free of path or + /// query delimiters. + /// + internal static bool IsSafeIdentity(string value) => + !value.Any(char.IsWhiteSpace) && + !value.Any(static character => character is '/' or '\\' or '?' or '#'); + + private static string NormalizeSafeIdentity( + string? raw, + int maxLength, + string invalidMessage) + { + var normalized = NormalizeString(raw, maxLength, required: true); + if (!string.Equals(raw, normalized, StringComparison.Ordinal) || + !IsSafeIdentity(normalized)) + { + throw Error(ParamsInvalid, invalidMessage); + } + + return normalized; + } + + private static IReadOnlyList NormalizeDistinctSet( + IReadOnlyCollection values, + int minCount, + int maxCount, + int maxItemLength, + string countInvalidMessage, + string itemInvalidMessage) + { + if (values.Count < minCount || values.Count > maxCount) + throw Error(ParamsInvalid, countInvalidMessage); + + var normalizedValues = new List(values.Count); + var distinct = new HashSet(StringComparer.Ordinal); + foreach (var value in values) + { + var normalized = NormalizeString(value, maxItemLength, required: true); + if (!string.Equals(value, normalized, StringComparison.Ordinal) || + !distinct.Add(normalized)) + { + throw Error(ParamsInvalid, itemInvalidMessage); + } + + normalizedValues.Add(normalized); + } + + return normalizedValues; + } + private static string NormalizeString(string? value, int maxLength, bool required) { var normalized = value?.Trim() ?? string.Empty; diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs index b758aef79e..9433101758 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs @@ -69,6 +69,7 @@ public static NyxIdChatBrowserActionDecision RequestAuthorization( !string.IsNullOrWhiteSpace(blocker?.ServiceSlug); var hasKeyCreate = blocker?.KeyCreate is not null; var hasKeyRotate = blocker?.KeyRotate is not null; + var hasServiceReauthorize = blocker?.ServiceReauthorize is not null; if (receipt?.Status != AgentToolReceiptStatus.AuthorizationRequired || blocker is null || signalKey is null || @@ -77,7 +78,8 @@ signalKey is null || (hasServiceAccessReview ? 1 : 0) + (hasCatalogServiceConnect ? 1 : 0) + (hasKeyCreate ? 1 : 0) + - (hasKeyRotate ? 1 : 0) != 1 || + (hasKeyRotate ? 1 : 0) + + (hasServiceReauthorize ? 1 : 0) != 1 || state.ActiveTurn is null || state.ActiveTask is null) { @@ -106,7 +108,9 @@ state.ActiveTurn is null || : hasKeyCreate ? registry.ResolveKeyCreate(blocker.KeyCreate) : hasKeyRotate - ? registry.ResolveKeyRotate(blocker.KeyRotate) + ? registry.ResolveKeyRotate(blocker.KeyRotate) + : hasServiceReauthorize + ? registry.ResolveServiceReauthorize(blocker.ServiceReauthorize) : registry.ResolveCatalogServiceConnect( blocker.ServiceSlug, blocker.RequestedScopes); @@ -1221,6 +1225,8 @@ NyxIdAssistantActionParams.ParamsOneofCase.CatalogServiceConnect or IsValidServiceAccessReviewParams(request.Params?.ServiceAccessReview), NyxIdAssistantActionKind.KeyCreate => IsValidKeyCreateParams(request.Params?.KeyCreate), NyxIdAssistantActionKind.KeyRotate => IsValidKeyRotateParams(request.Params?.KeyRotate), + NyxIdAssistantActionKind.ServiceReauthorize => + IsValidServiceReauthorizeParams(request.Params?.ServiceReauthorize), _ => false, }; @@ -1267,8 +1273,22 @@ private static bool IsValidServiceAccessReviewParams( private static bool IsValidKeyRotateParams(NyxIdKeyRotateParams? value) => value is not null && IsNormalizedActionValue(value.KeyId, 256) && - !value.KeyId.Any(char.IsWhiteSpace) && - !value.KeyId.Any(static character => character is '/' or '\\' or '?' or '#'); + NyxIdAssistantActionRegistry.IsSafeIdentity(value.KeyId); + + private static bool IsValidServiceReauthorizeParams(NyxIdServiceReauthorizeParams? value) + { + if (value is null || + !IsNormalizedActionValue(value.UserServiceId, 256) || + !NyxIdAssistantActionRegistry.IsSafeIdentity(value.UserServiceId) || + value.RequestedScopes.Count is < 1 or > 64) + { + return false; + } + + var scopes = new HashSet(StringComparer.Ordinal); + return value.RequestedScopes.All(scope => + IsNormalizedActionValue(scope, 256) && scopes.Add(scope)); + } private static bool IsNormalizedActionValue(string? value, int maxLength) => !string.IsNullOrWhiteSpace(value) && diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs index 36ec8113b1..c469493402 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs @@ -354,7 +354,11 @@ public static IReadOnlyList BuildApprovalChanged( NyxIdAssistantActionParams.ParamsOneofCase.ServiceReauthorize => new NyxIdAssistantActionWireParams { - ServiceReauthorize = request.Params.ServiceReauthorize.Clone(), + ServiceReauthorizeUserServiceId = request.Params.ServiceReauthorize.UserServiceId, + ServiceReauthorizeRequestedScopes = + { + request.Params.ServiceReauthorize.RequestedScopes, + }, }, NyxIdAssistantActionParams.ParamsOneofCase.ServiceAccessReview => new NyxIdAssistantActionWireParams diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs index 33ab8cc9f2..48cf8aa0bb 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs @@ -3085,6 +3085,8 @@ private async Task ClassifyTurnIntentAsync( NyxIdChatTurnIntent.KeyCreate, NyxIdChatTurnIntentClassifier.KeyRotateIntentId => NyxIdChatTurnIntent.KeyRotate, + NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId => + NyxIdChatTurnIntent.ServiceReauthorize, _ => NyxIdChatTurnIntent.Unspecified, }; } diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs index 3eecb571ff..c905d50993 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs @@ -2511,7 +2511,8 @@ private void LogVerifiedAuthorizationCatalogDiagnostic( private static bool IsBuiltInIntent(NyxIdChatTurnIntent intent) => intent is NyxIdChatTurnIntent.ServiceConnect or NyxIdChatTurnIntent.KeyCreate or - NyxIdChatTurnIntent.KeyRotate; + NyxIdChatTurnIntent.KeyRotate or + NyxIdChatTurnIntent.ServiceReauthorize; private static AgentToolExecutionContext ResolveCatalogToolContext( NeedsLlmReplyEvent request) @@ -2546,6 +2547,8 @@ private static bool IsProfileSelectedBuiltInIntent( NyxIdChatTurnIntentClassifier.KeyCreateIntentId, NyxIdChatTurnIntent.KeyRotate => NyxIdChatTurnIntentClassifier.KeyRotateIntentId, + NyxIdChatTurnIntent.ServiceReauthorize => + NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId, _ => null, }; return intentId is not null && string.Equals( diff --git a/agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto b/agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto index 777c0d5f07..dd397b4865 100644 --- a/agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto +++ b/agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto @@ -43,6 +43,7 @@ enum NyxIdChatTurnIntent { NYX_ID_CHAT_TURN_INTENT_SERVICE_CONNECT = 1; NYX_ID_CHAT_TURN_INTENT_KEY_CREATE = 2; NYX_ID_CHAT_TURN_INTENT_KEY_ROTATE = 3; + NYX_ID_CHAT_TURN_INTENT_SERVICE_REAUTHORIZE = 4; } enum NyxIdChatTaskStatus { @@ -939,16 +940,21 @@ message NyxIdAssistantActionParams { // postcondition facts remain internal committed state and never leak into the // browser-action request frame. message NyxIdAssistantActionWireParams { + // Field 3 previously nested service.reauthorize params under a wrapper; + // the NyxID descriptor schema is flat, so the wire form now uses 9 and 10. + reserved 3; + reserved "service_reauthorize"; oneof params { NyxIdCatalogServiceConnectParams catalog_service = 1; NyxIdCustomServiceConnectParams custom_service = 2; - NyxIdServiceReauthorizeParams service_reauthorize = 3; NyxIdServiceAccessReviewParams service_access_review = 8; } string key_create_name = 4 [json_name = "name"]; string key_create_platform = 5 [json_name = "platform"]; repeated string key_create_allowed_service_ids = 6 [json_name = "allowedServiceIds"]; string key_rotate_key_id = 7 [json_name = "keyId"]; + string service_reauthorize_user_service_id = 9 [json_name = "userServiceId"]; + repeated string service_reauthorize_requested_scopes = 10 [json_name = "requestedScopes"]; } message NyxIdAssistantActionRequestWirePayload { diff --git a/docs/canon/nyxid-chat-api.md b/docs/canon/nyxid-chat-api.md index 78ae39c73b..efcb309fb2 100644 --- a/docs/canon/nyxid-chat-api.md +++ b/docs/canon/nyxid-chat-api.md @@ -492,11 +492,11 @@ Expiry always fails closed as denial, never as approval. At or after `expiresAt` Aevatar owns action intent, task correlation, safe parameter references, and the decision to continue. NyxID owns the browser card and journey, consent copy, auth modality, mutation, credential storage, and final authorization. -Aevatar snapshots `GET /api/v1/assistant/actions` at startup and accepts schema version `4` with registry revision `nyxid-assistant-actions.v4`, `nyxid-assistant-actions.v5`, `nyxid-assistant-actions.v6`, or `nyxid-assistant-actions.v7` during the bounded rollout transition. Revision v4 recognizes only `service.connect`. Revision v5 pins `service.connect`, `service.reauthorize`, `key.create`, and `key.rotate` but keeps the three new actions non-executable. Revision v6 is the immutable least-scope contract containing `service.connect` and `key.create`; its `allowedServiceIds` schema requires 1 to 64 unique string identities. Revision v7 retains that least-scope contract and adds executable `key.rotate` with one exact predecessor key ID. Each revision validates every present descriptor's exact parameter schema and registry-owned risk/remember policy. The registry's `risk` and `remember_eligible` values are advisory inputs to Aevatar presentation/planning. The caller cannot submit or lower them, and NyxID recomputes and enforces authorization at execution time. +Aevatar snapshots `GET /api/v1/assistant/actions` at startup and accepts schema version `4` with registry revision `nyxid-assistant-actions.v4`, `nyxid-assistant-actions.v5`, `nyxid-assistant-actions.v6`, `nyxid-assistant-actions.v7`, or `nyxid-assistant-actions.v8` during the bounded rollout transition. Revision v4 recognizes only `service.connect`. Revision v5 pins `service.connect`, `service.reauthorize`, `key.create`, and `key.rotate` but keeps the three new actions non-executable. Revision v6 is the immutable least-scope contract containing `service.connect` and `key.create`; its `allowedServiceIds` schema requires 1 to 64 unique string identities. Revision v7 retains that least-scope contract and adds executable `key.rotate` with one exact predecessor key ID. Revision v8 retains v7 and adds `service.reauthorize` with one exact connected UserService ID plus a nonempty requested scope list. Aevatar pins v8 ahead of NyxID publishing it, with the exact descriptor recorded in `docs/contracts/nyxid-assistant-conformance/v1/registry-v8.json` (`params_schema` = object with `additionalProperties: false`, required `userServiceId: string` and `requestedScopes: string[]` with no item-count or uniqueness constraints; `risk: grant`; `tier: v1`; `remember_eligible: false`). Both the revision name `nyxid-assistant-actions.v8` and those descriptor bytes are assumptions about NyxID#1400 that NyxID has not yet published; the pin only avoids `NYXID_ACTION_REGISTRY_REVISION_UNSUPPORTED` if NyxID ships exactly that contract. A v8 payload whose `service.reauthorize` descriptor differs in `params_schema`, `risk`, or `remember_eligible`, or a differently named revision, fails `Load` at startup and installs the disabled registry, so all browser actions fail closed with the same blast radius the pin is meant to prevent. While NyxID production still serves v7, `service.reauthorize` is executable at v8 but deliberately not advertised: `nyxid_request_service_reauthorize` exists as a typed producer yet is mounted on no tool source, has no turn-intent candidate or built-in materializer member, and is absent from the system prompt. The dormant path (typed producer, registry mapper, blocker sub-message, AG-UI branch, projection fields) is exercised only by tests until a follow-up change advertises the verb after NyxID publishes v8 and the process re-snapshots the registry; if a `service.reauthorize` blocker ever reaches a v7 process, `NyxIdChatBrowserActions.RequestAuthorization` still resolves it to `NYXID_ACTION_UNSUPPORTED` and the turn fails closed without committing a browser action. Each revision validates every present descriptor's exact parameter schema and registry-owned risk/remember policy. The registry's `risk` and `remember_eligible` values are advisory inputs to Aevatar presentation/planning. The caller cannot submit or lower them, and NyxID recomputes and enforces authorization at execution time. This startup dependency is active only when `Aevatar:NyxId:AssistantActions:Enabled=true`. The reusable NyxIdChat composition default is `false`: a host that does not opt in does not call the registry endpoint and injects an immutable registry with no executable actions, so browser-action requests fail closed with `NYXID_ACTION_UNSUPPORTED` without preventing unrelated capabilities from starting. Mainnet explicitly enables assistant actions and fetches the registry from the public `Aevatar:NyxId:ApiBaseUrl`, never from `InternalApiBaseUrl`. A fetch, timeout, read, JSON, schema, or revision failure initializes the same immutable disabled registry and emits a scrubbed error, allowing ordinary chat and the Host to start. Host cancellation still aborts startup. The registry remains a one-shot process snapshot, so a degraded process does not enable browser actions until its next successful start. -The typed registry recognizes closed action schemas, but executable handoff is narrower: an action must also have an Aevatar producer, wire mapper, and typed postcondition reader. Revisions v4 and v5 execute only `service.connect`. Revision v6 executes `service.connect` and least-scope `key.create`; the key-create parser rejects missing, empty, over-limit, or duplicate service identities, and the producer emits only exact nonempty owner-visible UserService IDs. Revision v7 additionally executes `key.rotate`: the producer first resolves one exact owner-visible active key, emits only its safe ID, and completion is verified only after an owner-scoped exact replacement-key read proves the reported successor ID, the requested predecessor ID, a positive authority version, and immutable `created_at` plus authoritative `updated_at` no earlier than the committed action request. A later update to an older successor cannot satisfy the immutable creation-time fence. The AG-UI mapper carries these typed requests without key material. `service.reauthorize` remains fail closed at the executable gate. A browser completion report or permitted service access value alone is never effect proof. Catalog and custom connection are distinct variants; a boolean such as `custom: true` never changes the meaning of one shared field set. +The typed registry recognizes closed action schemas, but executable handoff is narrower: an action must also have an Aevatar producer, wire mapper, and typed postcondition reader. Revisions v4 and v5 execute only `service.connect`. Revision v6 executes `service.connect` and least-scope `key.create`; the key-create parser rejects missing, empty, over-limit, or duplicate service identities, and the producer emits only exact nonempty owner-visible UserService IDs. Revision v7 additionally executes `key.rotate`: the producer first resolves one exact owner-visible active key, emits only its safe ID, and completion is verified only after an owner-scoped exact replacement-key read proves the reported successor ID, the requested predecessor ID, a positive authority version, and immutable `created_at` plus authoritative `updated_at` no earlier than the committed action request. A later update to an older successor cannot satisfy the immutable creation-time fence. Revision v8 additionally executes `service.reauthorize`: the producer first resolves one exact owner-visible active connected UserService by ID (never by slug or display name), emits only its safe ID and the exact requested scopes, and completion is verified only after an owner-scoped exact UserService read proves the reported UserService ID matches the request, the credential and OAuth connection are active, the granted scopes cover every requested scope, and `last_authorized_at` falls no earlier than the committed action request. `service.reauthorize` stays fail closed at the executable gate for revisions v4 through v7. The AG-UI mapper carries these typed requests without key material, tokens, or authorization codes. A browser completion report or permitted service access value alone is never effect proof. Catalog and custom connection are distinct variants; a boolean such as `custom: true` never changes the meaning of one shared field set. ### Request wire frame @@ -668,7 +668,8 @@ latest safe input/approval resolution facts, typed `pendingActions` and bounded actor-authored attention, and actor version. It also exposes the exact safe typed parameters needed to resume browser actions after reload: `key.create` preserves `name`, `platform`, and the nonempty -`allowedServiceIds`; `key.rotate` preserves only `keyId`. These values come +`allowedServiceIds`; `key.rotate` preserves only `keyId`; `service.reauthorize` +preserves `userServiceId` and the nonempty `requestedScopes`. These values come from the committed actor state through the same current-state projection and never include full key material, credentials, or an alternate query-time reconstruction path. It also exposes diff --git a/docs/contracts/nyxid-assistant-conformance/v1/README.md b/docs/contracts/nyxid-assistant-conformance/v1/README.md index 6b38a1883a..571c2ac145 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/README.md +++ b/docs/contracts/nyxid-assistant-conformance/v1/README.md @@ -19,6 +19,37 @@ bash tools/ci/nyxid_conformance_guard.sh Commit the resulting `sources.json` update only after the guard passes. +## When NyxID publishes `nyxid-assistant-actions.v8` + +Aevatar pins v8 ahead of NyxID (`registry-v8.json` is the assumed exact +descriptor set; production NyxID still serves v7). `service.reauthorize` is +executable at v8 but deliberately unadvertised (no tool mount, no intent +candidate) until the flip below lands. Once NyxID production serves v8 +(ChronoAIProject/NyxID#1400): + +1. Diff the served v8 manifest against `registry-v8.json`; a byte or name + difference is a NyxID contract question, not a local patch. +2. In `sources.json`, set `assistant_registry.revision` to + `nyxid-assistant-actions.v8`, point `checked_in_payload` / + `checked_in_payload_sha256` at `registry-v8.json`, and refresh + `nyxid_source_sha256` plus `nyxid.revision` / `nyxid.tree` from the + publishing NyxID commit. +3. In `coverage-manifest.json`, flip the `service scopes` + (`service.reauthorize`) row to `status: shipped`, `availability: + executable`, `outcome_class: browser_action`, `mechanism: + typed_browser_action`, `evidence_type: typed_postcondition_read_model`, + and name its four artifacts (registry, `NyxIdRequestServiceReauthorizeTool`, + AG-UI frame builder, postcondition port); then recompute + `generated_artifacts["coverage-manifest.json"]`. Because + `semantic-evaluation.json` pins the raw `coverage_manifest_sha256`, this + flip also requires a fresh authenticated semantic evaluation run (see + "Run" below); the manifest row therefore stays byte-identical until then. +4. Update the `registry-v7.json` literal in + `test/Aevatar.AI.Tests/NyxIdConformanceManifestTests.cs` to `registry-v8.json`. +5. Merge the advertise branch (tool-source mounts, intent candidate, + materializer member, system-prompt line) and refresh the Aevatar pin again + with `--refresh-aevatar-revision`. + `semantic-evaluation.json` is the checked-in release-gate record. The conformance guard fails while its status is not `passed`, while results are absent, or when the recorded aggregate cannot be reproduced from the case evidence. diff --git a/docs/contracts/nyxid-assistant-conformance/v1/registry-v8.json b/docs/contracts/nyxid-assistant-conformance/v1/registry-v8.json new file mode 100644 index 0000000000..81d6b583c2 --- /dev/null +++ b/docs/contracts/nyxid-assistant-conformance/v1/registry-v8.json @@ -0,0 +1,166 @@ +{ + "schema_version": 4, + "revision": "nyxid-assistant-actions.v8", + "actions": [ + { + "action": "service.connect", + "description": "Ask the user's browser to connect a service through NyxID. Use when a task needs a catalog service (by slug) or a custom HTTPS endpoint that the user has not connected yet. NyxID owns the entire journey - auth modality, consent copy, and credential storage - and reports back only completion or decline with a safe resource reference. Never ask the user for keys, tokens, or passwords in chat.", + "params_schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "catalogService" + ], + "properties": { + "catalogService": { + "type": "object", + "additionalProperties": false, + "required": [ + "serviceSlug" + ], + "properties": { + "serviceSlug": { + "type": "string" + }, + "requestedScopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "viaNodeId": { + "type": "string" + }, + "targetOrgId": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "customService" + ], + "properties": { + "customService": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "endpointUrl", + "authMethod" + ], + "properties": { + "name": { + "type": "string" + }, + "endpointUrl": { + "type": "string" + }, + "authMethod": { + "type": "string" + }, + "authKeyName": { + "type": "string" + }, + "viaNodeId": { + "type": "string" + }, + "targetOrgId": { + "type": "string" + } + } + } + } + } + ] + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": true + }, + { + "action": "key.create", + "description": "Ask the user's browser to create a scoped NyxID API key for the named platform and allowed services. Use when the user wants a new agent identity bounded to specific user-service IDs. NyxID owns key creation and one-time key display, and reports only a safe key reference. Never request, expose, or repeat key material in chat.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "platform", + "allowedServiceIds" + ], + "properties": { + "name": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "allowedServiceIds": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + }, + { + "action": "key.rotate", + "description": "Ask the user's browser to rotate one exact NyxID API key. Use when the user needs a replacement credential for the identified key. NyxID commits an authoritative predecessor-successor relation, displays replacement key material once in the browser, and reports only the replacement key reference. Never request, expose, or repeat key material in chat.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId" + ], + "properties": { + "keyId": { + "type": "string" + } + } + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + }, + { + "action": "service.reauthorize", + "description": "Ask the user's browser to re-authorize an existing connected service and review its requested scopes. Use when a task needs permissions that the referenced user service does not currently grant. NyxID owns the authorization journey and credential storage, and reports only a safe user-service reference. Never ask the user for keys, tokens, passwords, or authorization codes in chat.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "userServiceId", + "requestedScopes" + ], + "properties": { + "userServiceId": { + "type": "string" + }, + "requestedScopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + } + ] +} diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 497e74a6fb..9d2435cfe8 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -15,8 +15,10 @@ "src/Aevatar.AI.Abstractions/ai_messages.proto": "099251f2623800c2b8dcf1a63958b0456d6d898ca5a7ede5f49bf392b3a8aa5b", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiAccessContracts.cs": "9aaf3d1e8f071bdf2bc81e3a82f861440dade2a02d040c906477065cc147082e", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "1b033df9cb55c741e9b52054cbd4a91067f03c8c3797bd076a7e3d6133eb0fcb", + "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs": "", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "18212bb64644cfbca401065bccce439ea5fa00316deff57d730a0d9ac2650e53", + "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs": "", "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "6281ec9394f937f55b1ff9dd800b92d9625f09219a33e31c87f8885430523f13" } }, @@ -41,7 +43,8 @@ "nyxid-assistant-actions.v4", "nyxid-assistant-actions.v5", "nyxid-assistant-actions.v6", - "nyxid-assistant-actions.v7" + "nyxid-assistant-actions.v7", + "nyxid-assistant-actions.v8" ], "nyxid_source": "backend/src/handlers/assistant_actions.rs", "nyxid_source_sha256": "7350930688e5fbe08355c0bfc1b95b1c8546c4db6c999d400c2e8d88b9c70f36", @@ -63,6 +66,10 @@ "nyxid-assistant-actions.v7": { "checked_in_payload": "registry-v7.json", "checked_in_payload_sha256": "b029b8fb295a213f7c4f4da8bde0ec0b74a5bcdfe502818bfdd0fb16d9bdf941" + }, + "nyxid-assistant-actions.v8": { + "checked_in_payload": "registry-v8.json", + "checked_in_payload_sha256": "788c6628977f29f1c357d4f7b172be904540ad0bbea84ccb2878c5cafaa1f818" } } }, diff --git a/src/Aevatar.AI.Abstractions/ai_messages.proto b/src/Aevatar.AI.Abstractions/ai_messages.proto index 799bbed640..0d06ec778c 100644 --- a/src/Aevatar.AI.Abstractions/ai_messages.proto +++ b/src/Aevatar.AI.Abstractions/ai_messages.proto @@ -474,6 +474,7 @@ message NyxIdAuthorizationRequiredEvent { repeated string requested_scopes = 7; NyxIdKeyCreateActionRequirement key_create = 8; NyxIdKeyRotateActionRequirement key_rotate = 9; + NyxIdServiceReauthorizeActionRequirement service_reauthorize = 10; } message NyxIdKeyCreateActionRequirement { @@ -486,6 +487,11 @@ message NyxIdKeyRotateActionRequirement { string key_id = 1; } +message NyxIdServiceReauthorizeActionRequirement { + string user_service_id = 1; + repeated string requested_scopes = 2; +} + message AgentToolReceipt { string call_id = 1; string tool_name = 2; diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs new file mode 100644 index 0000000000..b89ca8533c --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.ToolProviders; + +namespace Aevatar.AI.ToolProviders.NyxId.Tools; + +/// +/// Shared owner-authority, safe-identity, and error-envelope logic for the +/// nyxid_request_* browser-action handoff tools (key.create, key.rotate, +/// service.reauthorize). Per-tool code owns only its argument shape, the exact +/// NyxID read it verifies against, result matching, and the typed requirement. +/// +internal static class NyxIdBrowserActionRequestToolHelpers +{ + private const int MaxIdentityLength = 256; + + /// + /// One exact opaque NyxID identity: trimmed, nonempty, no control or whitespace + /// characters, no path/query/fragment separators, and never a credential prefix. + /// + public static bool TryNormalizeSafeIdentity(string? value, out string normalized) + { + normalized = value?.Trim() ?? string.Empty; + return normalized.Length is > 0 and <= MaxIdentityLength && + !normalized.Any(char.IsControl) && + !HasCredentialPrefix(normalized) && + !normalized.Any(char.IsWhiteSpace) && + !normalized.Any(static character => character is '/' or '\\' or '?' or '#'); + } + + public static bool HasCredentialPrefix(string value) => + value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase); + + public static string? ResolveOwnerReadToken() + => AgentToolHumanSessionNyxIdCredential.ResolveBearerToken( + AgentToolRequestContext.Current); + + public static bool HasVerifiedOwnerAuthority() => + !string.IsNullOrWhiteSpace(AgentToolRequestContext.OwnerScopeId) && + AgentToolRequestContext.NyxIdAuthority.IsComplete && + !string.IsNullOrWhiteSpace(AgentToolRequestContext.NyxIdAuthority.ExternalUserId); + + public static bool TryReadError( + string resultJson, + out string errorCode, + out string errorMessage) + { + errorCode = string.Empty; + errorMessage = string.Empty; + try + { + using var document = JsonDocument.Parse(resultJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.True || + !root.TryGetProperty("error_code", out var code) || code.ValueKind != JsonValueKind.String || + !root.TryGetProperty("safe_message", out var message) || message.ValueKind != JsonValueKind.String) + { + return false; + } + + errorCode = code.GetString() ?? string.Empty; + errorMessage = message.GetString() ?? string.Empty; + return true; + } + catch (JsonException) + { + return false; + } + } + + public static string ErrorResult(string code, string safeMessage) => + JsonSerializer.Serialize(new + { + error = true, + error_code = code, + safe_message = safeMessage, + }); + + public static AgentToolReceipt ErrorReceipt( + string callId, + string toolName, + string defaultToolName, + string code, + string safeMessage) => + new() + { + CallId = callId ?? string.Empty, + ToolName = string.IsNullOrWhiteSpace(toolName) ? defaultToolName : toolName, + Status = AgentToolReceiptStatus.Error, + ErrorCode = code, + ErrorMessage = safeMessage, + ResultJson = ErrorResult(code, safeMessage), + }; +} diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs index 073035cecd..a5889c4299 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; +using static Aevatar.AI.ToolProviders.NyxId.Tools.NyxIdBrowserActionRequestToolHelpers; namespace Aevatar.AI.ToolProviders.NyxId.Tools; @@ -108,18 +109,20 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c return ErrorReceipt( callId, toolName, + Name, ArgumentsInvalidCode, "name, platform, and allowed_service_ids must be valid"); } if (TryReadError(resultJson, out var errorCode, out var errorMessage)) - return ErrorReceipt(callId, toolName, errorCode, errorMessage); + return ErrorReceipt(callId, toolName, Name, errorCode, errorMessage); if (!ResultMatches(resultJson, request)) { return ErrorReceipt( callId, toolName, + Name, ResultInvalidCode, "NyxID key creation readiness returned an invalid result."); } @@ -207,8 +210,7 @@ private static bool TryNormalizeSafeValue(string? value, int maxLength, out stri { normalized = value?.Trim() ?? string.Empty; if (normalized.Length is 0 || normalized.Length > maxLength || normalized.Any(char.IsControl) || - normalized.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) || - normalized.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + HasCredentialPrefix(normalized)) { return false; } @@ -224,15 +226,6 @@ private static bool TryNormalizeSafeValue(string? value, int maxLength, out stri return true; } - private static string? ResolveOwnerReadToken() - => AgentToolHumanSessionNyxIdCredential.ResolveBearerToken( - AgentToolRequestContext.Current); - - private static bool HasVerifiedOwnerAuthority() => - !string.IsNullOrWhiteSpace(AgentToolRequestContext.OwnerScopeId) && - AgentToolRequestContext.NyxIdAuthority.IsComplete && - !string.IsNullOrWhiteSpace(AgentToolRequestContext.NyxIdAuthority.ExternalUserId); - private static bool ResultMatches(string resultJson, KeyCreateRequest request) { try @@ -262,58 +255,6 @@ private static bool ResultMatches(string resultJson, KeyCreateRequest request) } } - private static bool TryReadError( - string resultJson, - out string errorCode, - out string errorMessage) - { - errorCode = string.Empty; - errorMessage = string.Empty; - try - { - using var document = JsonDocument.Parse(resultJson); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.True || - !root.TryGetProperty("error_code", out var code) || code.ValueKind != JsonValueKind.String || - !root.TryGetProperty("safe_message", out var message) || message.ValueKind != JsonValueKind.String) - { - return false; - } - - errorCode = code.GetString() ?? ResultInvalidCode; - errorMessage = message.GetString() ?? "NyxID key creation readiness failed."; - return true; - } - catch (JsonException) - { - return false; - } - } - - private static string ErrorResult(string code, string safeMessage) => - JsonSerializer.Serialize(new - { - error = true, - error_code = code, - safe_message = safeMessage, - }); - - private static AgentToolReceipt ErrorReceipt( - string callId, - string toolName, - string code, - string safeMessage) => - new() - { - CallId = callId ?? string.Empty, - ToolName = string.IsNullOrWhiteSpace(toolName) ? "nyxid_request_key_create" : toolName, - Status = AgentToolReceiptStatus.Error, - ErrorCode = code, - ErrorMessage = safeMessage, - ResultJson = ErrorResult(code, safeMessage), - }; - private sealed record KeyCreateRequest( string Name, string Platform, diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs index b940adb6e6..c47b6e2faa 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.ToolProviders; +using static Aevatar.AI.ToolProviders.NyxId.Tools.NyxIdBrowserActionRequestToolHelpers; namespace Aevatar.AI.ToolProviders.NyxId.Tools; @@ -89,18 +90,20 @@ evidence.Value is null || return ErrorReceipt( callId, toolName, + Name, ArgumentsInvalidCode, "key_id must be one exact safe identity"); } if (TryReadError(resultJson, out var errorCode, out var errorMessage)) - return ErrorReceipt(callId, toolName, errorCode, errorMessage); + return ErrorReceipt(callId, toolName, Name, errorCode, errorMessage); if (!ResultMatches(resultJson, keyId)) { return ErrorReceipt( callId, toolName, + Name, ResultInvalidCode, "NyxID key rotation readiness returned an invalid result."); } @@ -134,7 +137,7 @@ private static bool TryParseArguments(string? argumentsJson, out string keyId) root.EnumerateObject().All(static property => property.Name == "key_id") && root.TryGetProperty("key_id", out var element) && element.ValueKind == JsonValueKind.String && - TryNormalizeIdentity(element.GetString(), out keyId) && + TryNormalizeSafeIdentity(element.GetString(), out keyId) && string.Equals(element.GetString(), keyId, StringComparison.Ordinal); } catch (JsonException) @@ -143,26 +146,6 @@ private static bool TryParseArguments(string? argumentsJson, out string keyId) } } - private static bool TryNormalizeIdentity(string? value, out string normalized) - { - normalized = value?.Trim() ?? string.Empty; - return normalized.Length is > 0 and <= 256 && - !normalized.Any(char.IsControl) && - !normalized.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && - !normalized.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase) && - !normalized.Any(char.IsWhiteSpace) && - !normalized.Any(static character => character is '/' or '\\' or '?' or '#'); - } - - private static string? ResolveOwnerReadToken() - => AgentToolHumanSessionNyxIdCredential.ResolveBearerToken( - AgentToolRequestContext.Current); - - private static bool HasVerifiedOwnerAuthority() => - !string.IsNullOrWhiteSpace(AgentToolRequestContext.OwnerScopeId) && - AgentToolRequestContext.NyxIdAuthority.IsComplete && - !string.IsNullOrWhiteSpace(AgentToolRequestContext.NyxIdAuthority.ExternalUserId); - private static bool ResultMatches(string resultJson, string keyId) { try @@ -186,56 +169,4 @@ private static bool ResultMatches(string resultJson, string keyId) return false; } } - - private static bool TryReadError( - string resultJson, - out string errorCode, - out string errorMessage) - { - errorCode = string.Empty; - errorMessage = string.Empty; - try - { - using var document = JsonDocument.Parse(resultJson); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.True || - !root.TryGetProperty("error_code", out var code) || code.ValueKind != JsonValueKind.String || - !root.TryGetProperty("safe_message", out var message) || message.ValueKind != JsonValueKind.String) - { - return false; - } - - errorCode = code.GetString() ?? ResultInvalidCode; - errorMessage = message.GetString() ?? "NyxID key rotation readiness failed."; - return true; - } - catch (JsonException) - { - return false; - } - } - - private static string ErrorResult(string code, string safeMessage) => - JsonSerializer.Serialize(new - { - error = true, - error_code = code, - safe_message = safeMessage, - }); - - private static AgentToolReceipt ErrorReceipt( - string callId, - string toolName, - string code, - string safeMessage) => - new() - { - CallId = callId ?? string.Empty, - ToolName = string.IsNullOrWhiteSpace(toolName) ? "nyxid_request_key_rotate" : toolName, - Status = AgentToolReceiptStatus.Error, - ErrorCode = code, - ErrorMessage = safeMessage, - ResultJson = ErrorResult(code, safeMessage), - }; } diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs new file mode 100644 index 0000000000..49222965c8 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.ToolProviders; +using static Aevatar.AI.ToolProviders.NyxId.Tools.NyxIdBrowserActionRequestToolHelpers; + +namespace Aevatar.AI.ToolProviders.NyxId.Tools; + +public sealed class NyxIdRequestServiceReauthorizeTool : INyxIdBuiltInTool, IAgentToolCapabilityDescriptor +{ + private const string ArgumentsInvalidCode = "NYXID_SERVICE_REAUTHORIZE_ARGUMENTS_INVALID"; + private const string ContextUnavailableCode = "NYXID_SERVICE_REAUTHORIZE_CONTEXT_UNAVAILABLE"; + private const string ServiceUnavailableCode = "NYXID_SERVICE_REAUTHORIZE_SERVICE_UNAVAILABLE"; + private const string ResultInvalidCode = "NYXID_SERVICE_REAUTHORIZE_RESULT_INVALID"; + private const string RequirementCode = "NYXID_SERVICE_REAUTHORIZATION_REQUIRED"; + private const string RequirementMessage = + "Re-authorize the exact connected NyxID service in the secure browser action."; + private const int MaxRequestedScopes = 64; + + private readonly NyxIdApiClient _client; + + public IReadOnlyCollection Capabilities => NyxIdToolSurfaces.HumanSessionOnly; + + public NyxIdRequestServiceReauthorizeTool(NyxIdApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public string Name => "nyxid_request_service_reauthorize"; + + public string Description => + "Verify one exact current-caller connected NyxID user service identity, then emit the typed " + + "service.reauthorize browser handoff for the requested scopes. This tool never re-authorizes " + + "a service and never accepts tokens, keys, or authorization codes."; + + public string ParametersSchema => """ + { + "type": "object", + "properties": { + "user_service_id": { "type": "string" }, + "requested_scopes": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string" } + } + }, + "required": ["user_service_id", "requested_scopes"], + "additionalProperties": false + } + """; + + public bool IsReadOnly => true; + + public async Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!TryParseArguments(argumentsJson, out var arguments)) + { + return ErrorResult( + ArgumentsInvalidCode, + "user_service_id must be one exact safe identity and requested_scopes must be a nonempty unique scope set"); + } + + var token = ResolveOwnerReadToken(); + if (token is null || !HasVerifiedOwnerAuthority()) + { + return ErrorResult( + ContextUnavailableCode, + "verified owner identity and source-readable NyxID authority are required"); + } + + var response = await _client.GetServiceAsync(token, arguments.UserServiceId, ct) + .ConfigureAwait(false); + var evidence = NyxIdApiAccessResponseParser.ParseUserServiceAuthorization(response); + if (!evidence.Succeeded || + evidence.Value is null || + !evidence.Value.IsActive || + !string.Equals(evidence.Value.UserServiceId, arguments.UserServiceId, StringComparison.Ordinal)) + { + return ErrorResult( + ServiceUnavailableCode, + "The exact caller-visible connected NyxID service is unavailable."); + } + + return JsonSerializer.Serialize(new + { + blocked = true, + action = "service.reauthorize", + user_service_id = arguments.UserServiceId, + requested_scopes = arguments.RequestedScopes, + reason_code = RequirementCode, + safe_message = RequirementMessage, + }); + } + + public AgentToolReceipt? CreateResultReceipt( + string callId, + string toolName, + string argumentsJson, + string resultJson) + { + if (!TryParseArguments(argumentsJson, out var arguments)) + { + return ErrorReceipt( + callId, + toolName, + Name, + ArgumentsInvalidCode, + "user_service_id must be one exact safe identity and requested_scopes must be a nonempty unique scope set"); + } + + if (TryReadError(resultJson, out var errorCode, out var errorMessage)) + return ErrorReceipt(callId, toolName, Name, errorCode, errorMessage); + + if (!ResultMatches(resultJson, arguments)) + { + return ErrorReceipt( + callId, + toolName, + Name, + ResultInvalidCode, + "NyxID service reauthorization readiness returned an invalid result."); + } + + var requirement = new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = arguments.UserServiceId, + }; + requirement.RequestedScopes.AddRange(arguments.RequestedScopes); + var blocker = new NyxIdAuthorizationRequiredEvent + { + ReasonCode = RequirementCode, + SafeMessage = RequirementMessage, + ServiceReauthorize = requirement, + }; + return new AgentToolReceipt + { + CallId = callId ?? string.Empty, + ToolName = string.IsNullOrWhiteSpace(toolName) ? Name : toolName, + Status = AgentToolReceiptStatus.AuthorizationRequired, + ResultJson = resultJson, + ErrorCode = blocker.ReasonCode, + ErrorMessage = blocker.SafeMessage, + AuthorizationRequired = blocker, + }; + } + + private sealed record ReauthorizeArguments( + string UserServiceId, + IReadOnlyList RequestedScopes); + + private static bool TryParseArguments(string? argumentsJson, out ReauthorizeArguments arguments) + { + arguments = new ReauthorizeArguments(string.Empty, Array.Empty()); + try + { + using var document = JsonDocument.Parse(argumentsJson ?? string.Empty); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.EnumerateObject().All(static property => + property.Name is "user_service_id" or "requested_scopes") || + !root.TryGetProperty("user_service_id", out var idElement) || + idElement.ValueKind != JsonValueKind.String || + !TryNormalizeSafeIdentity(idElement.GetString(), out var userServiceId) || + !string.Equals(idElement.GetString(), userServiceId, StringComparison.Ordinal) || + !root.TryGetProperty("requested_scopes", out var scopesElement) || + scopesElement.ValueKind != JsonValueKind.Array || + !TryReadScopes(scopesElement, out var requestedScopes)) + { + return false; + } + + arguments = new ReauthorizeArguments(userServiceId, requestedScopes); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool TryReadScopes(JsonElement scopesElement, out IReadOnlyList scopes) + { + var values = new List(); + var distinct = new HashSet(StringComparer.Ordinal); + foreach (var item in scopesElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + { + scopes = Array.Empty(); + return false; + } + + var scope = item.GetString() ?? string.Empty; + if (!TryNormalizeScope(scope, out var normalized) || + !string.Equals(scope, normalized, StringComparison.Ordinal) || + !distinct.Add(normalized)) + { + scopes = Array.Empty(); + return false; + } + + values.Add(normalized); + } + + scopes = values; + return values.Count is >= 1 and <= MaxRequestedScopes; + } + + private static bool TryNormalizeScope(string? value, out string normalized) + { + normalized = value?.Trim() ?? string.Empty; + return normalized.Length is > 0 and <= 256 && + !normalized.Any(char.IsControl) && + !normalized.Any(char.IsWhiteSpace); + } + + private static bool ResultMatches(string resultJson, ReauthorizeArguments arguments) + { + try + { + using var document = JsonDocument.Parse(resultJson); + var root = document.RootElement; + return root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("blocked", out var blocked) && + blocked.ValueKind == JsonValueKind.True && + root.TryGetProperty("action", out var action) && + action.GetString() == "service.reauthorize" && + root.TryGetProperty("user_service_id", out var resultUserServiceId) && + resultUserServiceId.GetString() == arguments.UserServiceId && + root.TryGetProperty("requested_scopes", out var resultScopes) && + resultScopes.ValueKind == JsonValueKind.Array && + resultScopes.EnumerateArray() + .Select(static scope => scope.GetString()) + .SequenceEqual(arguments.RequestedScopes, StringComparer.Ordinal) && + root.TryGetProperty("reason_code", out var reason) && + reason.GetString() == RequirementCode && + root.TryGetProperty("safe_message", out var message) && + message.GetString() == RequirementMessage; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs index bfe98f214a..1252971eda 100644 --- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs +++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs @@ -452,6 +452,8 @@ public sealed record NyxIdChatActionParamsSnapshot( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] NyxIdChatServiceAccessReviewSnapshot? ServiceAccessReview = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + NyxIdChatServiceReauthorizeSnapshot? ServiceReauthorize = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Name = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Platform = null, @@ -484,6 +486,10 @@ public sealed record NyxIdChatServiceAccessReviewSnapshot( string ServiceSlug, string ResourceUri); +public sealed record NyxIdChatServiceReauthorizeSnapshot( + string UserServiceId, + IReadOnlyList RequestedScopes); + public sealed record NyxIdChatActionReportSnapshot( string ActionRequestId, string OriginTurnId, diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs index 1ced9859e9..d178686c6a 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs @@ -580,6 +580,11 @@ private static NyxIdChatActionSnapshot ToAction( request.Params.ServiceAccessReview.UserServiceId, request.Params.ServiceAccessReview.ServiceSlug, request.Params.ServiceAccessReview.ResourceUri)), + NyxIdChatConversationActionParamsDocument.ParamsOneofCase.ServiceReauthorize => + new NyxIdChatActionParamsSnapshot( + ServiceReauthorize: new NyxIdChatServiceReauthorizeSnapshot( + request.Params.ServiceReauthorize.UserServiceId, + request.Params.ServiceReauthorize.RequestedScopes.ToArray())), _ => null, }; return parameters is null diff --git a/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs index f2f3782a68..0d336532ec 100644 --- a/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs +++ b/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs @@ -655,6 +655,15 @@ private static NyxIdChatConversationActionDocument ToAction( ResourceUri = action.Params.ServiceAccessReview.ResourceUri, }, }, + NyxIdAssistantActionParams.ParamsOneofCase.ServiceReauthorize => + new NyxIdChatConversationActionParamsDocument + { + ServiceReauthorize = new NyxIdChatConversationServiceReauthorizeDocument + { + UserServiceId = action.Params.ServiceReauthorize.UserServiceId, + RequestedScopes = { action.Params.ServiceReauthorize.RequestedScopes }, + }, + }, _ => null, }; return parameters is null diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto index 5d574a21c3..f7679c03c2 100644 --- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -787,6 +787,7 @@ message NyxIdChatConversationActionParamsDocument { NyxIdChatConversationKeyCreateDocument key_create = 3; NyxIdChatConversationKeyRotateDocument key_rotate = 4; NyxIdChatConversationServiceAccessReviewDocument service_access_review = 5; + NyxIdChatConversationServiceReauthorizeDocument service_reauthorize = 6; } } @@ -822,6 +823,11 @@ message NyxIdChatConversationServiceAccessReviewDocument { string resource_uri = 3; } +message NyxIdChatConversationServiceReauthorizeDocument { + string user_service_id = 1; + repeated string requested_scopes = 2; +} + message NyxIdChatConversationCurrentStateDocument { string id = 1; string actor_id = 2; diff --git a/test/Aevatar.AI.Tests/NyxIdAssistantActionRegistryTests.cs b/test/Aevatar.AI.Tests/NyxIdAssistantActionRegistryTests.cs index 5eef0cbb99..f2e54b6846 100644 --- a/test/Aevatar.AI.Tests/NyxIdAssistantActionRegistryTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdAssistantActionRegistryTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using Aevatar.AI.Abstractions; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.GAgents.NyxidChat; using FluentAssertions; @@ -13,13 +14,14 @@ public sealed class NyxIdAssistantActionRegistryTests private const string LegacyRevision = "nyxid-assistant-actions.v4"; private const string TransitionRevision = "nyxid-assistant-actions.v5"; private const string LeastScopeRevision = "nyxid-assistant-actions.v6"; - private const string SupportedRevision = "nyxid-assistant-actions.v7"; + private const string KeyRotationRevision = "nyxid-assistant-actions.v7"; + private const string SupportedRevision = "nyxid-assistant-actions.v8"; [Fact] public void Load_ShouldPinSchemaVersionAndRevision() { var registry = NyxIdAssistantActionRegistry.Load( - RegistryJsonWithKeyRotation()); + RegistryJsonWithServiceReauthorize()); registry.SchemaVersion.Should().Be(4); registry.RegistryRevision.Should().Be(SupportedRevision); @@ -132,13 +134,157 @@ public void Load_ShouldExposeKeyRotationOnlyInV7() registry.TryGetDefinition("key.rotate", out _).Should().BeTrue(); registry.TryGetDefinition("service.reauthorize", out _).Should().BeFalse(); NyxIdAssistantActionRegistry.IsActionExecutable( - SupportedRevision, + KeyRotationRevision, NyxIdAssistantActionKind.KeyRotate) .Should().BeTrue(); NyxIdAssistantActionRegistry.IsActionExecutable( LeastScopeRevision, NyxIdAssistantActionKind.KeyRotate) .Should().BeFalse(); + NyxIdAssistantActionRegistry.IsActionExecutable( + KeyRotationRevision, + NyxIdAssistantActionKind.ServiceReauthorize) + .Should().BeFalse(); + } + + [Fact] + public void Load_ShouldExposeServiceReauthorizeOnlyInV8() + { + var registry = NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize()); + + registry.TryGetDefinition("service.connect", out _).Should().BeTrue(); + registry.TryGetDefinition("key.create", out _).Should().BeTrue(); + registry.TryGetDefinition("key.rotate", out _).Should().BeTrue(); + registry.TryGetDefinition("service.reauthorize", out var definition).Should().BeTrue(); + definition!.Action.Should().Be(NyxIdAssistantActionKind.ServiceReauthorize); + definition.RememberEligible.Should().BeFalse(); + NyxIdAssistantActionRegistry.IsActionExecutable( + SupportedRevision, + NyxIdAssistantActionKind.ServiceReauthorize) + .Should().BeTrue(); + NyxIdAssistantActionRegistry.IsActionExecutable( + SupportedRevision, + NyxIdAssistantActionKind.KeyRotate) + .Should().BeTrue(); + foreach (var revision in new[] + { + LegacyRevision, + TransitionRevision, + LeastScopeRevision, + KeyRotationRevision, + }) + { + NyxIdAssistantActionRegistry.IsActionExecutable( + revision, + NyxIdAssistantActionKind.ServiceReauthorize) + .Should().BeFalse(revision); + } + + var validated = registry.ValidateRequest( + "service.reauthorize", + """{"userServiceId":"us-github-alpha","requestedScopes":["repo","read:org"]}"""); + validated.Definition.Action.Should().Be(NyxIdAssistantActionKind.ServiceReauthorize); + validated.Params.ServiceReauthorize.UserServiceId.Should().Be("us-github-alpha"); + validated.Params.ServiceReauthorize.RequestedScopes.Should().Equal("repo", "read:org"); + } + + [Fact] + public void Load_ShouldRejectServiceReauthorizeDescriptorDriftInV8() + { + Action staleSchema = () => NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize( + serviceReauthorizeSchema: StaleServiceReauthorizeSchema)); + staleSchema.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_REGISTRY_INVALID"); + + Action rememberDrift = () => NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize(serviceReauthorizeRememberEligible: true)); + rememberDrift.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_REGISTRY_INVALID"); + + Action riskDrift = () => NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize(serviceReauthorizeRisk: "low")); + riskDrift.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_REGISTRY_INVALID"); + + Action missingDescriptor = () => NyxIdAssistantActionRegistry.Load( + RegistryJsonWithKeyRotation(revision: SupportedRevision)); + missingDescriptor.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_REGISTRY_INVALID"); + + Action looseKeyCreate = () => NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize(keyCreateSchema: KeyCreateSchema)); + looseKeyCreate.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_REGISTRY_INVALID"); + } + + [Fact] + public void ResolveServiceReauthorize_ShouldRequireExecutableRevisionAndExactParams() + { + var registry = NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize()); + var requirement = new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "us-github-alpha", + RequestedScopes = { "repo", "read:org" }, + }; + + var validated = registry.ResolveServiceReauthorize(requirement); + validated.Definition.Action.Should().Be(NyxIdAssistantActionKind.ServiceReauthorize); + validated.Definition.RegistryRevision.Should().Be(SupportedRevision); + validated.Params.ServiceReauthorize.UserServiceId.Should().Be("us-github-alpha"); + validated.Params.ServiceReauthorize.RequestedScopes.Should().Equal("repo", "read:org"); + + foreach (var invalid in new[] + { + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "us-github-alpha", + }, + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "us-github-alpha", + RequestedScopes = { "repo", "repo" }, + }, + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "us-github-alpha", + RequestedScopes = { " repo" }, + }, + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "us github", + RequestedScopes = { "repo" }, + }, + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "us/github", + RequestedScopes = { "repo" }, + }, + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "", + RequestedScopes = { "repo" }, + }, + }) + { + Action resolve = () => registry.ResolveServiceReauthorize(invalid); + resolve.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_PARAMS_INVALID"); + } + + var keyRotationRegistry = NyxIdAssistantActionRegistry.Load( + RegistryJsonWithKeyRotation()); + Action failClosed = () => keyRotationRegistry.ResolveServiceReauthorize(requirement); + failClosed.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_UNSUPPORTED"); + + var waveOneRegistry = NyxIdAssistantActionRegistry.Load( + RegistryJsonWithWaveOneActions()); + Action pinnedButNotExecutable = () => waveOneRegistry.ResolveServiceReauthorize(requirement); + pinnedButNotExecutable.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_UNSUPPORTED"); } [Fact] @@ -298,6 +444,27 @@ public void ParseServiceReauthorize_ShouldRequireExactUserServiceIdentity() .Which.Code.Should().Be("NYXID_ACTION_PARAMS_INVALID"); } + [Theory] + [InlineData("""{"userServiceId":"us-github-alpha","requestedScopes":[]}""")] + [InlineData("""{"userServiceId":"us-github-alpha","requestedScopes":["repo","repo"]}""")] + [InlineData("""{"userServiceId":"us-github-alpha","requestedScopes":[" repo"]}""")] + [InlineData("""{"userServiceId":"us-github-alpha","requestedScopes":["repo",""]}""")] + [InlineData("""{"userServiceId":" us-github-alpha","requestedScopes":["repo"]}""")] + [InlineData("""{"userServiceId":"us github","requestedScopes":["repo"]}""")] + [InlineData("""{"userServiceId":"us/github","requestedScopes":["repo"]}""")] + [InlineData("""{"userServiceId":"us?github","requestedScopes":["repo"]}""")] + [InlineData("""{"userServiceId":"","requestedScopes":["repo"]}""")] + public void ValidateRequest_ShouldRejectLooseServiceReauthorizeParamsAtV8(string paramsJson) + { + var registry = NyxIdAssistantActionRegistry.Load( + RegistryJsonWithServiceReauthorize()); + + Action validate = () => registry.ValidateRequest("service.reauthorize", paramsJson); + + validate.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_PARAMS_INVALID"); + } + [Fact] public void ParseKeyCreate_ShouldRequireAtLeastOneExactAllowedServiceIdentity() { @@ -393,7 +560,8 @@ public async Task StartupService_ShouldFetchAndValidateRegistryOnce() (RegistryJson(), LegacyRevision), (RegistryJsonWithWaveOneActions(), TransitionRevision), (RegistryJsonWithLeastScopeKeyCreate(), LeastScopeRevision), - (RegistryJsonWithKeyRotation(), SupportedRevision), + (RegistryJsonWithKeyRotation(), KeyRotationRevision), + (RegistryJsonWithServiceReauthorize(), SupportedRevision), }) { var source = new RecordingRegistrySource(payload); @@ -738,10 +906,11 @@ private static string RegistryJsonWithLeastScopeKeyCreate( } """; - private static string RegistryJsonWithKeyRotation() => $$""" + private static string RegistryJsonWithKeyRotation( + string revision = KeyRotationRevision) => $$""" { "schema_version": 4, - "revision": "{{SupportedRevision}}", + "revision": "{{revision}}", "actions": [ { "action": "service.connect", @@ -771,6 +940,51 @@ private static string RegistryJsonWithKeyRotation() => $$""" } """; + private static string RegistryJsonWithServiceReauthorize( + string serviceReauthorizeSchema = ServiceReauthorizeSchema, + string keyCreateSchema = LeastScopeKeyCreateSchema, + bool serviceReauthorizeRememberEligible = false, + string serviceReauthorizeRisk = "grant") => $$""" + { + "schema_version": 4, + "revision": "{{SupportedRevision}}", + "actions": [ + { + "action": "service.connect", + "description": "Connect a service.", + "params_schema": {{ServiceConnectSchema}}, + "risk": "grant", + "tier": "v1", + "remember_eligible": true + }, + { + "action": "key.create", + "description": "Create a least-scope API key.", + "params_schema": {{keyCreateSchema}}, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + }, + { + "action": "key.rotate", + "description": "Rotate an API key.", + "params_schema": {{KeyRotateSchema}}, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + }, + { + "action": "service.reauthorize", + "description": "Reauthorize a connected service.", + "params_schema": {{serviceReauthorizeSchema}}, + "risk": "{{serviceReauthorizeRisk}}", + "tier": "v1", + "remember_eligible": {{serviceReauthorizeRememberEligible.ToString().ToLowerInvariant()}} + } + ] + } + """; + private static string RegistryJsonWithManifestOnlyAction() => $$""" { "schema_version": 4, diff --git a/test/Aevatar.AI.Tests/NyxIdChatAguiSseEventWriterTests.cs b/test/Aevatar.AI.Tests/NyxIdChatAguiSseEventWriterTests.cs index 902dda5315..cb5d25cdf8 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatAguiSseEventWriterTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatAguiSseEventWriterTests.cs @@ -675,7 +675,7 @@ public async Task WriteAsync_ShouldMapServiceReauthorizeToExactSchemaV4WirePaylo Request = new NyxIdChatActionRequestState { SchemaVersion = 4, - RegistryRevision = "nyxid-assistant-actions.v5", + RegistryRevision = NyxIdAssistantActionRegistry.SupportedRegistryRevision, ConversationActorId = "conversation-alpha", OriginTurnId = "turn-alpha", TaskId = "task-alpha", @@ -738,10 +738,8 @@ public async Task WriteAsync_ShouldMapServiceReauthorizeToExactSchemaV4WirePaylo "actionRequestId": "action-alpha", "action": "service.reauthorize", "params": { - "serviceReauthorize": { - "userServiceId": "us-github-alpha", - "requestedScopes": ["repo", "read:org"] - } + "userServiceId": "us-github-alpha", + "requestedScopes": ["repo", "read:org"] } } """); diff --git a/test/Aevatar.AI.Tests/NyxIdChatBrowserActionTests.cs b/test/Aevatar.AI.Tests/NyxIdChatBrowserActionTests.cs index ee1bef0937..927cd01f26 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatBrowserActionTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatBrowserActionTests.cs @@ -168,7 +168,7 @@ public void KeyRotateAuthorizationRequired_ShouldCommitExactKeyActionRequest() decision.ShouldCommit.Should().BeTrue(); decision.Outcome.Should().Be(NyxIdChatTransitionOutcome.Accepted); decision.Request.RegistryRevision.Should().Be( - NyxIdAssistantActionRegistry.SupportedRegistryRevision); + NyxIdAssistantActionRegistry.KeyRotationRegistryRevision); decision.Request.Action.Should().Be(NyxIdAssistantActionKind.KeyRotate); decision.Request.Params.ParamsCase.Should().Be( NyxIdAssistantActionParams.ParamsOneofCase.KeyRotate); @@ -182,6 +182,162 @@ public void KeyRotateAuthorizationRequired_ShouldCommitExactKeyActionRequest() step.ActionRequestId == decision.Request.ActionRequestId); } + [Fact] + public void ServiceReauthorizeAuthorizationRequired_ShouldCommitExactServiceActionRequest() + { + var state = AuthorizationWaitingState(); + var signal = ServiceReauthorizeSignal(state); + + var decision = NyxIdChatBrowserActions.RequestAuthorization( + state, + signal, + ReauthorizeRegistry(), + Now); + + decision.ShouldCommit.Should().BeTrue(); + decision.Outcome.Should().Be(NyxIdChatTransitionOutcome.Accepted); + decision.Request.RegistryRevision.Should().Be( + NyxIdAssistantActionRegistry.SupportedRegistryRevision); + decision.Request.Action.Should().Be(NyxIdAssistantActionKind.ServiceReauthorize); + decision.Request.Params.ParamsCase.Should().Be( + NyxIdAssistantActionParams.ParamsOneofCase.ServiceReauthorize); + decision.Request.Params.ServiceReauthorize.UserServiceId.Should().Be("service-alpha"); + decision.Request.Params.ServiceReauthorize.RequestedScopes.Should() + .Equal("repo", "read:org"); + decision.Request.RememberEligible.Should().BeFalse(); + decision.State.PendingActions.Should().ContainSingle() + .Which.Should().BeEquivalentTo(decision.Request); + decision.State.ActiveTask.Steps.Should().ContainSingle(step => + step.Kind == NyxIdChatStepKind.BrowserAction && + step.Source.BrowserAction.Action == NyxIdAssistantActionKind.ServiceReauthorize && + step.ActionRequestId == decision.Request.ActionRequestId); + } + + [Fact] + public void ServiceReauthorizeAuthorizationRequired_ShouldRejectWhenRegistryDoesNotExecuteIt() + { + var state = AuthorizationWaitingState(); + + Action resolve = () => NyxIdChatBrowserActions.RequestAuthorization( + state, + ServiceReauthorizeSignal(state), + RotationRegistry(), + Now); + + resolve.Should().Throw() + .Which.Code.Should().Be("NYXID_ACTION_UNSUPPORTED"); + } + + [Fact] + public void ServiceReauthorizeAuthorizationRequired_ShouldRejectMixedBlockerVariants() + { + var state = AuthorizationWaitingState(); + var signal = ServiceReauthorizeSignal(state); + signal.Tool.Receipt.AuthorizationRequired.KeyRotate = + new NyxIdKeyRotateActionRequirement { KeyId = "key-alpha" }; + + var decision = NyxIdChatBrowserActions.RequestAuthorization( + state, + signal, + ReauthorizeRegistry(), + Now); + + decision.ShouldCommit.Should().BeFalse(); + decision.ReasonCode.Should().Be(NyxIdChatBrowserActions.ActionRequestInvalid); + } + + [Fact] + public void ServiceReauthorizeCompletedReport_ShouldDispatchTypedPostconditionForUserService() + { + var blocked = BlockedServiceReauthorizeState(); + var actionRequestId = blocked.PendingActions.Single().ActionRequestId; + var command = ContinueCommand(actionRequestId, NyxIdChatActionDisposition.Completed); + + var decision = NyxIdChatBrowserActions.Continue(blocked, command, Now); + + decision.ShouldCommit.Should().BeTrue(); + decision.ShouldDispatch.Should().BeTrue(); + decision.Admission.Status.Should().Be(NyxIdChatContinuationAdmissionStatus.Accepted); + decision.NextCommand!.InputCase.Should().Be( + NyxIdChatOperationDispatchCommand.InputOneofCase.ActionPostcondition); + decision.NextCommand.ActionPostcondition.Action.Should().Be( + NyxIdAssistantActionKind.ServiceReauthorize); + decision.NextCommand.ActionPostcondition.Params.ParamsCase.Should().Be( + NyxIdAssistantActionParams.ParamsOneofCase.ServiceReauthorize); + decision.NextCommand.ActionPostcondition.Params.ServiceReauthorize.UserServiceId + .Should().Be("service-alpha"); + decision.NextCommand.ActionPostcondition.Params.ServiceReauthorize.RequestedScopes + .Should().Equal("repo", "read:org"); + decision.NextCommand.ActionPostcondition.ResourceHint.UserService.UserServiceId + .Should().Be("service-alpha"); + + var verified = NyxIdChatBrowserActions.ReconcilePostcondition( + decision.State, + new NyxIdChatOperationResultSignal + { + Key = decision.NextCommand.Key.Clone(), + ActionPostcondition = new NyxIdChatActionPostconditionResult + { + ActionRequestId = actionRequestId, + Disposition = NyxIdChatActionDisposition.Completed, + Verified = true, + Resource = new NyxIdChatSafeResourceRef + { + UserService = new NyxIdChatUserServiceRef + { + UserServiceId = "service-alpha", + }, + }, + }, + }, + Now); + + verified.ShouldCommit.Should().BeTrue(); + verified.State.PendingActions.Should().BeEmpty(); + verified.State.RecentActions.Should().ContainSingle(action => + action.ActionRequestId == actionRequestId && + action.PostconditionResult.Verified); + } + + [Fact] + public void ServiceReauthorizeDeclinedReport_ShouldFailTurnWithoutPostcondition() + { + var blocked = BlockedServiceReauthorizeState(); + + var decision = NyxIdChatBrowserActions.Continue( + blocked, + ContinueCommand( + blocked.PendingActions.Single().ActionRequestId, + NyxIdChatActionDisposition.Declined), + Now); + + decision.ShouldCommit.Should().BeTrue(); + decision.ShouldDispatch.Should().BeFalse(); + decision.State.ActiveTurn.Status.Should().Be(NyxIdChatTurnStatus.Failed); + decision.State.ActiveTurn.FailureCode.Should().Be("NYXID_ACTION_DECLINED"); + decision.State.PendingActions.Should().BeEmpty(); + } + + [Fact] + public void ServiceReauthorizeCompletedReport_ShouldRejectKeyResource() + { + var blocked = BlockedServiceReauthorizeState(); + var command = ContinueCommand( + blocked.PendingActions.Single().ActionRequestId, + NyxIdChatActionDisposition.Completed); + command.Actions[0].Resource = new NyxIdChatSafeResourceRef + { + Key = new NyxIdChatKeyRef { KeyId = "key-alpha" }, + }; + + var decision = NyxIdChatBrowserActions.Continue(blocked, command, Now); + + decision.ShouldCommit.Should().BeFalse(); + decision.ShouldDispatch.Should().BeFalse(); + decision.Outcome.Should().Be(NyxIdChatTransitionOutcome.Rejected); + decision.ReasonCode.Should().Be(NyxIdChatBrowserActions.ActionContinuationInvalid); + } + [Fact] public void ActionRequest_ShouldBeContentIdempotentAndRejectIdentityReuseConflict() { @@ -371,7 +527,7 @@ public void CommitRequest_ShouldAcceptKeyRotateOnlyOnV7() AuthorizationRequiredSignal(state), Registry(), Now).Request; - request.RegistryRevision = NyxIdAssistantActionRegistry.SupportedRegistryRevision; + request.RegistryRevision = NyxIdAssistantActionRegistry.KeyRotationRegistryRevision; request.Action = NyxIdAssistantActionKind.KeyRotate; request.Params = new NyxIdAssistantActionParams { @@ -390,6 +546,53 @@ public void CommitRequest_ShouldAcceptKeyRotateOnlyOnV7() rejectedV6.ReasonCode.Should().Be(NyxIdChatBrowserActions.ActionRequestInvalid); } + [Fact] + public void CommitRequest_ShouldAcceptServiceReauthorizeOnlyOnV8() + { + var state = AuthorizationWaitingState(); + var request = NyxIdChatBrowserActions.RequestAuthorization( + state, + AuthorizationRequiredSignal(state), + Registry(), + Now).Request; + request.RegistryRevision = NyxIdAssistantActionRegistry.SupportedRegistryRevision; + request.Action = NyxIdAssistantActionKind.ServiceReauthorize; + request.Params = new NyxIdAssistantActionParams + { + ServiceReauthorize = new NyxIdServiceReauthorizeParams + { + UserServiceId = "service-alpha", + RequestedScopes = { "repo" }, + }, + }; + + var accepted = NyxIdChatBrowserActions.CommitRequest(state, request, Now); + + accepted.ShouldCommit.Should().BeTrue(); + accepted.Outcome.Should().Be(NyxIdChatTransitionOutcome.Accepted); + accepted.Request.Params.ServiceReauthorize.UserServiceId.Should().Be("service-alpha"); + + foreach (var revision in new[] + { + NyxIdAssistantActionRegistry.LegacyRegistryRevision, + NyxIdAssistantActionRegistry.WaveOneDraftRegistryRevision, + NyxIdAssistantActionRegistry.LeastScopeRegistryRevision, + NyxIdAssistantActionRegistry.KeyRotationRegistryRevision, + }) + { + request.RegistryRevision = revision; + var rejected = NyxIdChatBrowserActions.CommitRequest(state, request, Now); + rejected.ShouldCommit.Should().BeFalse(revision); + rejected.ReasonCode.Should().Be(NyxIdChatBrowserActions.ActionRequestInvalid); + } + + request.RegistryRevision = NyxIdAssistantActionRegistry.SupportedRegistryRevision; + request.Params.ServiceReauthorize.RequestedScopes.Clear(); + var rejectedEmptyScopes = NyxIdChatBrowserActions.CommitRequest(state, request, Now); + rejectedEmptyScopes.ShouldCommit.Should().BeFalse(); + rejectedEmptyScopes.ReasonCode.Should().Be(NyxIdChatBrowserActions.ActionRequestInvalid); + } + [Fact] public void CompletedReport_ShouldRejectResourceVariantThatDoesNotMatchAction() { @@ -1646,6 +1849,30 @@ private static NyxIdAssistantActionRegistry LeastScopeRegistry() => NyxIdAssistantActionRegistry.Load(LeastScopeRegistryJson); private static NyxIdAssistantActionRegistry RotationRegistry() + { + var manifest = JsonNode.Parse(LeastScopeRegistryJson)!.AsObject(); + manifest["revision"] = NyxIdAssistantActionRegistry.KeyRotationRegistryRevision; + manifest["actions"]!.AsArray().Add(JsonNode.Parse(""" + { + "action": "key.rotate", + "description": "Rotate an API key.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": ["keyId"], + "properties": { + "keyId": {"type": "string"} + } + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + } + """)); + return NyxIdAssistantActionRegistry.Load(manifest.ToJsonString()); + } + + private static NyxIdAssistantActionRegistry ReauthorizeRegistry() { var manifest = JsonNode.Parse(LeastScopeRegistryJson)!.AsObject(); manifest["revision"] = NyxIdAssistantActionRegistry.SupportedRegistryRevision; @@ -1666,9 +1893,53 @@ private static NyxIdAssistantActionRegistry RotationRegistry() "remember_eligible": false } """)); + manifest["actions"]!.AsArray().Add(JsonNode.Parse(""" + { + "action": "service.reauthorize", + "description": "Reauthorize a connected service.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": ["userServiceId", "requestedScopes"], + "properties": { + "userServiceId": {"type": "string"}, + "requestedScopes": {"type": "array", "items": {"type": "string"}} + } + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + } + """)); return NyxIdAssistantActionRegistry.Load(manifest.ToJsonString()); } + private static NyxIdChatOperationResultSignal ServiceReauthorizeSignal( + NyxIdChatConversationGAgentState state) + { + var signal = AuthorizationRequiredSignal(state); + signal.Tool.Receipt.ToolName = "nyxid_request_service_reauthorize"; + signal.Tool.Receipt.ErrorCode = "NYXID_SERVICE_REAUTHORIZATION_REQUIRED"; + signal.Tool.Receipt.AuthorizationRequired.ServiceSlug = string.Empty; + signal.Tool.Receipt.AuthorizationRequired.ReasonCode = + "NYXID_SERVICE_REAUTHORIZATION_REQUIRED"; + signal.Tool.Receipt.AuthorizationRequired.RequestedScopes.Clear(); + signal.Tool.Receipt.AuthorizationRequired.ServiceReauthorize = + new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "service-alpha", + RequestedScopes = { "repo", "read:org" }, + }; + return signal; + } + + private static NyxIdChatConversationGAgentState BlockedServiceReauthorizeState() => + NyxIdChatBrowserActions.RequestAuthorization( + AuthorizationWaitingState(), + ServiceReauthorizeSignal(AuthorizationWaitingState()), + ReauthorizeRegistry(), + Now).State; + private const string LeastScopeRegistryJson = """ { "schema_version": 4, diff --git a/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs b/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs index 49f821f844..7b31d875c1 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs @@ -2580,6 +2580,83 @@ public async Task AuthorizationRequiredResult_WhenActionRegistryIsDisabled_Shoul .State.ActiveTurn.FailureCode == "NYXID_ACTION_UNSUPPORTED"); } + [Fact] + public async Task ServiceReauthorizeAuthorizationRequired_WhenRegistryIsStillV7_ShouldFailTurnClosed() + { + // Rollout window: Aevatar pins registry v8 before NyxID publishes it, so a process + // that snapshotted v7 may still receive a service.reauthorize blocker. Until the + // process snapshots v8, the blocker must fail the turn closed with + // NYXID_ACTION_UNSUPPORTED instead of committing a browser action. + const string conversationActorId = "conversation-alpha"; + var eventStore = new InMemoryEventStoreForTests(); + using var services = BuildEventSourcingServices( + eventStore, + actionRegistry: CreateKeyRotationActionRegistry()); + var dispatch = new RecordingActorDispatchPort([], static (_, _) => Task.CompletedTask); + var agent = CreateController(services, conversationActorId, dispatch); + await agent.ActivateAsync(); + await agent.HandleEventAsync(CreateEnvelope(conversationActorId, CreateStartTurnCommand())); + var llmKey = agent.State.ActiveTask.Steps.Single().Operation.Key.Clone(); + await agent.HandleEventAsync(CreateEnvelope(conversationActorId, new NyxIdChatOperationResultSignal + { + Key = llmKey, + Llm = new NyxIdChatLLMOperationResult + { + ToolCalls = + { + new NyxIdChatToolCall + { + CallId = "call-reauthorize-alpha", + ToolName = "nyxid_request_service_reauthorize", + ArgumentsJson = """{"userServiceId":"service-alpha","requestedScopes":["repo"]}""", + Safety = new NyxIdChatToolCallSafety(), + }, + }, + }, + })); + var toolKey = agent.State.ActiveTask.Steps + .Single(step => step.Kind == NyxIdChatStepKind.Tool).Operation.Key.Clone(); + + await agent.HandleEventAsync(CreateEnvelope(conversationActorId, new NyxIdChatOperationResultSignal + { + Key = toolKey, + Tool = new NyxIdChatToolOperationResult + { + ExternalEffect = NyxIdChatEffectEvidence.NotStarted, + Receipt = new AgentToolReceipt + { + CallId = "call-reauthorize-alpha", + ToolName = "nyxid_request_service_reauthorize", + Status = AgentToolReceiptStatus.AuthorizationRequired, + ErrorCode = "NYXID_SERVICE_REAUTHORIZATION_REQUIRED", + AuthorizationRequired = new NyxIdAuthorizationRequiredEvent + { + ReasonCode = "NYXID_SERVICE_REAUTHORIZATION_REQUIRED", + SafeMessage = "Re-authorize the connected service.", + ServiceReauthorize = new NyxIdServiceReauthorizeActionRequirement + { + UserServiceId = "service-alpha", + RequestedScopes = { "repo" }, + }, + }, + }, + }, + })); + + agent.State.ActiveTurn.Status.Should().Be(NyxIdChatTurnStatus.Failed); + agent.State.ActiveTurn.FailureCode.Should().Be("NYXID_ACTION_UNSUPPORTED"); + agent.State.ActiveTask.Status.Should().Be(NyxIdChatTaskStatus.Failed); + agent.State.PendingActions.Should().BeEmpty(); + agent.State.PendingHistoryTerminal.ErrorCode.Should().Be("NYXID_ACTION_UNSUPPORTED"); + var committed = await eventStore.GetEventsAsync(conversationActorId); + committed.Should().NotContain(stateEvent => + stateEvent.EventData.Is(NyxIdChatActionRequestedEvent.Descriptor)); + committed.Should().Contain(stateEvent => + stateEvent.EventData.Is(NyxIdChatOperationReconciledEvent.Descriptor) && + stateEvent.EventData.Unpack() + .State.ActiveTurn.FailureCode == "NYXID_ACTION_UNSUPPORTED"); + } + [Fact] public async Task ActionContinuation_ShouldCommitPostconditionWaterlineBeforeDispatch() { @@ -8452,7 +8529,33 @@ private static NyxIdAssistantActionRegistry CreateActionRegistry() => """); private static NyxIdAssistantActionRegistry CreateLeastScopeActionRegistry() => - NyxIdAssistantActionRegistry.Load(""" + NyxIdAssistantActionRegistry.Load(LeastScopeActionRegistryJson); + + private static NyxIdAssistantActionRegistry CreateKeyRotationActionRegistry() + { + var manifest = System.Text.Json.Nodes.JsonNode.Parse(LeastScopeActionRegistryJson)!.AsObject(); + manifest["revision"] = NyxIdAssistantActionRegistry.KeyRotationRegistryRevision; + manifest["actions"]!.AsArray().Add(System.Text.Json.Nodes.JsonNode.Parse(""" + { + "action": "key.rotate", + "description": "Rotate an API key.", + "params_schema": { + "type": "object", + "additionalProperties": false, + "required": ["keyId"], + "properties": { + "keyId": {"type": "string"} + } + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": false + } + """)); + return NyxIdAssistantActionRegistry.Load(manifest.ToJsonString()); + } + + private const string LeastScopeActionRegistryJson = """ { "schema_version": 4, "revision": "nyxid-assistant-actions.v6", @@ -8531,7 +8634,7 @@ private static NyxIdAssistantActionRegistry CreateLeastScopeActionRegistry() => } ] } - """); + """; private static EventEnvelope CreateEnvelope(string actorId, IMessage payload) => new() { diff --git a/test/Aevatar.AI.Tests/NyxIdConformanceManifestTests.cs b/test/Aevatar.AI.Tests/NyxIdConformanceManifestTests.cs index 03d463fe23..4c8d8f2543 100644 --- a/test/Aevatar.AI.Tests/NyxIdConformanceManifestTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdConformanceManifestTests.cs @@ -30,13 +30,16 @@ public void TransitionRegistries_ShouldExposeOnlyRevisionExecutableActions() File.ReadAllText(Path.Combine(ContractRoot, "registry-v5.json"))); var leastScope = NyxIdAssistantActionRegistry.Load( File.ReadAllText(Path.Combine(ContractRoot, "registry-v6.json"))); - var target = NyxIdAssistantActionRegistry.Load( + var keyRotation = NyxIdAssistantActionRegistry.Load( File.ReadAllText(Path.Combine(ContractRoot, "registry-v7.json"))); + var target = NyxIdAssistantActionRegistry.Load( + File.ReadAllText(Path.Combine(ContractRoot, "registry-v8.json"))); legacy.RegistryRevision.Should().Be("nyxid-assistant-actions.v4"); draft.RegistryRevision.Should().Be("nyxid-assistant-actions.v5"); leastScope.RegistryRevision.Should().Be("nyxid-assistant-actions.v6"); - target.RegistryRevision.Should().Be("nyxid-assistant-actions.v7"); + keyRotation.RegistryRevision.Should().Be("nyxid-assistant-actions.v7"); + target.RegistryRevision.Should().Be("nyxid-assistant-actions.v8"); foreach (var registry in new[] { legacy, draft }) { registry.TryGetDefinition("service.connect", out _).Should().BeTrue(); @@ -49,10 +52,24 @@ public void TransitionRegistries_ShouldExposeOnlyRevisionExecutableActions() leastScope.TryGetDefinition("key.create", out _).Should().BeTrue(); leastScope.TryGetDefinition("service.reauthorize", out _).Should().BeFalse(); leastScope.TryGetDefinition("key.rotate", out _).Should().BeFalse(); + keyRotation.TryGetDefinition("service.connect", out _).Should().BeTrue(); + keyRotation.TryGetDefinition("key.create", out _).Should().BeTrue(); + keyRotation.TryGetDefinition("service.reauthorize", out _).Should().BeFalse(); + keyRotation.TryGetDefinition("key.rotate", out _).Should().BeTrue(); target.TryGetDefinition("service.connect", out _).Should().BeTrue(); target.TryGetDefinition("key.create", out _).Should().BeTrue(); - target.TryGetDefinition("service.reauthorize", out _).Should().BeFalse(); target.TryGetDefinition("key.rotate", out _).Should().BeTrue(); + target.TryGetDefinition("service.reauthorize", out var reauthorize).Should().BeTrue(); + reauthorize!.Action.Should().Be(NyxIdAssistantActionKind.ServiceReauthorize); + reauthorize.RememberEligible.Should().BeFalse(); + NyxIdAssistantActionRegistry.IsActionExecutable( + target.RegistryRevision, + NyxIdAssistantActionKind.ServiceReauthorize) + .Should().BeTrue(); + NyxIdAssistantActionRegistry.IsActionExecutable( + keyRotation.RegistryRevision, + NyxIdAssistantActionKind.ServiceReauthorize) + .Should().BeFalse(); } [Fact] diff --git a/test/Aevatar.AI.Tests/NyxIdRequestServiceReauthorizeToolTests.cs b/test/Aevatar.AI.Tests/NyxIdRequestServiceReauthorizeToolTests.cs new file mode 100644 index 0000000000..5d5ba5260d --- /dev/null +++ b/test/Aevatar.AI.Tests/NyxIdRequestServiceReauthorizeToolTests.cs @@ -0,0 +1,258 @@ +using System.Net; +using System.Text; +using Aevatar.AI.Abstractions; +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.AI.ToolProviders.NyxId.Tools; +using FluentAssertions; + +namespace Aevatar.AI.Tests; + +public sealed class NyxIdRequestServiceReauthorizeToolTests +{ + private const string ActiveServiceAlpha = """ + { + "id": "service-alpha", + "api_key_id": "credential-alpha", + "status": "active", + "is_active": true, + "connected": true, + "connection_status": "active", + "granted_scopes": ["read:user"], + "last_authorized_at": "2026-08-10T07:00:00Z" + } + """; + + [Fact] + public void Tool_ShouldExposeTypedHumanSessionOnlyReadOnlySurface() + { + var tool = CreateTool(new StubHandler(ActiveServiceAlpha)); + + tool.Name.Should().Be("nyxid_request_service_reauthorize"); + tool.IsReadOnly.Should().BeTrue(); + ((IAgentToolCapabilityDescriptor)tool).Capabilities.Should() + .BeEquivalentTo(NyxIdToolSurfaces.HumanSessionOnly); + tool.ParametersSchema.Should().Contain("\"user_service_id\"") + .And.Contain("\"requested_scopes\"") + .And.Contain("\"additionalProperties\": false"); + } + + [Fact] + public async Task ExecuteAsync_ShouldEmitTypedRequirementForExactOwnerService() + { + var handler = new StubHandler(ActiveServiceAlpha); + var tool = CreateTool(handler); + const string arguments = + """{"user_service_id":"service-alpha","requested_scopes":["repo","read:org"]}"""; + var previous = AgentToolRequestContext.Current; + AgentToolRequestContext.Current = CapabilityContext(); + try + { + var result = await tool.ExecuteAsync(arguments); + var receipt = tool.CreateResultReceipt("call-reauthorize", tool.Name, arguments, result); + + handler.Requests.Should().Equal("/api/v1/keys/service-alpha"); + handler.Methods.Should().OnlyContain(static method => method == HttpMethod.Get); + handler.BearerTokens.Should().Equal("runtime-caller-credential"); + result.Should().Contain("\"blocked\":true") + .And.Contain("\"action\":\"service.reauthorize\""); + receipt.Should().NotBeNull(); + receipt!.Status.Should().Be(AgentToolReceiptStatus.AuthorizationRequired); + receipt.ErrorCode.Should().Be("NYXID_SERVICE_REAUTHORIZATION_REQUIRED"); + receipt.AuthorizationRequired.ServiceSlug.Should().BeEmpty(); + receipt.AuthorizationRequired.KeyCreate.Should().BeNull(); + receipt.AuthorizationRequired.KeyRotate.Should().BeNull(); + receipt.AuthorizationRequired.ServiceReauthorize.UserServiceId.Should().Be("service-alpha"); + receipt.AuthorizationRequired.ServiceReauthorize.RequestedScopes.Should() + .Equal("repo", "read:org"); + receipt.ToString().Should().NotContain("credential-alpha") + .And.NotContain("runtime-caller-credential") + .And.NotContain("token") + .And.NotContain("secret"); + result.Should().NotContain("credential-alpha") + .And.NotContain("runtime-caller-credential"); + } + finally + { + AgentToolRequestContext.Current = previous; + } + } + + [Theory] + [InlineData("{}")] + [InlineData("""{"user_service_id":"service-alpha"}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":[]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":["repo","repo"]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":[" repo"]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":["re po"]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":[""]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":[1]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":"repo"}""")] + [InlineData("""{"user_service_id":" service-alpha","requested_scopes":["repo"]}""")] + [InlineData("""{"user_service_id":"service/alpha","requested_scopes":["repo"]}""")] + [InlineData("""{"user_service_id":"Bearer secret","requested_scopes":["repo"]}""")] + [InlineData("""{"user_service_id":"service-alpha","requested_scopes":["repo"],"slug":"github"}""")] + [InlineData("not-json")] + public async Task ExecuteAsync_ShouldRejectInvalidArgumentsBeforeRead(string arguments) + { + var handler = new StubHandler(ActiveServiceAlpha); + var tool = CreateTool(handler); + var previous = AgentToolRequestContext.Current; + AgentToolRequestContext.Current = CapabilityContext(); + try + { + var result = await tool.ExecuteAsync(arguments); + var receipt = tool.CreateResultReceipt("call-reauthorize", tool.Name, arguments, result); + + handler.Requests.Should().BeEmpty(); + result.Should().Contain("NYXID_SERVICE_REAUTHORIZE_ARGUMENTS_INVALID"); + receipt.Should().NotBeNull(); + receipt!.Status.Should().Be(AgentToolReceiptStatus.Error); + receipt.AuthorizationRequired.Should().BeNull(); + } + finally + { + AgentToolRequestContext.Current = previous; + } + } + + [Fact] + public async Task ExecuteAsync_ShouldRequireVerifiedOwnerAuthorityBeforeRead() + { + var handler = new StubHandler(ActiveServiceAlpha); + var tool = CreateTool(handler); + const string arguments = + """{"user_service_id":"service-alpha","requested_scopes":["repo"]}"""; + var previous = AgentToolRequestContext.Current; + AgentToolRequestContext.Current = AgentToolExecutionContext.Empty; + try + { + var result = await tool.ExecuteAsync(arguments); + var receipt = tool.CreateResultReceipt("call-reauthorize", tool.Name, arguments, result); + + handler.Requests.Should().BeEmpty(); + result.Should().Contain("NYXID_SERVICE_REAUTHORIZE_CONTEXT_UNAVAILABLE"); + receipt!.Status.Should().Be(AgentToolReceiptStatus.Error); + receipt.AuthorizationRequired.Should().BeNull(); + } + finally + { + AgentToolRequestContext.Current = previous; + } + } + + [Theory] + [InlineData(""" + { + "id": "service-other", + "status": "active", + "is_active": true, + "connection_status": "active", + "granted_scopes": ["repo"], + "last_authorized_at": "2026-08-10T07:00:00Z" + } + """)] + [InlineData(""" + { + "id": "service-alpha", + "status": "active", + "is_active": false, + "connection_status": "active", + "granted_scopes": ["repo"], + "last_authorized_at": "2026-08-10T07:00:00Z" + } + """)] + [InlineData("""{"error":"not_found"}""")] + public async Task ExecuteAsync_ShouldFailClosedWhenExactOwnerServiceIsUnavailable( + string responseJson) + { + var handler = new StubHandler(responseJson); + var tool = CreateTool(handler); + const string arguments = + """{"user_service_id":"service-alpha","requested_scopes":["repo"]}"""; + var previous = AgentToolRequestContext.Current; + AgentToolRequestContext.Current = CapabilityContext(); + try + { + var result = await tool.ExecuteAsync(arguments); + var receipt = tool.CreateResultReceipt("call-reauthorize", tool.Name, arguments, result); + + handler.Requests.Should().Equal("/api/v1/keys/service-alpha"); + result.Should().Contain("NYXID_SERVICE_REAUTHORIZE_SERVICE_UNAVAILABLE"); + receipt.Should().NotBeNull(); + receipt!.Status.Should().Be(AgentToolReceiptStatus.Error); + receipt.AuthorizationRequired.Should().BeNull(); + } + finally + { + AgentToolRequestContext.Current = previous; + } + } + + [Fact] + public void CreateResultReceipt_ShouldRejectResultThatDoesNotEchoExactArguments() + { + var tool = CreateTool(new StubHandler(ActiveServiceAlpha)); + const string arguments = + """{"user_service_id":"service-alpha","requested_scopes":["repo"]}"""; + const string driftedResult = + """ + {"blocked":true,"action":"service.reauthorize","user_service_id":"service-alpha","requested_scopes":["repo","admin:org"],"reason_code":"NYXID_SERVICE_REAUTHORIZATION_REQUIRED","safe_message":"Re-authorize the exact connected NyxID service in the secure browser action."} + """; + + var receipt = tool.CreateResultReceipt("call-reauthorize", tool.Name, arguments, driftedResult); + + receipt.Should().NotBeNull(); + receipt!.Status.Should().Be(AgentToolReceiptStatus.Error); + receipt.ErrorCode.Should().Be("NYXID_SERVICE_REAUTHORIZE_RESULT_INVALID"); + receipt.AuthorizationRequired.Should().BeNull(); + } + + private static NyxIdRequestServiceReauthorizeTool CreateTool(StubHandler handler) + { + var options = new NyxIdToolOptions { BaseUrl = "https://nyx.test" }; + var client = new NyxIdApiClient(options, new HttpClient(handler)); + return new NyxIdRequestServiceReauthorizeTool(client); + } + + private static AgentToolExecutionContext CapabilityContext() => + AgentToolExecutionContext.Empty with + { + Caller = new AgentToolCallerContext( + "scope-alpha", + "caller-alpha", + null, + "scope-alpha"), + Credentials = new AgentToolCredentials( + "runtime-caller-credential", + "runtime-organization-credential", + null, + AgentToolNyxIdCredentialKind.SourceReadableUserBearer), + NyxIdAuthority = new AgentToolNyxIdAuthorityContext( + "nyxid", + string.Empty, + "nyx-user-alpha"), + }; + + private sealed class StubHandler(string responseJson) : HttpMessageHandler + { + public List Requests { get; } = []; + + public List BearerTokens { get; } = []; + + public List Methods { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri!.AbsolutePath); + BearerTokens.Add(request.Headers.Authorization?.Parameter); + Methods.Add(request.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs b/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs index f13de1f3f3..dcda4d0fb1 100644 --- a/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs +++ b/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs @@ -152,6 +152,50 @@ await projector.ProjectAsync( .Should().Be("key-predecessor"); } + [Fact] + public async Task ProjectAsync_ShouldHydrateReloadableServiceReauthorizeParameters() + { + var dispatcher = new RecordingWriteDispatcher(); + var projector = new NyxIdChatConversationCurrentStateProjector( + dispatcher, + new FixedProjectionClock(DateTimeOffset.Parse("2026-08-19T04:00:00Z"))); + var state = BuildState(); + var reauthorize = state.PendingActions.Single(); + reauthorize.Action = NyxIdAssistantActionKind.ServiceReauthorize; + reauthorize.Params = new NyxIdAssistantActionParams + { + ServiceReauthorize = new NyxIdServiceReauthorizeParams + { + UserServiceId = "service-alpha", + RequestedScopes = { "repo", "read:org" }, + }, + }; + var recentReauthorize = reauthorize.Clone(); + recentReauthorize.ActionRequestId = "action-service-reauthorize"; + state.RecentActions.Add(recentReauthorize); + + await projector.ProjectAsync( + NewContext(), + WrapCommitted( + new NyxIdChatActionRequestedEvent(), + state, + version: 19, + eventId: "event-alpha-19", + stateEventTimestamp: DateTimeOffset.Parse("2026-08-19T04:00:00Z"))); + + var document = dispatcher.Upserts.Should().ContainSingle().Subject; + var pendingRequest = document.PendingActions.Should().ContainSingle().Which.Request; + pendingRequest.Action.Should().Be("service.reauthorize"); + pendingRequest.Params.ParamsCase.Should().Be( + NyxIdChatConversationActionParamsDocument.ParamsOneofCase.ServiceReauthorize); + pendingRequest.Params.ServiceReauthorize.UserServiceId.Should().Be("service-alpha"); + pendingRequest.Params.ServiceReauthorize.RequestedScopes.Should().Equal("repo", "read:org"); + var recentRequest = document.RecentActions.Should().ContainSingle().Which.Request; + recentRequest.ActionRequestId.Should().Be("action-service-reauthorize"); + recentRequest.Params.ServiceReauthorize.UserServiceId.Should().Be("service-alpha"); + recentRequest.Params.ServiceReauthorize.RequestedScopes.Should().Equal("repo", "read:org"); + } + [Fact] public async Task ProjectAsync_ShouldCopyAuthoritativeDeletionTombstone() { diff --git a/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs index d1b9f65f3c..a02fa721cd 100644 --- a/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs +++ b/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs @@ -197,6 +197,51 @@ public async Task GetAsync_ShouldExposeFlatReloadableKeyActionParameters() rotateParams.AllowedServiceIds.Should().BeNull(); } + [Fact] + public async Task GetAsync_ShouldExposeNestedReloadableServiceReauthorizeParameters() + { + var document = BuildDocument(stateVersion: 8); + var reauthorize = document.PendingActions.Single(); + reauthorize.Action = "service.reauthorize"; + reauthorize.Request.Action = "service.reauthorize"; + reauthorize.Request.Params = new NyxIdChatConversationActionParamsDocument + { + ServiceReauthorize = new NyxIdChatConversationServiceReauthorizeDocument + { + UserServiceId = "service-alpha", + RequestedScopes = { "repo", "read:org" }, + }, + }; + var port = new ProjectionNyxIdChatConversationStateQueryPort( + new RecordingReader { Document = document }); + + var result = await port.GetAsync(new NyxIdChatConversationStateQuery( + "scope-alpha", + "conversation-alpha")); + + var request = result.Snapshot!.PendingActions.Single().Request!; + request.Action.Should().Be("service.reauthorize"); + request.Params.ServiceReauthorize.Should().BeEquivalentTo( + new NyxIdChatServiceReauthorizeSnapshot("service-alpha", ["repo", "read:org"])); + request.Params.KeyId.Should().BeNull(); + request.Params.Name.Should().BeNull(); + request.Params.Platform.Should().BeNull(); + request.Params.AllowedServiceIds.Should().BeNull(); + request.Params.ServiceAccessReview.Should().BeNull(); + + var json = System.Text.Json.JsonSerializer.Serialize( + request.Params, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + using var parsed = System.Text.Json.JsonDocument.Parse(json); + var root = parsed.RootElement; + root.EnumerateObject().Select(property => property.Name).Should() + .BeEquivalentTo("serviceReauthorize"); + var nested = root.GetProperty("serviceReauthorize"); + nested.GetProperty("userServiceId").GetString().Should().Be("service-alpha"); + nested.GetProperty("requestedScopes").EnumerateArray().Select(scope => scope.GetString()) + .Should().Equal("repo", "read:org"); + } + [Fact] public async Task GetAsync_ShouldExposeReloadableServiceAccessReviewParameters() { diff --git a/tools/ci/tests/test_nyxid_conformance_guard.py b/tools/ci/tests/test_nyxid_conformance_guard.py index 1592ed6dee..69065bc444 100644 --- a/tools/ci/tests/test_nyxid_conformance_guard.py +++ b/tools/ci/tests/test_nyxid_conformance_guard.py @@ -202,6 +202,7 @@ def write_transition_payloads(contract_root: Path): "nyxid-assistant-actions.v5", "nyxid-assistant-actions.v6", "nyxid-assistant-actions.v7", + "nyxid-assistant-actions.v8", ] transition_payloads = {} for revision in revisions: From 0581191b7c2f26979c84c464f01e5ce2194d7bf1 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 19 Aug 2026 13:05:52 +0800 Subject: [PATCH 2/3] Refresh NyxID conformance source pin Co-Authored-By: Claude Fable 5 --- .../v1/sources.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 9d2435cfe8..f9f3c255b5 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,23 +2,23 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "10b445740479b263dc61f9ec0d37a62ba6793346", - "contract_files_sha256": "64a7efebf275d30f20d41576d92cd91896225cb657c943946c8cea10e479e92c", + "revision": "c923ae7bcfd50efca7505b934a7a1da68bd437d6", + "contract_files_sha256": "91f9391008ee17b16c4e8f05848349cd6bc458c36a91cd6564e0b9bbde2ce78b", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "23fd2cf48541c4b8da3fa1ef07277a7700c8478f9c638be8221465bf877fbb31", - "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "3854a0847baee04bd8dac537ed7b76d157aca6e56716d5541187f828780d5a95", - "agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs": "7d4cadee813442087dc74ff7d5ab9bc074749372ab1037c7e037431bb38d81e1", - "agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs": "5f976c9924229431a4350d08d0501526651fbf7f183693489ec79d066ffa6670", + "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "7015dfa39b79bd50df81a2684d7a336442bca2e9ccb0bb5265db05dc8d65c323", + "agents/Aevatar.GAgents.NyxidChat/NyxIdChatBrowserActions.cs": "b659e0d2fcd4c28d4d1909475d10c8cbd1f9ca68cf7b0b945e13f1364e054f92", + "agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationAguiFrameBuilder.cs": "35f013c7b8a754ac89734475ca09ef31140279c2198777d82c730fef3a61e9ed", "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_recovery_secret.proto": "07dbc449a732df6c7a6d0a97054ddbe35a517f4af4c5670b05ed0bea0bf2011a", - "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto": "04081dd86ae6d22154a343bc4d2e951b8377d669b9724b00f5b0dbef99baafcc", + "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto": "76ea1f6eeffd2ec70f5f2d5241637fcf7ccce4ff54de8d7dbf1edd40f87a849b", "docs/adr/0048-nyxid-assistant-operation-class-boundary.md": "884aca09774e773e68154c923fec8078610b2cf8e97f581fedc36e10451ccec3", - "src/Aevatar.AI.Abstractions/ai_messages.proto": "099251f2623800c2b8dcf1a63958b0456d6d898ca5a7ede5f49bf392b3a8aa5b", + "src/Aevatar.AI.Abstractions/ai_messages.proto": "e00e0bb46cf93f73dfedf83c2c08463e2df22333921d88070643bf7373d65142", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiAccessContracts.cs": "9aaf3d1e8f071bdf2bc81e3a82f861440dade2a02d040c906477065cc147082e", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "1b033df9cb55c741e9b52054cbd4a91067f03c8c3797bd076a7e3d6133eb0fcb", - "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs": "", - "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", - "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "18212bb64644cfbca401065bccce439ea5fa00316deff57d730a0d9ac2650e53", - "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs": "", + "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdBrowserActionRequestToolHelpers.cs": "ee0badbba33a19d967899a7efacedd9d68e70d79995a7438c90dcb3d0b6e8e49", + "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "1d2449545c6e5dff21fae3e1c923a64fa44ad1bda62c0ca2a1311198fdd8eccd", + "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "ba3498dd0202f2f9ba0a4d73ea26d18bfe86001af45bba0e47d79e6e966fc378", + "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestServiceReauthorizeTool.cs": "2fe3bd8d1c2d93e70386342e1b1733e90a547e7fd225fc912a168736fb3641f1", "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "6281ec9394f937f55b1ff9dd800b92d9625f09219a33e31c87f8885430523f13" } }, From 1fd1020e80b4ce3f856c066b9972e555d766fe1c Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 19 Aug 2026 13:31:55 +0800 Subject: [PATCH 3/3] Keep profile-authored service_reauthorize routes ordinary while unadvertised Drop the ServiceReauthorize arms from the conversation GAgent route-to-intent mapping and from the executor built-in intent checks so a published profile whose member IntentId is literally service_reauthorize keeps its full committed catalog instead of narrowing to a built-in the materializer does not resolve yet. Add a GAgent test pinning the ordinary-profile-route behaviour. Co-Authored-By: Claude Fable 5 --- .../NyxIdChatConversationGAgent.cs | 2 - .../NyxIdChatTurnOperationExecutor.cs | 5 +- .../NyxIdChatConversationGAgentTests.cs | 99 +++++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs index 48cf8aa0bb..33ab8cc9f2 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatConversationGAgent.cs @@ -3085,8 +3085,6 @@ private async Task ClassifyTurnIntentAsync( NyxIdChatTurnIntent.KeyCreate, NyxIdChatTurnIntentClassifier.KeyRotateIntentId => NyxIdChatTurnIntent.KeyRotate, - NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId => - NyxIdChatTurnIntent.ServiceReauthorize, _ => NyxIdChatTurnIntent.Unspecified, }; } diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs index c905d50993..3eecb571ff 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatTurnOperationExecutor.cs @@ -2511,8 +2511,7 @@ private void LogVerifiedAuthorizationCatalogDiagnostic( private static bool IsBuiltInIntent(NyxIdChatTurnIntent intent) => intent is NyxIdChatTurnIntent.ServiceConnect or NyxIdChatTurnIntent.KeyCreate or - NyxIdChatTurnIntent.KeyRotate or - NyxIdChatTurnIntent.ServiceReauthorize; + NyxIdChatTurnIntent.KeyRotate; private static AgentToolExecutionContext ResolveCatalogToolContext( NeedsLlmReplyEvent request) @@ -2547,8 +2546,6 @@ private static bool IsProfileSelectedBuiltInIntent( NyxIdChatTurnIntentClassifier.KeyCreateIntentId, NyxIdChatTurnIntent.KeyRotate => NyxIdChatTurnIntentClassifier.KeyRotateIntentId, - NyxIdChatTurnIntent.ServiceReauthorize => - NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId, _ => null, }; return intentId is not null && string.Equals( diff --git a/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs b/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs index 7b31d875c1..e05c049999 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatConversationGAgentTests.cs @@ -1004,6 +1004,105 @@ await agent.HandleEventAsync(CreateEnvelope( AgentToolOperationApprovalPayload.Required); } + [Fact] + public async Task StartTurn_EnforcedProfileAuthoredServiceReauthorizeRoute_ShouldStayOrdinaryProfileRoute() + { + const string conversationActorId = "conversation-profile-authored-service-reauthorize"; + const string prompt = + "Re-authorize the exact UserService us-lark-alpha for scope im:message through " + + "tool lark-message-create."; + IAgentTool[] routeTools = [new CanonicalProfileTool("lark-message-create")]; + var classifierProvider = new ExactConnectedServiceRoutingClassifierProvider( + NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId); + var profileClassifier = new StreamingAgentProfileTurnClassifier( + new FixedLlmProviderFactory(classifierProvider)); + var materializer = new AgentProfileTurnCatalogMaterializer( + new FixedToolSetRegistry("profile.route", new FixedToolSource(routeTools)), + profileClassifier); + var independentClassifier = + new RecordingTurnIntentClassifier(NyxIdChatTurnIntent.ServiceConnect); + var profile = new AgentProfileSnapshot + { + ProfileId = "profile-mainnet-authored-service-reauthorize", + ProfileVersion = "profile-v1", + AgentKind = NyxIdChatServiceDefaults.GAgentKind, + PolicyRevision = "policy-v1", + RouteToolSetRef = "profile.route", + MaximumToolPolicy = new AgentProfileToolPolicy + { + ToolNames = { "lark-message-create" }, + }, + RecoveryToolPolicy = new AgentProfileToolPolicy(), + ClassifierTimeoutMs = 1_000, + ActivationMode = AgentProfileActivationMode.Enforced, + }; + profile.Members.Add(new AgentProfileSkillMember + { + IntentId = NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId, + RoutingDescription = + "Re-authorize an already-connected exact UserService operation.", + TaskToolPolicy = new AgentProfileToolPolicy + { + ToolNames = { "lark-message-create" }, + }, + SideEffectClass = AgentProfileSideEffectClass.ExternalHandoff, + }); + profile = AgentProfileSnapshotCodec.Seal(profile); + var eventStore = new InMemoryEventStoreForTests(); + var dispatch = new RecordingActorDispatchPort([], static (_, _) => Task.CompletedTask); + using var services = BuildEventSourcingServices( + eventStore, + registryCommandPort: new RecordingGAgentActorRegistryCommandPort()); + var agent = CreateController( + services, + conversationActorId, + dispatch, + materializer, + turnIntentClassifier: independentClassifier); + await agent.ActivateAsync(); + var start = WithOwner(CreateStartTurnCommand(), "owner-alpha"); + start.ConversationActorId = conversationActorId; + start.Prompt = prompt; + + await agent.HandleEventAsync(CreateEnvelope( + conversationActorId, + new NyxIdChatConversationCreateCommand + { + ScopeId = start.ScopeId, + CreatedLocally = true, + RequestedActorId = conversationActorId, + AgentProfile = profile, + FirstTurn = start, + })); + await DispatchPendingCreationFirstTurnAsync(agent, dispatch); + + classifierProvider.Requests.Should().HaveCount(2); + using var phaseOneInput = JsonDocument.Parse(classifierProvider.Requests[0].Messages + .Single(static message => message.Role == "user").Content!); + phaseOneInput.RootElement.GetProperty("intents").EnumerateArray() + .Select(static candidate => candidate.GetProperty("intent_id").GetString()).Should().Equal( + NyxIdChatTurnIntentClassifier.ServiceConnectIntentId, + NyxIdChatTurnIntentClassifier.KeyCreateIntentId, + NyxIdChatTurnIntentClassifier.KeyRotateIntentId, + AgentProfileTurnCatalogMaterializer.ProfileTaskRouteIntentId); + independentClassifier.UserMessages.Should().BeEmpty(); + agent.State.ActiveTurn.AgentProfileTurnAuthority.CandidateRoute.IntentId.Should() + .Be(NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId); + agent.State.ActiveTurn.AgentProfileTurnAuthority.AuthorityCeilingToolNames.Should() + .Equal("lark-message-create"); + agent.State.ActiveTurn.Intent.Should().Be( + NyxIdChatTurnIntent.Unspecified, + "a profile-authored service_reauthorize route stays an ordinary profile route " + + "while service.reauthorize is not advertised"); + var command = dispatch.OperationCalls.Should().ContainSingle().Which.Envelope.Payload + .Unpack(); + command.Llm.Intent.Should().Be(NyxIdChatTurnIntent.Unspecified); + command.Llm.AgentProfileTurnAuthority.CandidateRoute.IntentId.Should() + .Be(NyxIdChatTurnIntentClassifier.ServiceReauthorizeIntentId); + command.Llm.AgentProfileTurnAuthority.AuthorityCeilingToolNames.Should() + .Equal("lark-message-create"); + } + [Fact] public async Task StartTurn_EnforcedGeneralProfile_ShouldSelectServiceConnectAgainstBroadProfileIntent() {