From dc83eb4a52e8316c2820f7e6999845501cdc0444 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:42:28 +0800 Subject: [PATCH 1/4] feat(channels): suggest services for selected skills --- .../2026-09-28-channel-skill-services.md | 65 ++++ .../src/locales/channelMessages.en-US.ts | 32 +- .../src/locales/channelMessages.zh-CN.ts | 29 +- .../channels/ChannelConfigurationPage.tsx | 16 + .../channels/ChannelDefaultSkill.test.tsx | 15 +- .../channels/ChannelEditPage.test.tsx | 7 + .../channels/ChannelServicePicker.tsx | 5 +- .../channels/ChannelSkillServices.test.tsx | 292 ++++++++++++++++++ .../channels/ChannelSkillServices.tsx | 245 +++++++++++++++ .../channels/connectionStyles.ts | 13 + .../channels/queries.ts | 2 + .../channels/skillServiceRecommendations.ts | 70 +++++ .../src/shared/api/channelServicesApi.test.ts | 39 +++ .../src/shared/api/channelServicesApi.ts | 61 ++++ .../src/shared/api/channelSkillsApi.test.ts | 80 ++++- .../src/shared/api/channelSkillsApi.ts | 46 +++ 16 files changed, 1012 insertions(+), 5 deletions(-) create mode 100644 apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md new file mode 100644 index 000000000..8025d00d1 --- /dev/null +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md @@ -0,0 +1,65 @@ +# Channel Skill service suggestions + +Issue: https://github.com/aevatarAI/aevatar/issues/3678 + +## User behavior + +Channel binding and editing automatically show suggested Services when a Skill +is selected. Each suggestion names its evidence and shows the account's personal +or organization service instances. Selecting an instance adds its exact +UserService ID to the existing form; the normal channel save commits the choice. +Discovery never adds grants, selects services, or removes existing selections. +Changing or clearing the Skill preserves manually selected services. + +The region distinguishes unselected, selected, no connection, inactive, +unavailable account access, and missing current-session authorization. It does +not claim credential validity, which the inventory endpoint does not expose. +Links open NyxID service management and the existing Account service-access +review in new tabs, preserving unsaved form choices. Refresh suggestions reloads +both discovery and selectable service access after connection or authorization +changes. Pending and failed discovery stays local to this region; manual service +selection remains usable. An empty result does not imply that no services are +needed. Current platform-required services retain their existing behavior. + +## Discovery contracts and limits + +- Resolve the registration's Skill name through authenticated Ornn + `GET /api/v1/skills/:idOrName`, then read `GET /api/v1/skills/:guid/json`. + Verify matching names and read only the description, root `SKILL.md`, and + optional `nyxidServiceSlug` association. +- Read NyxID `GET /api/v1/catalog?include_all=true` for service names, slugs and + exact `recommended_skills` names. Read `GET /api/v1/user-services` for account + instances. These are existing external contracts, also used by the backend + Ornn client on `feature/integrate`; no backend changes are required. +- Evidence priority is the Skill's explicit service association, then an exact + catalog recommendation, then whole service-name/slug mentions in the Skill + description or root instructions. Names shorter than three characters are + excluded from text inference. Nested skill references are not traversed. +- None of these sources declares an exhaustive mandatory dependency list. + Suggestions therefore remain advisory; text mentions explicitly say they may + be needed for some tasks. There is no LLM inference or fabricated required- + service schema. Literal matching can miss aliases or identify incidental + mentions; users retain the complete manual picker. +- Skill content is input data, never executed or rendered as instructions. + Reasons show the evidence category without copying private instruction text. + Malformed or mismatched responses produce a retryable discovery failure. +- Slugs associate recommendations with instances; only the existing authorized + service-choice adapter determines selectable UserService IDs. Catalog IDs and + Ornn association IDs never enter the channel authorization payload. +- Queries are scoped by console scope and selected Skill name, consume abort + signals, and do not show the previous Skill's results while a new query runs. + +## Design and validation + +Use the existing compact Channels form, typography and color tokens. The inline +suggestion list supports wrapped names, narrow widths, keyboard actions, loading, +empty, error and retry states. English and Chinese messages use existing locales. +The Excalidraw baseline remains unchanged: +`30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. + +Route integration coverage exercises evidence, distinct same-slug personal and +organization instances, exact submitted IDs, unavailable access, manual fallback, +late responses, clearing and retry after new authorization. Adapter coverage +protects authenticated package/catalog requests and mismatched Skill identity. +Full frontend typecheck, suite and production build belong to GitHub CI under +the personal incremental-validation policy. diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts index 99646cf08..37dc1217a 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -1,4 +1,34 @@ export default { + 'channels.suggestions.title': 'Suggested for {skill}', + 'channels.suggestions.refresh': 'Refresh suggestions', + 'channels.suggestions.loading': 'Finding related services...', + 'channels.suggestions.error': + 'Could not identify related services. Refresh to retry, or select services manually below.', + 'channels.suggestions.help': + 'Suggestions may be incomplete and do not confirm required dependencies. Select the services your task needs; each addition is saved with the channel.', + 'channels.suggestions.linked': 'Linked to this skill in Ornn.', + 'channels.suggestions.catalog': 'The service catalog recommends this skill.', + 'channels.suggestions.mention': + 'Mentioned in the skill description or instructions; may be needed for some tasks.', + 'channels.suggestions.unknownSource': 'Unknown source', + 'channels.suggestions.checking': 'Checking access...', + 'channels.suggestions.accessError': + 'Could not check access. Refresh to retry.', + 'channels.suggestions.inactive': 'Inactive — manage this service in NyxID.', + 'channels.suggestions.unavailable': + 'Access unavailable — check with the service owner.', + 'channels.suggestions.unauthorized': + 'Not authorized for this session — review service access.', + 'channels.suggestions.notSelected': 'Not selected', + 'channels.suggestions.selected': 'Selected', + 'channels.suggestions.selectNamed': 'Select {service}', + 'channels.suggestions.select': 'Select', + 'channels.suggestions.notConnected': + 'No connection found in your account. Add this service in NyxID.', + 'channels.suggestions.empty': + 'No related services identified. You can still select services manually below.', + 'channels.suggestions.manage': 'Manage connections in NyxID ↗', + 'channels.suggestions.reviewAccess': 'Review service access ↗', 'channels.column.owner': 'Owner', 'channels.owner.organization': 'Organization', 'channels.owner.id': 'Owner ID', @@ -202,7 +232,7 @@ export default { 'channels.connect.selectAll': 'Select all', 'channels.connect.selectAllResults': 'Select all results', 'channels.connect.servicesHelp': - 'Only services available through your current NyxID authorization are shown.', + 'Choose from your authorized services, or connect additional services in NyxID.', 'channels.connect.servicesLoading': 'Loading services', 'channels.connect.servicesError': 'Could not load your services. Retry before connecting.', diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts index 4491cc65c..652a3e019 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -1,4 +1,30 @@ export default { + 'channels.suggestions.title': '为 {skill} 推荐', + 'channels.suggestions.refresh': '刷新推荐', + 'channels.suggestions.loading': '正在识别相关服务…', + 'channels.suggestions.error': + '暂时无法识别相关服务。请刷新重试,或在下方手动选择。', + 'channels.suggestions.help': + '推荐结果可能不完整,也不代表必需依赖。请按任务需要选择服务,保存 Channel 后生效。', + 'channels.suggestions.linked': '该 Skill 在 Ornn 中关联了此服务。', + 'channels.suggestions.catalog': '服务目录将该 Skill 列为推荐技能。', + 'channels.suggestions.mention': + 'Skill 的描述或说明提到了此服务,部分任务可能需要。', + 'channels.suggestions.unknownSource': '未知来源', + 'channels.suggestions.checking': '正在检查访问权限…', + 'channels.suggestions.accessError': '暂时无法检查访问权限,请刷新重试。', + 'channels.suggestions.inactive': '服务未启用,请前往 NyxID 管理。', + 'channels.suggestions.unavailable': '暂无访问权限,请联系服务所有者。', + 'channels.suggestions.unauthorized': '当前会话尚未授权,请检查服务访问权限。', + 'channels.suggestions.notSelected': '尚未选择', + 'channels.suggestions.selected': '已选择', + 'channels.suggestions.selectNamed': '选择 {service}', + 'channels.suggestions.select': '选择', + 'channels.suggestions.notConnected': + '账号中未找到此服务的连接,请前往 NyxID 添加。', + 'channels.suggestions.empty': '未识别到相关服务。你仍可在下方手动选择。', + 'channels.suggestions.manage': '在 NyxID 管理连接 ↗', + 'channels.suggestions.reviewAccess': '检查服务访问权限 ↗', 'channels.column.owner': '归属', 'channels.owner.organization': '组织', 'channels.owner.id': '归属 ID', @@ -179,7 +205,8 @@ export default { 'channels.connect.selected': '已选 {count} 项', 'channels.connect.selectAll': '全选', 'channels.connect.selectAllResults': '全选搜索结果', - 'channels.connect.servicesHelp': '仅显示当前 NyxID 授权范围内可用的服务。', + 'channels.connect.servicesHelp': + '选择已授权的服务,或前往 NyxID 连接更多服务。', 'channels.connect.servicesLoading': '正在加载服务', 'channels.connect.servicesError': '无法加载服务,请重试后再连接。', 'channels.connect.servicesEmpty': '当前 NyxID 授权范围内暂无可用服务。', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx index 7c0d5e9e7..df97f32a1 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx @@ -29,6 +29,7 @@ import { import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; import ChannelServicePicker from './ChannelServicePicker'; import ChannelSkillField from './ChannelSkillField'; +import ChannelSkillServices from './ChannelSkillServices'; import { channelConnectionCss } from './connectionStyles'; import { ChannelBadge, @@ -565,6 +566,21 @@ function ConfigurationForm({ retry={() => void services.refetch()} editing={editing} replacesDefaults={baseline?.authorizationMode === 'nyxid_default'} + suggestions={ + + setServiceIds((ids) => [...new Set([...ids, id])]) + } + refreshAccess={() => void services.refetch()} + /> + } /> {failure ? (

diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx index 4c8c6284b..cdf4cc800 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx @@ -59,7 +59,9 @@ const linkedSkill = (id = skillId) => ({ }); const detailReads = () => fetchMock.mock.calls.filter(([input]) => - String(input).includes('/api/v1/skills/'), + [skillId, secondSkillId].some((id) => + String(input).endsWith(`/skills/${id}`), + ), ); const writes = () => fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); @@ -89,6 +91,17 @@ beforeEach(() => { return response(linkedSkill()); if (String(input).endsWith(`/skills/${secondSkillId}`)) return response(linkedSkill(secondSkillId)); + if (String(input).endsWith('/catalog?include_all=true')) + return response({ entries: [] }); + const name = String(input).match(/\/skills\/([^/]+)$/)?.[1]; + if (name) return response({ data: { guid: `guid-${name}`, name } }); + const packageName = String(input).match( + /\/skills\/guid-([^/]+)\/json$/, + )?.[1]; + if (packageName) + return response({ + data: { name: packageName, files: { 'SKILL.md': '' } }, + }); if (String(input).endsWith('/user-services')) return response({ services: [ diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx index ea3d06afd..161ad7e8b 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx @@ -99,6 +99,13 @@ const selectedServiceIds = [ 'user-service-ornn', ]; function catalogue(input: RequestInfo | URL, services = serviceCatalogue) { + if (String(input).endsWith('/catalog?include_all=true')) + return response({ entries: [] }); + const name = String(input).match(/\/skills\/([^/]+)$/)?.[1]; + if (name) return response({ data: { guid: `guid-${name}`, name } }); + const packageName = String(input).match(/\/skills\/guid-([^/]+)\/json$/)?.[1]; + if (packageName) + return response({ data: { name: packageName, files: { 'SKILL.md': '' } } }); if (String(input).includes('/skill-search')) return response({ data: { diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx index bac500860..9c2be7860 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx @@ -18,6 +18,7 @@ export default function ChannelServicePicker({ retry, editing = false, replacesDefaults = false, + suggestions, }: { readonly services: readonly ChannelServiceChoice[]; readonly selectedIds: readonly string[]; @@ -31,6 +32,7 @@ export default function ChannelServicePicker({ readonly retry: () => void; readonly editing?: boolean; readonly replacesDefaults?: boolean; + readonly suggestions?: React.ReactNode; }) { const [search, setSearch] = React.useState(''); const term = search.trim().toLocaleLowerCase(); @@ -64,9 +66,10 @@ export default function ChannelServicePicker({

{t( 'channels.connect.servicesHelp', - 'Only services available through your current NyxID authorization are shown.', + 'Choose from your authorized services, or connect additional services in NyxID.', )}

+ {suggestions} {loading ? ( ({ authFetch: jest.fn() })); +jest.mock('@/shared/studio/api', () => ({ + studioApi: { + getAuthSession: jest.fn().mockResolvedValue({ authenticated: false }), + }, +})); + +const fetchMock = jest.mocked(authFetch); +const response = (value: unknown, status = 200) => + ({ ok: status === 200, status, json: async () => value }) as Response; +const personal = (id: string, slug: string, label = slug) => ({ + id, + slug, + label, + is_active: true, + credential_source: { type: 'personal' }, +}); +const inventory = [ + personal('us-ornn', 'ornn-api', 'Ornn'), + personal('us-llm', 'chrono-llm-public', 'LLM'), + personal('us-manual', 'manual-only', 'Manual service'), + personal('us-personal', 'api-github', 'Personal GitHub'), + { + ...personal('us-org', 'api-github', 'Team GitHub'), + credential_source: { type: 'org', org_name: 'Acme', allowed: true }, + }, + personal('us-linear', 'linear', 'Linear'), + { ...personal('us-inactive', 'inactive-service'), is_active: false }, + { + ...personal('us-owner', 'owner-service'), + credential_source: { type: 'org', org_name: 'Other team', allowed: false }, + }, +]; +const allowedIds = ['us-ornn', 'us-llm', 'us-manual', 'us-personal', 'us-org']; +const registration = { + id: 'reg-alpha', + nyx_channel_bot_id: 'bot-alpha', + platform: 'lark', + label: 'Support bot', + owned: true, + binding_status: 'bound', + availability_status: 'available', + nyx_status: 'active', + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-manual', 'us-ornn', 'us-llm'], + state_version: 12, +}; +const catalog = [ + { slug: 'api-github', name: 'GitHub' }, + { slug: 'slack', name: 'Slack' }, + { slug: 'linear', name: 'Linear' }, + { slug: 'api-drive', name: 'Drive', recommended_skills: ['support'] }, + { slug: 'inactive-service', name: 'Inactive service' }, + { slug: 'owner-service', name: 'Owner service' }, + { slug: 'mail', name: 'Mail' }, +]; + +async function serve(input: RequestInfo | URL, init?: RequestInit) { + const url = String(input); + if (init?.method === 'POST') + return response({ error: 'insecure_webhook_base_url' }, 400); + if (url.endsWith('/user-services')) return response({ services: inventory }); + if (url.endsWith('/catalog?include_all=true')) + return response({ entries: catalog }); + if (url.includes('/skill-search')) + return response({ + data: { + items: ['support', 'slow-skill', 'no-services'].map((name) => ({ + guid: `guid-${name}`, + name, + })), + meta: { hasMore: false }, + }, + }); + const name = url.match(/\/skills\/([^/]+)$/)?.[1]; + if (name) + return response({ + data: { + guid: `guid-${name}`, + name, + description: 'Task guide', + nyxidServiceSlug: name === 'support' ? 'api-github' : null, + nyxidServiceId: 'catalog-id-is-not-authorization', + }, + }); + const packageName = url.match(/\/skills\/guid-([^/]+)\/json$/)?.[1]; + if (packageName) + return response({ + data: { + name: packageName, + files: { + 'SKILL.md': + packageName === 'no-services' + ? 'No integrations.' + : 'Use Slack, Linear, inactive-service and owner-service. gmail and mail-helper are unrelated. TEST_ONLY_PRIVATE', + }, + }, + }); + if (url === '/api/channels/registrations?scope=all') + return response([registration]); + if (url === '/api/channels/registrations/reg-alpha') + return response(registration); + throw new Error(`Unexpected request: ${url}`); +} + +beforeEach(() => { + fetchMock.mockReset().mockImplementation(serve); + persistAuthSession( + createNyxIDServiceSession({ allowed_service_ids: allowedIds }), + ); +}); + +function renderForm() { + return renderWithQueryClient( + , + ); +} +function suggestions(skill = 'support') { + return screen.getByRole('region', { name: `Suggested for ${skill}` }); +} +async function chooseSkill(name: string) { + fireEvent.mouseDown(await screen.findByRole('combobox')); + fireEvent.click( + await screen.findByText(name, { + selector: '.channels__skill-option strong', + }), + ); +} +const writes = () => + fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); + +it('discovers evidence and access gaps without granting access, then saves only the explicitly selected UserService instance', async () => { + renderForm(); + await screen.findByRole('button', { name: 'Select Team GitHub' }); + const related = within(suggestions()); + expect( + related.getByText('Linked to this skill in Ornn.'), + ).toBeInTheDocument(); + expect( + related.getByText('The service catalog recommends this skill.'), + ).toBeInTheDocument(); + expect(related.getByText('Slack')).toBeInTheDocument(); + expect(related.getAllByText(/may be needed for some tasks/)).not.toHaveLength( + 0, + ); + expect(related.queryByText('Mail')).not.toBeInTheDocument(); + expect( + related.getByText(/Not authorized for this session/), + ).toBeInTheDocument(); + expect(related.getByText(/Inactive —/)).toBeInTheDocument(); + expect(related.getByText(/Access unavailable —/)).toBeInTheDocument(); + expect(related.getAllByText(/No connection found/)).toHaveLength(2); + expect( + related.queryByRole('button', { name: 'Select Linear' }), + ).not.toBeInTheDocument(); + expect( + related.getByRole('link', { name: /Manage connections/ }), + ).toHaveAttribute('href', 'https://nyx.chrono-ai.fun/services'); + expect( + related.getByRole('link', { name: /Review service access/ }), + ).toHaveAttribute('href', '/scopes/scope-alpha/settings?section=account'); + expect(document.body).not.toHaveTextContent('TEST_ONLY_PRIVATE'); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + expect(writes()).toHaveLength(0); + fireEvent.click(related.getByRole('button', { name: 'Select Team GitHub' })); + expect(related.getByText('Selected')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /Personal GitHub/ }), + ).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Team GitHub/ })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByRole('alert'); + expect(writes()).toHaveLength(1); + expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-llm', 'us-manual', 'us-org', 'us-ornn'], + }); +}); + +it('updates suggestions on skill changes and ignores late results while retaining manual choices after clearing', async () => { + let complete!: (value: Response) => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + let pendingSignal: AbortSignal | null | undefined; + fetchMock.mockImplementation((input, init) => { + if (String(input).endsWith('/skills/guid-slow-skill/json')) { + pendingSignal = init?.signal; + return pending; + } + return serve(input, init); + }); + renderForm(); + fireEvent.click( + await screen.findByRole('button', { name: 'Select Personal GitHub' }), + ); + await chooseSkill('slow-skill'); + await waitFor(() => expect(pendingSignal).toBeTruthy()); + expect( + within(suggestions('slow-skill')).getByText('Finding related services...'), + ).toBeInTheDocument(); + expect( + within(suggestions('slow-skill')).queryByText('GitHub'), + ).not.toBeInTheDocument(); + await chooseSkill('no-services'); + await screen.findByText(/No related services identified/); + expect(pendingSignal?.aborted).toBe(true); + await act(async () => + complete( + response({ + data: { name: 'slow-skill', files: { 'SKILL.md': 'Slack' } }, + }), + ), + ); + expect( + within(suggestions('no-services')).queryByText('Slack'), + ).not.toBeInTheDocument(); + fireEvent.mouseDown(screen.getByRole('img', { name: 'close-circle' })); + expect( + screen.queryByRole('region', { name: /Suggested for/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /Personal GitHub/ }), + ).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /Manual service/ }), + ).toBeChecked(); + expect(writes()).toHaveLength(0); +}); + +it('keeps manual selection usable on discovery failure and refreshes connections and authorization on retry', async () => { + let recovered = false; + fetchMock.mockImplementation((input, init) => { + if (!recovered && String(input).includes('/catalog?')) + return Promise.resolve( + response({ message: 'PRIVATE_SERVER_DETAIL' }, 503), + ); + if (recovered && String(input).endsWith('/user-services')) + return Promise.resolve( + response({ + services: [ + ...inventory, + personal('us-slack', 'slack', 'Connected Slack'), + ], + }), + ); + return serve(input, init); + }); + renderForm(); + await screen.findByText(/Could not identify related services/); + expect(document.body).not.toHaveTextContent('PRIVATE_SERVER_DETAIL'); + fireEvent.click(screen.getByRole('checkbox', { name: /Personal GitHub/ })); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled(); + recovered = true; + persistAuthSession( + createNyxIDServiceSession({ + allowed_service_ids: [...allowedIds, 'us-slack', 'us-linear'], + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Refresh suggestions' })); + await screen.findByRole('button', { name: 'Select Connected Slack' }); + expect(screen.getByRole('button', { name: 'Select Linear' })).toBeEnabled(); + expect( + screen.queryByText(/Could not identify related services/), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /Personal GitHub/ }), + ).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /Connected Slack/ }), + ).not.toBeChecked(); + expect(writes()).toHaveLength(0); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx new file mode 100644 index 000000000..88ae7a090 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx @@ -0,0 +1,245 @@ +import { useQuery } from '@tanstack/react-query'; +import { Button } from 'antd'; +import * as React from 'react'; +import { + type ChannelServiceChoice, + listChannelServiceCatalog, + listChannelServiceInventory, +} from '@/shared/api/channelServicesApi'; +import { getChannelSkillServiceContext } from '@/shared/api/channelSkillsApi'; +import { t } from '@/shared/i18n/messages'; +import { AevatarLoadingDots } from '@/shared/ui/AevatarLoading'; +import { buildWorkflowActivitySettingsHref } from '../navigation'; +import { channelKeys } from './queries'; +import { recommendSkillServices } from './skillServiceRecommendations'; + +export default function ChannelSkillServices({ + scopeId, + skillName, + services, + selectedIds, + checkingAccess, + accessFailed, + disabled, + onSelect, + refreshAccess, +}: { + readonly scopeId: string; + readonly skillName: string; + readonly services: readonly ChannelServiceChoice[]; + readonly selectedIds: readonly string[]; + readonly checkingAccess: boolean; + readonly accessFailed: boolean; + readonly disabled: boolean; + readonly onSelect: (id: string) => void; + readonly refreshAccess: () => void; +}) { + const titleId = React.useId(); + const query = useQuery({ + queryKey: channelKeys.skillServices(scopeId, skillName), + queryFn: async ({ signal }) => { + const [skill, catalog, inventory] = await Promise.all([ + getChannelSkillServiceContext(skillName, signal), + listChannelServiceCatalog(signal), + listChannelServiceInventory(signal), + ]); + return recommendSkillServices(skill, catalog, inventory); + }, + enabled: Boolean(scopeId && skillName), + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + if (!skillName) return null; + const authorizedIds = new Set(services.map((service) => service.id)); + const refreshing = query.isFetching || checkingAccess; + return ( +
+
+

+ {t('channels.suggestions.title', 'Suggested for {skill}', { + skill: skillName, + })} +

+ +
+ {query.isPending || query.isFetching ? ( +

+ + {t('channels.suggestions.loading', 'Finding related services...')} +

+ ) : query.isError ? ( +

+ {t( + 'channels.suggestions.error', + 'Could not identify related services. Refresh to retry, or select services manually below.', + )} +

+ ) : ( + <> +

+ {t( + 'channels.suggestions.help', + 'Suggestions may be incomplete and do not confirm required dependencies. Select the services your task needs; each addition is saved with the channel.', + )} +

+ {query.data.length ? ( +
    + {query.data.map((recommendation) => ( +
  • + {recommendation.label} + + {recommendation.slug} + +

    + {recommendation.evidence === 'linked' + ? t( + 'channels.suggestions.linked', + 'Linked to this skill in Ornn.', + ) + : recommendation.evidence === 'catalog' + ? t( + 'channels.suggestions.catalog', + 'The service catalog recommends this skill.', + ) + : t( + 'channels.suggestions.mention', + 'Mentioned in the skill description or instructions; may be needed for some tasks.', + )} +

    + {recommendation.instances.length ? ( + recommendation.instances.map((instance) => { + const selected = selectedIds.includes(instance.id); + const selectable = authorizedIds.has(instance.id); + return ( +
    +
    + + {instance.label} + + + {instance.source === 'personal' + ? t('channels.connect.personal', 'Personal') + : instance.source === 'organization' + ? instance.organizationName || + t( + 'channels.connect.organization', + 'Organization', + ) + : t( + 'channels.suggestions.unknownSource', + 'Unknown source', + )} + + + {checkingAccess + ? t( + 'channels.suggestions.checking', + 'Checking access...', + ) + : accessFailed + ? t( + 'channels.suggestions.accessError', + 'Could not check access. Refresh to retry.', + ) + : !instance.active + ? t( + 'channels.suggestions.inactive', + 'Inactive — manage this service in NyxID.', + ) + : !instance.allowed + ? t( + 'channels.suggestions.unavailable', + 'Access unavailable — check with the service owner.', + ) + : !selectable + ? t( + 'channels.suggestions.unauthorized', + 'Not authorized for this session — review service access.', + ) + : !selected + ? t( + 'channels.suggestions.notSelected', + 'Not selected', + ) + : null} + +
    + {selected ? ( + + {t('channels.suggestions.selected', 'Selected')} + + ) : selectable && + instance.active && + instance.allowed ? ( + + ) : null} +
    + ); + }) + ) : ( +

    + {t( + 'channels.suggestions.notConnected', + 'No connection found in your account. Add this service in NyxID.', + )} +

    + )} +
  • + ))} +
+ ) : ( +

+ {t( + 'channels.suggestions.empty', + 'No related services identified. You can still select services manually below.', + )} +

+ )} + + )} + +
+ ); +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts index fd71e9989..5c9e6a180 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts @@ -23,6 +23,19 @@ export const channelConnectionCss = ` .channels__connection-form button.ant-input-password-icon:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__services-heading { align-items: center; display: flex; gap: 16px; justify-content: space-between; } .channels__services-heading > span { color: var(--wa-blue); font-size: 11px; } +.channels__skill-services { background: var(--wa-subtle); border: 1px solid var(--channels-border); border-radius: var(--wa-radius); margin-top: 12px; padding: 12px; } +.channels__skill-services .channels__services-heading { align-items: flex-start; flex-wrap: wrap; gap: 8px; } +.channels__skill-services h3 { color: var(--wa-ink); flex: 1 1 160px; font-size: 12px; line-height: 20px; margin: 0; overflow-wrap: anywhere; } +.channels__suggestion-list { list-style: none; margin: 12px 0 0; max-height: 360px; overflow-y: auto; padding: 0; scrollbar-gutter: stable; } +.channels__suggestion { border-top: 1px solid var(--channels-border); color: var(--wa-ink); font-size: 12px; padding: 12px 0; overflow-wrap: anywhere; } +.channels__suggestion:first-child { border-top: 0; padding-top: 0; } +.channels__suggestion:last-child { padding-bottom: 0; } +.channels__suggestion-instance { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.channels__suggestion-instance > div { flex: 1 1 180px; min-width: 0; } +.channels__suggestion-selected { color: var(--wa-blue); font-size: 11px; } +.channels__suggestion-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; } +.channels__suggestion-links a { color: var(--wa-blue); font-size: 11px; line-height: 18px; } +.channels__suggestion-links a:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__service-picker { border: 1px solid var(--channels-border); border-radius: 5px; margin-top: 12px; padding: 10px 10px 0; } .channels__service-picker > .ant-input-affix-wrapper { background: var(--wa-subtle); min-height: 34px; } .channels__service-picker .ant-input { background: transparent; font-size: 12px; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts index 6525a7ca9..e0fd58467 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts @@ -11,6 +11,8 @@ export const channelKeys = { ['channels', scopeId, 'skills', search] as const, skill: (scopeId: string, id: string) => ['channels', scopeId, 'skill', id] as const, + skillServices: (scopeId: string, name: string) => + ['channels', scopeId, 'skill-services', name] as const, list: (scopeId: string) => ['channels', scopeId, 'registrations'] as const, }; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts new file mode 100644 index 000000000..56bbe60d6 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts @@ -0,0 +1,70 @@ +import type { + ChannelServiceCatalogEntry, + ChannelServiceChoice, +} from '@/shared/api/channelServicesApi'; +import type { ChannelSkillServiceContext } from '@/shared/api/channelSkillsApi'; + +export interface SkillServiceRecommendation { + readonly slug: string; + readonly label: string; + readonly evidence: 'linked' | 'catalog' | 'mention'; + readonly instances: readonly ChannelServiceChoice[]; +} + +function mentions(text: string, term: string) { + // Match whole names/slugs, including those in URLs, but not substrings of + // other hyphenated names. Free text is always advisory. + if (term.trim().length < 3) return false; + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp( + `(? [entry.slug, entry])); + for (const service of inventory) { + if (!candidates.has(service.slug)) + candidates.set(service.slug, { + slug: service.slug, + name: service.label, + recommendedSkills: [], + }); + } + if (skill.linkedServiceSlug && !candidates.has(skill.linkedServiceSlug)) + candidates.set(skill.linkedServiceSlug, { + slug: skill.linkedServiceSlug, + name: skill.linkedServiceSlug, + recommendedSkills: [], + }); + const text = `${skill.description}\n${skill.instructions}`; + return [...candidates.values()].flatMap( + (entry) => { + const evidence = + entry.slug === skill.linkedServiceSlug + ? 'linked' + : entry.recommendedSkills.includes(skill.name) + ? 'catalog' + : mentions(text, entry.slug) || mentions(text, entry.name) + ? 'mention' + : null; + return evidence + ? [ + { + slug: entry.slug, + label: entry.name, + evidence, + instances: inventory.filter( + (service) => service.slug === entry.slug, + ), + }, + ] + : []; + }, + ); +} diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts index f7d7ed480..3325e4f3c 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts @@ -2,6 +2,7 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; import { + listChannelServiceCatalog, listChannelServiceIdentities, listChannelServices, } from './channelServicesApi'; @@ -28,6 +29,44 @@ const personal = { const response = (value: unknown, status = 200) => ({ ok: status === 200, status, json: async () => value }) as Response; +it('reads unconnected catalog candidates with current credentials without exposing catalog IDs or secrets as choices', async () => { + const session = createNyxIDServiceSession({ allowed_service_ids: [] }); + persistAuthSession(session); + fetchMock.mockResolvedValue( + response({ + entries: [ + { + id: 'catalog-github', + slug: 'api-github', + name: 'GitHub', + recommended_skills: ['support'], + api_key: 'TEST_ONLY_SECRET', + }, + ], + }), + ); + const signal = new AbortController().signal; + expect(await listChannelServiceCatalog(signal)).toEqual([ + { + slug: 'api-github', + name: 'GitHub', + recommendedSkills: ['support'], + }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + 'https://nyx.example.test/api/v1/catalog?include_all=true', + { + signal, + credentials: 'omit', + cache: 'no-store', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.tokens.accessToken}`, + }, + }, + ); +}); + it('reads safe service names independently of current selectable grants and activity', async () => { persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: [] })); fetchMock.mockResolvedValue( diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts index 79567bc4c..d7c3fecbb 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts @@ -26,6 +26,67 @@ export type ChannelServiceIdentity = Pick< 'id' | 'slug' | 'label' >; +export interface ChannelServiceCatalogEntry { + readonly slug: string; + readonly name: string; + readonly recommendedSkills: readonly string[]; +} + +// Discovery may show unavailable services, but only listChannelServices owns +// the selectable IDs admitted by the current bearer. +export async function listChannelServiceInventory(signal?: AbortSignal) { + const session = await ensureActiveAuthSession(); + if (!session) throw new ChannelApiError(401); + return readChannelServiceInventory(session.tokens.accessToken, signal); +} + +export async function listChannelServiceCatalog( + signal?: AbortSignal, +): Promise { + const config = getNyxIDRuntimeConfig(); + if (config.configurationError || !config.baseUrl) + throw new Error('NyxID is unavailable.'); + const session = await ensureActiveAuthSession(); + if (!session) throw new ChannelApiError(401); + const response = await authFetch( + `${config.baseUrl}/api/v1/catalog?include_all=true`, + { + signal, + credentials: 'omit', + cache: 'no-store', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.tokens.accessToken}`, + }, + }, + ); + if (!response.ok) throw new ChannelApiError(response.status); + const body = expectRecord(await response.json(), 'Service catalog'); + return expectArray(body.entries, 'Service catalog entries', (value) => { + const row = expectRecord(value, 'Service catalog entry'); + const slug = readString(row, 'slug', 'Catalog slug'); + const name = readString(row, 'name', 'Catalog name'); + if (!slug.trim() || !name.trim()) + throw new Error('Missing catalog identity.'); + return { + slug, + name, + recommendedSkills: + row.recommended_skills == null + ? [] + : expectArray( + row.recommended_skills, + 'Recommended skills', + (item) => { + if (typeof item !== 'string') + throw new Error('Invalid recommended skill.'); + return item; + }, + ), + }; + }); +} + function decodeService(value: unknown): ChannelServiceChoice { const row = expectRecord(value, 'User service'); const source = expectRecord(row.credential_source, 'Credential source'); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts index 56e417fba..928269d66 100644 --- a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts @@ -1,7 +1,11 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; -import { getChannelSkillById, searchChannelSkills } from './channelSkillsApi'; +import { + getChannelSkillById, + getChannelSkillServiceContext, + searchChannelSkills, +} from './channelSkillsApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); jest.mock('@/shared/auth/config', () => ({ @@ -11,6 +15,80 @@ jest.mock('@/shared/auth/config', () => ({ }), })); const fetchMock = jest.mocked(authFetch); + +it('resolves the selected name and reads its exact package with authenticated, abortable requests', async () => { + fetchMock.mockReset(); + const session = createNyxIDServiceSession(); + persistAuthSession(session); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + guid: 'guid+one', + name: 'team+docs', + description: 'Documentation', + nyxidServiceSlug: 'api-github', + nyxidServiceId: 'catalog-id', + }, + }), + } as Response); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + name: 'team+docs', + files: { 'SKILL.md': 'Use GitHub.', 'private.txt': 'TEST_ONLY_SECRET' }, + }, + }), + } as Response); + const signal = new AbortController().signal; + expect(await getChannelSkillServiceContext('team+docs', signal)).toEqual({ + name: 'team+docs', + description: 'Documentation', + instructions: 'Use GitHub.', + linkedServiceSlug: 'api-github', + }); + expect( + fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname), + ).toEqual([ + '/api/v1/proxy/s/ornn-api/api/v1/skills/team%2Bdocs', + '/api/v1/proxy/s/ornn-api/api/v1/skills/guid%2Bone/json', + ]); + for (const [, init] of fetchMock.mock.calls) { + expect(init).toMatchObject({ + credentials: 'omit', + cache: 'no-store', + headers: { Authorization: `Bearer ${session.tokens.accessToken}` }, + }); + expect(init?.signal).toBe(signal); + } +}); + +it('rejects another skill package instead of deriving recommendations from mismatched content', async () => { + fetchMock.mockReset(); + persistAuthSession(createNyxIDServiceSession()); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + guid: 'skill-guid', + name: 'support', + }, + }), + } as Response); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + name: 'another-skill', + files: { 'SKILL.md': 'TEST_ONLY_SECRET' }, + }, + }), + } as Response); + await expect(getChannelSkillServiceContext('support')).rejects.toThrow( + 'Skill identity changed.', + ); +}); it('uses the current user token and private proxy search, preserving shared skills and cursor pagination', async () => { const session = createNyxIDServiceSession(); persistAuthSession(session); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts index d82ec3b6d..e4949b610 100644 --- a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts @@ -16,6 +16,13 @@ export interface ChannelSkillChoice { readonly description: string; } +export interface ChannelSkillServiceContext { + readonly name: string; + readonly description: string; + readonly instructions: string; + readonly linkedServiceSlug: string | null; +} + async function requestSkillData(path: string, signal?: AbortSignal) { const config = getNyxIDRuntimeConfig(); if (config.configurationError || !config.baseUrl) @@ -65,6 +72,45 @@ export async function getChannelSkillById(id: string, signal?: AbortSignal) { return skill; } +// Registrations store a skill name. Resolve that name through Ornn, then read +// its package by the returned GUID; service associations are discovery hints, +// never UserService identities or grants. +export async function getChannelSkillServiceContext( + name: string, + signal?: AbortSignal, +): Promise { + if (!name.trim() || name.length > 128 || name === '.' || name === '..') + throw new Error('Invalid skill name.'); + const detail = await requestSkillData( + `skills/${encodeURIComponent(name)}`, + signal, + ); + const skill = readSkill(detail); + if (skill.name !== name || skill.id === '.' || skill.id === '..') + throw new Error('Invalid skill identity.'); + const data = await requestSkillData( + `skills/${encodeURIComponent(skill.id)}/json`, + signal, + ); + if (readString(data, 'name', 'Skill name') !== name) + throw new Error('Skill identity changed.'); + const files = expectRecord(data.files, 'Skill files'); + const instructions = readString(files, 'SKILL.md', 'Skill instructions'); + if (instructions.length > 200_000) + throw new Error('Skill instructions exceed the discovery limit.'); + return { + name, + description: skill.description, + instructions, + linkedServiceSlug: + readOptionalString( + detail, + 'nyxidServiceSlug', + 'Linked service slug', + )?.trim() || null, + }; +} + export async function searchChannelSkills( search: string, cursor?: string, From ab0b57c6890d09e8b6ab199cd3db89408c877baf Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:53:22 +0800 Subject: [PATCH 2/4] refactor(skills): move service discovery to the backend --- .../2026-09-28-channel-skill-services.md | 98 +++++------- .../channels/ChannelDefaultSkill.test.tsx | 15 +- .../channels/ChannelEditPage.test.tsx | 15 +- .../channels/ChannelSkillServices.test.tsx | 147 ++++++++++++------ .../channels/ChannelSkillServices.tsx | 18 +-- .../channels/skillServiceRecommendations.ts | 70 --------- .../src/shared/api/channelServicesApi.test.ts | 39 ----- .../src/shared/api/channelServicesApi.ts | 61 -------- .../api/channelSkillServicesApi.test.ts | 90 +++++++++++ .../src/shared/api/channelSkillServicesApi.ts | 92 +++++++++++ .../src/shared/api/channelSkillsApi.test.ts | 80 +--------- .../src/shared/api/channelSkillsApi.ts | 46 ------ .../skill-service-recommendations.md | 99 ++++++++++++ .../Aevatar.AI.Abstractions.csproj | 1 + .../Skills/ISkillServiceRecommendations.cs | 20 +++ .../skill_service_recommendations.proto | 56 +++++++ .../SkillServiceRecommendationService.cs | 72 +++++++++ .../NyxIdApiClient.cs | 4 +- .../OrnnSkillServiceDiscoverySource.cs | 129 +++++++++++++++ .../ServiceCollectionExtensions.cs | 4 + .../Hosting/MainnetHostBuilderExtensions.cs | 1 + .../SkillServiceRecommendationEndpoints.cs | 78 ++++++++++ .../SkillServiceRecommendationTests.cs | 142 +++++++++++++++++ ...SkillServiceRecommendationEndpointTests.cs | 82 ++++++++++ 24 files changed, 1029 insertions(+), 430 deletions(-) delete mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts create mode 100644 apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts create mode 100644 apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts create mode 100644 docs/contracts/skill-service-recommendations.md create mode 100644 src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs create mode 100644 src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto create mode 100644 src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs create mode 100644 src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs create mode 100644 src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs create mode 100644 test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs create mode 100644 test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md index 8025d00d1..fa0714687 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md @@ -2,64 +2,52 @@ Issue: https://github.com/aevatarAI/aevatar/issues/3678 -## User behavior - -Channel binding and editing automatically show suggested Services when a Skill -is selected. Each suggestion names its evidence and shows the account's personal -or organization service instances. Selecting an instance adds its exact -UserService ID to the existing form; the normal channel save commits the choice. -Discovery never adds grants, selects services, or removes existing selections. -Changing or clearing the Skill preserves manually selected services. +## Backend-owned discovery -The region distinguishes unselected, selected, no connection, inactive, -unavailable account access, and missing current-session authorization. It does -not claim credential validity, which the inventory endpoint does not expose. -Links open NyxID service management and the existing Account service-access -review in new tabs, preserving unsaved form choices. Refresh suggestions reloads -both discovery and selectable service access after connection or authorization -changes. Pending and failed discovery stays local to this region; manual service -selection remains usable. An empty result does not imply that no services are -needed. Current platform-required services retain their existing behavior. +The console calls `GET /api/skills/service-recommendations?skillName=...` on +Mainnet. The backend owns Skill resolution, service discovery and evidence. +See the [backend contract](../../../../../docs/contracts/skill-service-recommendations.md) +for the response, source contracts, layering and limitations. -## Discovery contracts and limits +The browser does not fetch `SKILL.md`, load the catalog for inference, or match +service names against Skill content. Its API adapter validates the selected +Skill identity, evidence enums and exact UserService instance IDs. A missing or +unsupported backend result produces a retryable discovery state, with the +manual picker still usable; there is no local inference fallback. -- Resolve the registration's Skill name through authenticated Ornn - `GET /api/v1/skills/:idOrName`, then read `GET /api/v1/skills/:guid/json`. - Verify matching names and read only the description, root `SKILL.md`, and - optional `nyxidServiceSlug` association. -- Read NyxID `GET /api/v1/catalog?include_all=true` for service names, slugs and - exact `recommended_skills` names. Read `GET /api/v1/user-services` for account - instances. These are existing external contracts, also used by the backend - Ornn client on `feature/integrate`; no backend changes are required. -- Evidence priority is the Skill's explicit service association, then an exact - catalog recommendation, then whole service-name/slug mentions in the Skill - description or root instructions. Names shorter than three characters are - excluded from text inference. Nested skill references are not traversed. -- None of these sources declares an exhaustive mandatory dependency list. - Suggestions therefore remain advisory; text mentions explicitly say they may - be needed for some tasks. There is no LLM inference or fabricated required- - service schema. Literal matching can miss aliases or identify incidental - mentions; users retain the complete manual picker. -- Skill content is input data, never executed or rendered as instructions. - Reasons show the evidence category without copying private instruction text. - Malformed or mismatched responses produce a retryable discovery failure. -- Slugs associate recommendations with instances; only the existing authorized - service-choice adapter determines selectable UserService IDs. Catalog IDs and - Ornn association IDs never enter the channel authorization payload. -- Queries are scoped by console scope and selected Skill name, consume abort - signals, and do not show the previous Skill's results while a new query runs. - -## Design and validation +## User behavior -Use the existing compact Channels form, typography and color tokens. The inline -suggestion list supports wrapped names, narrow widths, keyboard actions, loading, -empty, error and retry states. English and Chinese messages use existing locales. -The Excalidraw baseline remains unchanged: +Binding and editing show suggestions after a Skill is selected. Reasons name the +backend's evidence category without exposing private instructions. Account +instances show personal/organization source, activity and availability. +The existing authorized-service choices determine which exact IDs are selectable; +backend `allowed` is account access and does not replace current bearer grants. + +Selecting an instance adds its exact UserService ID to the form; normal save +commits it. Discovery never selects, authorizes or removes services. Changing or +clearing the Skill preserves manual choices. Queries are keyed by scope and +Skill name and consume abort signals, so late results do not replace the current +Skill's recommendations. + +No connection, inactive access and missing session authorization offer NyxID +connection management or existing Account service-access review in a new tab. +Refresh reloads backend discovery and selectable access after recovery. +Pending and failed discovery stay within the suggestion region. + +The current backend sources do not declare mandatory dependencies. Suggestions +remain advisory; text mentions explicitly say they may be needed for some tasks. +An empty result does not prove no services are required. Existing platform- +required services keep their own behavior. Inventory is not credential-validity +evidence. + +## Design and verification + +Retain the compact Channels form, design tokens, wrapped names, keyboard actions +and English/Chinese locales. Excalidraw assets remain unchanged: `30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. +Use real authenticated backend responses; mocks exist only in tests. -Route integration coverage exercises evidence, distinct same-slug personal and -organization instances, exact submitted IDs, unavailable access, manual fallback, -late responses, clearing and retry after new authorization. Adapter coverage -protects authenticated package/catalog requests and mismatched Skill identity. -Full frontend typecheck, suite and production build belong to GitHub CI under -the personal incremental-validation policy. +Route integration tests cover rendering backend suggestions, exact instance +selection and save, access gaps, switching/clearing, stale responses and recovery. +API tests cover request encoding and malformed/mismatched results. +Full frontend typecheck, suite and production build are delegated to GitHub CI. diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx index cdf4cc800..637377c05 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx @@ -91,16 +91,13 @@ beforeEach(() => { return response(linkedSkill()); if (String(input).endsWith(`/skills/${secondSkillId}`)) return response(linkedSkill(secondSkillId)); - if (String(input).endsWith('/catalog?include_all=true')) - return response({ entries: [] }); - const name = String(input).match(/\/skills\/([^/]+)$/)?.[1]; - if (name) return response({ data: { guid: `guid-${name}`, name } }); - const packageName = String(input).match( - /\/skills\/guid-([^/]+)\/json$/, - )?.[1]; - if (packageName) + if (String(input).startsWith('/api/skills/service-recommendations?')) return response({ - data: { name: packageName, files: { 'SKILL.md': '' } }, + skillName: new URL( + String(input), + 'https://console.test', + ).searchParams.get('skillName'), + suggestions: [], }); if (String(input).endsWith('/user-services')) return response({ diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx index 161ad7e8b..13bef286e 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx @@ -99,13 +99,14 @@ const selectedServiceIds = [ 'user-service-ornn', ]; function catalogue(input: RequestInfo | URL, services = serviceCatalogue) { - if (String(input).endsWith('/catalog?include_all=true')) - return response({ entries: [] }); - const name = String(input).match(/\/skills\/([^/]+)$/)?.[1]; - if (name) return response({ data: { guid: `guid-${name}`, name } }); - const packageName = String(input).match(/\/skills\/guid-([^/]+)\/json$/)?.[1]; - if (packageName) - return response({ data: { name: packageName, files: { 'SKILL.md': '' } } }); + if (String(input).startsWith('/api/skills/service-recommendations?')) + return response({ + skillName: new URL( + String(input), + 'https://console.test', + ).searchParams.get('skillName'), + suggestions: [], + }); if (String(input).includes('/skill-search')) return response({ data: { diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx index 571d324a4..2aca31440 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx @@ -60,23 +60,99 @@ const registration = { service_ids: ['us-manual', 'us-ornn', 'us-llm'], state_version: 12, }; -const catalog = [ - { slug: 'api-github', name: 'GitHub' }, - { slug: 'slack', name: 'Slack' }, - { slug: 'linear', name: 'Linear' }, - { slug: 'api-drive', name: 'Drive', recommended_skills: ['support'] }, - { slug: 'inactive-service', name: 'Inactive service' }, - { slug: 'owner-service', name: 'Owner service' }, - { slug: 'mail', name: 'Mail' }, -]; +const instance = (id: string, slug: string, label: string) => ({ + id, + slug, + label, + active: true, + allowed: true, + source: 'personal', + organizationName: '', +}); +function suggestionsResponse(skillName = 'support', recovered = false) { + return { + skillName, + suggestions: + skillName === 'no-services' + ? [] + : [ + { + slug: 'api-github', + label: 'GitHub', + evidence: 'linked', + instances: [ + instance('us-personal', 'api-github', 'Personal GitHub'), + { + ...instance('us-org', 'api-github', 'Team GitHub'), + source: 'organization', + organizationName: 'Acme', + }, + ], + }, + { + slug: 'linear', + label: 'Linear', + evidence: 'mention', + instances: [instance('us-linear', 'linear', 'Linear')], + }, + { + slug: 'inactive-service', + label: 'Inactive service', + evidence: 'mention', + instances: [ + { + ...instance( + 'us-inactive', + 'inactive-service', + 'Inactive service', + ), + active: false, + }, + ], + }, + { + slug: 'owner-service', + label: 'Owner service', + evidence: 'mention', + instances: [ + { + ...instance('us-owner', 'owner-service', 'Owner service'), + allowed: false, + source: 'organization', + organizationName: 'Other team', + }, + ], + }, + { + slug: 'api-drive', + label: 'Drive', + evidence: 'catalog', + instances: [], + }, + { + slug: 'slack', + label: 'Slack', + evidence: 'mention', + instances: recovered + ? [instance('us-slack', 'slack', 'Connected Slack')] + : [], + }, + ], + }; +} async function serve(input: RequestInfo | URL, init?: RequestInit) { const url = String(input); if (init?.method === 'POST') return response({ error: 'insecure_webhook_base_url' }, 400); if (url.endsWith('/user-services')) return response({ services: inventory }); - if (url.endsWith('/catalog?include_all=true')) - return response({ entries: catalog }); + if (url.startsWith('/api/skills/service-recommendations?')) + return response( + suggestionsResponse( + new URL(url, 'https://console.test').searchParams.get('skillName') ?? + '', + ), + ); if (url.includes('/skill-search')) return response({ data: { @@ -87,30 +163,6 @@ async function serve(input: RequestInfo | URL, init?: RequestInit) { meta: { hasMore: false }, }, }); - const name = url.match(/\/skills\/([^/]+)$/)?.[1]; - if (name) - return response({ - data: { - guid: `guid-${name}`, - name, - description: 'Task guide', - nyxidServiceSlug: name === 'support' ? 'api-github' : null, - nyxidServiceId: 'catalog-id-is-not-authorization', - }, - }); - const packageName = url.match(/\/skills\/guid-([^/]+)\/json$/)?.[1]; - if (packageName) - return response({ - data: { - name: packageName, - files: { - 'SKILL.md': - packageName === 'no-services' - ? 'No integrations.' - : 'Use Slack, Linear, inactive-service and owner-service. gmail and mail-helper are unrelated. TEST_ONLY_PRIVATE', - }, - }, - }); if (url === '/api/channels/registrations?scope=all') return response([registration]); if (url === '/api/channels/registrations/reg-alpha') @@ -147,7 +199,7 @@ async function chooseSkill(name: string) { const writes = () => fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); -it('discovers evidence and access gaps without granting access, then saves only the explicitly selected UserService instance', async () => { +it('displays backend recommendations and access gaps, then saves only the explicitly selected UserService instance', async () => { renderForm(); await screen.findByRole('button', { name: 'Select Team GitHub' }); const related = within(suggestions()); @@ -161,7 +213,6 @@ it('discovers evidence and access gaps without granting access, then saves only expect(related.getAllByText(/may be needed for some tasks/)).not.toHaveLength( 0, ); - expect(related.queryByText('Mail')).not.toBeInTheDocument(); expect( related.getByText(/Not authorized for this session/), ).toBeInTheDocument(); @@ -177,7 +228,11 @@ it('discovers evidence and access gaps without granting access, then saves only expect( related.getByRole('link', { name: /Review service access/ }), ).toHaveAttribute('href', '/scopes/scope-alpha/settings?section=account'); - expect(document.body).not.toHaveTextContent('TEST_ONLY_PRIVATE'); + expect( + fetchMock.mock.calls.some(([url]) => + /\/skills\/.*\/json|\/catalog\?/.test(String(url)), + ), + ).toBe(false); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); expect(writes()).toHaveLength(0); fireEvent.click(related.getByRole('button', { name: 'Select Team GitHub' })); @@ -203,7 +258,9 @@ it('updates suggestions on skill changes and ignores late results while retainin }); let pendingSignal: AbortSignal | null | undefined; fetchMock.mockImplementation((input, init) => { - if (String(input).endsWith('/skills/guid-slow-skill/json')) { + if ( + String(input).endsWith('/service-recommendations?skillName=slow-skill') + ) { pendingSignal = init?.signal; return pending; } @@ -224,13 +281,7 @@ it('updates suggestions on skill changes and ignores late results while retainin await chooseSkill('no-services'); await screen.findByText(/No related services identified/); expect(pendingSignal?.aborted).toBe(true); - await act(async () => - complete( - response({ - data: { name: 'slow-skill', files: { 'SKILL.md': 'Slack' } }, - }), - ), - ); + await act(async () => complete(response(suggestionsResponse('slow-skill')))); expect( within(suggestions('no-services')).queryByText('Slack'), ).not.toBeInTheDocument(); @@ -250,10 +301,12 @@ it('updates suggestions on skill changes and ignores late results while retainin it('keeps manual selection usable on discovery failure and refreshes connections and authorization on retry', async () => { let recovered = false; fetchMock.mockImplementation((input, init) => { - if (!recovered && String(input).includes('/catalog?')) + if (!recovered && String(input).includes('/service-recommendations?')) return Promise.resolve( response({ message: 'PRIVATE_SERVER_DETAIL' }, 503), ); + if (recovered && String(input).includes('/service-recommendations?')) + return Promise.resolve(response(suggestionsResponse('support', true))); if (recovered && String(input).endsWith('/user-services')) return Promise.resolve( response({ diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx index 88ae7a090..93fb3113c 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx @@ -1,17 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { Button } from 'antd'; import * as React from 'react'; -import { - type ChannelServiceChoice, - listChannelServiceCatalog, - listChannelServiceInventory, -} from '@/shared/api/channelServicesApi'; -import { getChannelSkillServiceContext } from '@/shared/api/channelSkillsApi'; +import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; +import { getChannelSkillServices } from '@/shared/api/channelSkillServicesApi'; import { t } from '@/shared/i18n/messages'; import { AevatarLoadingDots } from '@/shared/ui/AevatarLoading'; import { buildWorkflowActivitySettingsHref } from '../navigation'; import { channelKeys } from './queries'; -import { recommendSkillServices } from './skillServiceRecommendations'; export default function ChannelSkillServices({ scopeId, @@ -37,14 +32,7 @@ export default function ChannelSkillServices({ const titleId = React.useId(); const query = useQuery({ queryKey: channelKeys.skillServices(scopeId, skillName), - queryFn: async ({ signal }) => { - const [skill, catalog, inventory] = await Promise.all([ - getChannelSkillServiceContext(skillName, signal), - listChannelServiceCatalog(signal), - listChannelServiceInventory(signal), - ]); - return recommendSkillServices(skill, catalog, inventory); - }, + queryFn: ({ signal }) => getChannelSkillServices(skillName, signal), enabled: Boolean(scopeId && skillName), retry: false, refetchOnWindowFocus: false, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts deleted file mode 100644 index 56bbe60d6..000000000 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/skillServiceRecommendations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - ChannelServiceCatalogEntry, - ChannelServiceChoice, -} from '@/shared/api/channelServicesApi'; -import type { ChannelSkillServiceContext } from '@/shared/api/channelSkillsApi'; - -export interface SkillServiceRecommendation { - readonly slug: string; - readonly label: string; - readonly evidence: 'linked' | 'catalog' | 'mention'; - readonly instances: readonly ChannelServiceChoice[]; -} - -function mentions(text: string, term: string) { - // Match whole names/slugs, including those in URLs, but not substrings of - // other hyphenated names. Free text is always advisory. - if (term.trim().length < 3) return false; - const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp( - `(? [entry.slug, entry])); - for (const service of inventory) { - if (!candidates.has(service.slug)) - candidates.set(service.slug, { - slug: service.slug, - name: service.label, - recommendedSkills: [], - }); - } - if (skill.linkedServiceSlug && !candidates.has(skill.linkedServiceSlug)) - candidates.set(skill.linkedServiceSlug, { - slug: skill.linkedServiceSlug, - name: skill.linkedServiceSlug, - recommendedSkills: [], - }); - const text = `${skill.description}\n${skill.instructions}`; - return [...candidates.values()].flatMap( - (entry) => { - const evidence = - entry.slug === skill.linkedServiceSlug - ? 'linked' - : entry.recommendedSkills.includes(skill.name) - ? 'catalog' - : mentions(text, entry.slug) || mentions(text, entry.name) - ? 'mention' - : null; - return evidence - ? [ - { - slug: entry.slug, - label: entry.name, - evidence, - instances: inventory.filter( - (service) => service.slug === entry.slug, - ), - }, - ] - : []; - }, - ); -} diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts index 3325e4f3c..f7d7ed480 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts @@ -2,7 +2,6 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; import { - listChannelServiceCatalog, listChannelServiceIdentities, listChannelServices, } from './channelServicesApi'; @@ -29,44 +28,6 @@ const personal = { const response = (value: unknown, status = 200) => ({ ok: status === 200, status, json: async () => value }) as Response; -it('reads unconnected catalog candidates with current credentials without exposing catalog IDs or secrets as choices', async () => { - const session = createNyxIDServiceSession({ allowed_service_ids: [] }); - persistAuthSession(session); - fetchMock.mockResolvedValue( - response({ - entries: [ - { - id: 'catalog-github', - slug: 'api-github', - name: 'GitHub', - recommended_skills: ['support'], - api_key: 'TEST_ONLY_SECRET', - }, - ], - }), - ); - const signal = new AbortController().signal; - expect(await listChannelServiceCatalog(signal)).toEqual([ - { - slug: 'api-github', - name: 'GitHub', - recommendedSkills: ['support'], - }, - ]); - expect(fetchMock).toHaveBeenCalledWith( - 'https://nyx.example.test/api/v1/catalog?include_all=true', - { - signal, - credentials: 'omit', - cache: 'no-store', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${session.tokens.accessToken}`, - }, - }, - ); -}); - it('reads safe service names independently of current selectable grants and activity', async () => { persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: [] })); fetchMock.mockResolvedValue( diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts index d7c3fecbb..79567bc4c 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts @@ -26,67 +26,6 @@ export type ChannelServiceIdentity = Pick< 'id' | 'slug' | 'label' >; -export interface ChannelServiceCatalogEntry { - readonly slug: string; - readonly name: string; - readonly recommendedSkills: readonly string[]; -} - -// Discovery may show unavailable services, but only listChannelServices owns -// the selectable IDs admitted by the current bearer. -export async function listChannelServiceInventory(signal?: AbortSignal) { - const session = await ensureActiveAuthSession(); - if (!session) throw new ChannelApiError(401); - return readChannelServiceInventory(session.tokens.accessToken, signal); -} - -export async function listChannelServiceCatalog( - signal?: AbortSignal, -): Promise { - const config = getNyxIDRuntimeConfig(); - if (config.configurationError || !config.baseUrl) - throw new Error('NyxID is unavailable.'); - const session = await ensureActiveAuthSession(); - if (!session) throw new ChannelApiError(401); - const response = await authFetch( - `${config.baseUrl}/api/v1/catalog?include_all=true`, - { - signal, - credentials: 'omit', - cache: 'no-store', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${session.tokens.accessToken}`, - }, - }, - ); - if (!response.ok) throw new ChannelApiError(response.status); - const body = expectRecord(await response.json(), 'Service catalog'); - return expectArray(body.entries, 'Service catalog entries', (value) => { - const row = expectRecord(value, 'Service catalog entry'); - const slug = readString(row, 'slug', 'Catalog slug'); - const name = readString(row, 'name', 'Catalog name'); - if (!slug.trim() || !name.trim()) - throw new Error('Missing catalog identity.'); - return { - slug, - name, - recommendedSkills: - row.recommended_skills == null - ? [] - : expectArray( - row.recommended_skills, - 'Recommended skills', - (item) => { - if (typeof item !== 'string') - throw new Error('Invalid recommended skill.'); - return item; - }, - ), - }; - }); -} - function decodeService(value: unknown): ChannelServiceChoice { const row = expectRecord(value, 'User service'); const source = expectRecord(row.credential_source, 'Credential source'); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts new file mode 100644 index 000000000..1a26aa62a --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts @@ -0,0 +1,90 @@ +import { authFetch } from '@/shared/auth/fetch'; +import { getChannelSkillServices } from './channelSkillServicesApi'; + +jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); +const fetchMock = jest.mocked(authFetch); +const response = (value: unknown, status = 200) => + ({ ok: status === 200, status, json: async () => value }) as Response; + +afterEach(() => fetchMock.mockReset()); + +it('reads the backend contract with an encoded skill name and preserves exact instance identity', async () => { + fetchMock.mockResolvedValue( + response({ + skillName: 'team+docs', + suggestions: [ + { + slug: 'api-github', + label: 'GitHub', + evidence: 'catalog', + instances: [ + { + id: 'us-team', + slug: 'api-github', + label: 'Team GitHub', + active: true, + allowed: false, + source: 'organization', + organizationName: 'Acme', + }, + ], + }, + ], + }), + ); + const signal = new AbortController().signal; + expect(await getChannelSkillServices('team+docs', signal)).toEqual([ + { + slug: 'api-github', + label: 'GitHub', + evidence: 'catalog', + instances: [ + { + id: 'us-team', + slug: 'api-github', + label: 'Team GitHub', + active: true, + allowed: false, + source: 'organization', + organizationName: 'Acme', + }, + ], + }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + '/api/skills/service-recommendations?skillName=team%2Bdocs', + { + signal, + cache: 'no-store', + }, + ); +}); + +it('rejects mismatched and unsupported results and never surfaces upstream error contents', async () => { + fetchMock.mockResolvedValueOnce( + response({ skillName: 'another-skill', suggestions: [] }), + ); + await expect(getChannelSkillServices('support')).rejects.toThrow( + 'Mismatched skill recommendations.', + ); + fetchMock.mockResolvedValueOnce( + response({ + skillName: 'support', + suggestions: [ + { + slug: 'github', + label: 'GitHub', + evidence: 'new-unknown-kind', + instances: [], + }, + ], + }), + ); + await expect(getChannelSkillServices('support')).rejects.toThrow( + 'Invalid service suggestion.', + ); + fetchMock.mockResolvedValueOnce(response({ error: 'TEST_ONLY_SECRET' }, 502)); + await expect(getChannelSkillServices('support')).rejects.toThrow( + 'Channel request failed (502).', + ); +}); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts new file mode 100644 index 000000000..afe7c9ef3 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts @@ -0,0 +1,92 @@ +import { authFetch } from '@/shared/auth/fetch'; +import type { ChannelServiceChoice } from './channelServicesApi'; +import { ChannelApiError } from './channelsApi'; +import { + expectArray, + expectBoolean, + expectRecord, + readString, +} from './http/decoders'; + +export interface SkillServiceRecommendation { + readonly slug: string; + readonly label: string; + readonly evidence: 'linked' | 'catalog' | 'mention'; + readonly instances: readonly ChannelServiceChoice[]; +} + +export async function getChannelSkillServices( + skillName: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ skillName }); + const response = await authFetch( + `/api/skills/service-recommendations?${params}`, + { + signal, + cache: 'no-store', + }, + ); + if (!response.ok) throw new ChannelApiError(response.status); + const body = expectRecord( + await response.json(), + 'Skill service recommendations', + ); + if (body.skillName !== skillName) + throw new Error('Mismatched skill recommendations.'); + const suggestions = expectArray( + body.suggestions, + 'Service suggestions', + (value) => { + const item = expectRecord(value, 'Service suggestion'); + const slug = readString(item, 'slug', 'Service slug'); + const label = readString(item, 'label', 'Service label'); + const evidence = item.evidence; + if ( + !slug.trim() || + !label.trim() || + (evidence !== 'linked' && + evidence !== 'catalog' && + evidence !== 'mention') + ) + throw new Error('Invalid service suggestion.'); + const instances = expectArray( + item.instances, + 'Service instances', + (entry): ChannelServiceChoice => { + const instance = expectRecord(entry, 'Service instance'); + const id = readString(instance, 'id', 'UserService ID'); + const source = instance.source; + if ( + !id.trim() || + instance.slug !== slug || + (source !== 'personal' && + source !== 'organization' && + source !== 'unknown') + ) + throw new Error('Invalid service instance.'); + return { + id, + slug, + source, + label: readString(instance, 'label', 'Instance label'), + active: expectBoolean(instance.active, 'Service activity'), + allowed: expectBoolean(instance.allowed, 'Account access'), + organizationName: + readString(instance, 'organizationName', 'Organization name') || + null, + }; + }, + ); + if ( + new Set(instances.map((instance) => instance.id)).size !== + instances.length + ) + throw new Error('Ambiguous service instances.'); + return { slug, label, evidence, instances }; + }, + ); + if (new Set(suggestions.map((item) => item.slug)).size !== suggestions.length) + throw new Error('Ambiguous service suggestions.'); + return suggestions; +} diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts index 928269d66..56e417fba 100644 --- a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.test.ts @@ -1,11 +1,7 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; -import { - getChannelSkillById, - getChannelSkillServiceContext, - searchChannelSkills, -} from './channelSkillsApi'; +import { getChannelSkillById, searchChannelSkills } from './channelSkillsApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); jest.mock('@/shared/auth/config', () => ({ @@ -15,80 +11,6 @@ jest.mock('@/shared/auth/config', () => ({ }), })); const fetchMock = jest.mocked(authFetch); - -it('resolves the selected name and reads its exact package with authenticated, abortable requests', async () => { - fetchMock.mockReset(); - const session = createNyxIDServiceSession(); - persistAuthSession(session); - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - guid: 'guid+one', - name: 'team+docs', - description: 'Documentation', - nyxidServiceSlug: 'api-github', - nyxidServiceId: 'catalog-id', - }, - }), - } as Response); - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - name: 'team+docs', - files: { 'SKILL.md': 'Use GitHub.', 'private.txt': 'TEST_ONLY_SECRET' }, - }, - }), - } as Response); - const signal = new AbortController().signal; - expect(await getChannelSkillServiceContext('team+docs', signal)).toEqual({ - name: 'team+docs', - description: 'Documentation', - instructions: 'Use GitHub.', - linkedServiceSlug: 'api-github', - }); - expect( - fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname), - ).toEqual([ - '/api/v1/proxy/s/ornn-api/api/v1/skills/team%2Bdocs', - '/api/v1/proxy/s/ornn-api/api/v1/skills/guid%2Bone/json', - ]); - for (const [, init] of fetchMock.mock.calls) { - expect(init).toMatchObject({ - credentials: 'omit', - cache: 'no-store', - headers: { Authorization: `Bearer ${session.tokens.accessToken}` }, - }); - expect(init?.signal).toBe(signal); - } -}); - -it('rejects another skill package instead of deriving recommendations from mismatched content', async () => { - fetchMock.mockReset(); - persistAuthSession(createNyxIDServiceSession()); - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - guid: 'skill-guid', - name: 'support', - }, - }), - } as Response); - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - name: 'another-skill', - files: { 'SKILL.md': 'TEST_ONLY_SECRET' }, - }, - }), - } as Response); - await expect(getChannelSkillServiceContext('support')).rejects.toThrow( - 'Skill identity changed.', - ); -}); it('uses the current user token and private proxy search, preserving shared skills and cursor pagination', async () => { const session = createNyxIDServiceSession(); persistAuthSession(session); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts index e4949b610..d82ec3b6d 100644 --- a/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelSkillsApi.ts @@ -16,13 +16,6 @@ export interface ChannelSkillChoice { readonly description: string; } -export interface ChannelSkillServiceContext { - readonly name: string; - readonly description: string; - readonly instructions: string; - readonly linkedServiceSlug: string | null; -} - async function requestSkillData(path: string, signal?: AbortSignal) { const config = getNyxIDRuntimeConfig(); if (config.configurationError || !config.baseUrl) @@ -72,45 +65,6 @@ export async function getChannelSkillById(id: string, signal?: AbortSignal) { return skill; } -// Registrations store a skill name. Resolve that name through Ornn, then read -// its package by the returned GUID; service associations are discovery hints, -// never UserService identities or grants. -export async function getChannelSkillServiceContext( - name: string, - signal?: AbortSignal, -): Promise { - if (!name.trim() || name.length > 128 || name === '.' || name === '..') - throw new Error('Invalid skill name.'); - const detail = await requestSkillData( - `skills/${encodeURIComponent(name)}`, - signal, - ); - const skill = readSkill(detail); - if (skill.name !== name || skill.id === '.' || skill.id === '..') - throw new Error('Invalid skill identity.'); - const data = await requestSkillData( - `skills/${encodeURIComponent(skill.id)}/json`, - signal, - ); - if (readString(data, 'name', 'Skill name') !== name) - throw new Error('Skill identity changed.'); - const files = expectRecord(data.files, 'Skill files'); - const instructions = readString(files, 'SKILL.md', 'Skill instructions'); - if (instructions.length > 200_000) - throw new Error('Skill instructions exceed the discovery limit.'); - return { - name, - description: skill.description, - instructions, - linkedServiceSlug: - readOptionalString( - detail, - 'nyxidServiceSlug', - 'Linked service slug', - )?.trim() || null, - }; -} - export async function searchChannelSkills( search: string, cursor?: string, diff --git a/docs/contracts/skill-service-recommendations.md b/docs/contracts/skill-service-recommendations.md new file mode 100644 index 000000000..4406b7aa0 --- /dev/null +++ b/docs/contracts/skill-service-recommendations.md @@ -0,0 +1,99 @@ +# Skill service recommendations + +Skill-to-service discovery is backend behavior shared by all clients. Channels +consumes the result and owns only display, selection and explicit save. Clients +must not download Skill instructions or recreate dependency inference. + +## Endpoint + +`GET /api/skills/service-recommendations?skillName=` requires an +authenticated caller and their NyxID bearer credential. It is served by Mainnet +and returns `Cache-Control: no-store`. The name is the same Skill name stored by +channel registration, limited to 128 characters and excluding dot path segments. + +```json +{ + "skillName": "support", + "suggestions": [{ + "slug": "api-github", + "label": "GitHub", + "evidence": "linked", + "instances": [{ + "id": "user-service-example", + "slug": "api-github", + "label": "Team GitHub", + "active": true, + "allowed": true, + "source": "organization", + "organizationName": "Example team" + }] + }] +} +``` + +`evidence` is `linked`, `catalog`, or `mention`. These are advisory sources, +not required/optional dependency declarations. Empty `instances` means no +matching connection in the caller's account inventory. `allowed` reports +account-level access only; it does not grant access to the current session or +channel. `source` is `personal`, `organization`, or `unknown`. Existing +channel authorization still uses explicitly selected exact UserService IDs. +Catalog IDs and Skill association IDs must never become authorization IDs. +Inventory availability is not credential-validity evidence. + +Successful empty discovery returns an empty `suggestions` array. Missing +authentication is 401; malformed names return 400 with +`{"code":"invalid_skill_name"}`; inaccessible, malformed, mismatched or +unavailable upstream data returns 502 with +`{"code":"skill_service_discovery_unavailable"}`. Failures never become +successful empty or partial recommendations and never expose upstream errors, +private instructions, file contents, or credentials. Cancellation propagates. + +## Ownership and source contracts + +- `Aevatar.AI.Abstractions.Skills`: protobuf input/output, evidence and instance + contracts; caller credential parameters remain outside serialized data. +- `Aevatar.AI.Core.Skills.SkillServiceRecommendationService`: stateless discovery + policy behind `ISkillServiceDiscoverySource`. No Host/HTTP dependency. +- `Aevatar.AI.ToolProviders.Ornn.OrnnSkillServiceDiscoverySource`: external + adaptation through existing NyxID/Ornn clients. JSON is decoded at this boundary. +- `Aevatar.Mainnet.Host.Api.Skills.SkillServiceRecommendationEndpoints`: + authentication, HTTP result/error mapping, and response DTOs only. +- Console `channelSkillServicesApi`: response validation only; the UI cannot + invent recommendations or evidence categories. + +The adapter resolves the selected name using Ornn `GET /api/v1/skills/:name`, +verifies identity, then loads `GET /api/v1/skills/:guid/json`. It reads the +description, root `SKILL.md`, and optional `nyxidServiceSlug`. It reads NyxID +`GET /api/v1/catalog?include_all=true` and `GET /api/v1/user-services` using +the same invocation's caller credential. Existing source contracts were +compared with `feature/integrate`; no external-product changes are required. +The adapter uses the configured Ornn per-call timeout as the discovery budget, +and rejects root instructions above 200,000 characters. + +Evidence priority is explicit Skill association, then exact catalog +`recommended_skills` association, then whole service-name/slug mention in the +description or root instructions. Names shorter than three characters are +excluded from text inference. Catalog and inventory candidates are combined; +all exact same-slug instances remain distinct. Skill instructions are data, +never executed. No LLM inference, recursive Skill loading, grant mutation, +credential creation, or channel update occurs during discovery. + +These APIs provide no exhaustive mandatory-dependency contract. Literal matching +can miss aliases or include incidental mentions. Every result is advisory and +may be incomplete. Adding authoritative required dependencies later must extend +the typed contract and source evidence, rather than infer a requirement from +text. Platform-required services retain their current separate contract. + +All collections are invocation-local. There is no shared credential/result +cache, actor state, persisted derived dependency list, event replay, projection +priming or query-time materialization. This is transient discovery over external +caller-visible catalog facts, not a new authority for channel configuration. + +## Verification + +Provider integration tests exercise real policy and adapters against an HTTP +boundary: evidence precedence, bounded name matching, distinct instance IDs, +sanitized failures, identity mismatches, cancellation and caller isolation. +Host tests cover response mapping, authentication, validation, cancellation +forwarding and retryable errors. Console tests cover API decoding, stale-response +isolation, manual selection, exact save IDs and recovery. diff --git a/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj b/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj index 4fed2ecbf..41189f117 100644 --- a/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj +++ b/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj @@ -22,5 +22,6 @@ + diff --git a/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs b/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs new file mode 100644 index 000000000..412723a45 --- /dev/null +++ b/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs @@ -0,0 +1,20 @@ +namespace Aevatar.AI.Abstractions.Skills; + +// Caller credentials are per invocation, never retained in service state or protobuf data. +public interface ISkillServiceDiscoverySource +{ + Task ReadAsync( + string accessToken, string skillName, CancellationToken ct = default); +} + +public interface ISkillServiceRecommendationService +{ + Task RecommendAsync( + string accessToken, string skillName, CancellationToken ct = default); +} + +public sealed class SkillServiceDiscoveryException : Exception +{ + public SkillServiceDiscoveryException() + : base("Skill service discovery is unavailable.") { } +} diff --git a/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto b/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto new file mode 100644 index 000000000..6a5d016ec --- /dev/null +++ b/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package aevatar.ai.skills; +option csharp_namespace = "Aevatar.AI.Abstractions.Skills"; + +// Advisory discovery evidence; none of these sources declares a mandatory dependency. +enum SkillServiceEvidence { + SKILL_SERVICE_EVIDENCE_UNSPECIFIED = 0; + SKILL_SERVICE_EVIDENCE_LINKED = 1; + SKILL_SERVICE_EVIDENCE_CATALOG = 2; + SKILL_SERVICE_EVIDENCE_MENTION = 3; +} + +enum SkillServiceCredentialSource { + SKILL_SERVICE_CREDENTIAL_SOURCE_UNSPECIFIED = 0; + SKILL_SERVICE_CREDENTIAL_SOURCE_PERSONAL = 1; + SKILL_SERVICE_CREDENTIAL_SOURCE_ORGANIZATION = 2; +} + +message SkillServiceInstance { + string user_service_id = 1; + string slug = 2; + string label = 3; + bool active = 4; + bool account_access_allowed = 5; + SkillServiceCredentialSource credential_source = 6; + string organization_name = 7; +} + +message SkillServiceCatalogEntry { + string slug = 1; + string name = 2; + repeated string recommended_skill_names = 3; +} + +// Transient typed input from external adapters. Never persisted or returned to clients. +message SkillServiceDiscoveryInput { + string skill_name = 1; + string description = 2; + string instructions = 3; + string linked_service_slug = 4; + repeated SkillServiceCatalogEntry catalog = 5; + repeated SkillServiceInstance instances = 6; +} + +message SkillServiceRecommendation { + string slug = 1; + string label = 2; + SkillServiceEvidence evidence = 3; + repeated SkillServiceInstance instances = 4; +} + +message SkillServiceRecommendations { + string skill_name = 1; + repeated SkillServiceRecommendation suggestions = 2; +} diff --git a/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs b/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs new file mode 100644 index 000000000..854cd1632 --- /dev/null +++ b/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs @@ -0,0 +1,72 @@ +using System.Text.RegularExpressions; +using Aevatar.AI.Abstractions.Skills; + +namespace Aevatar.AI.Core.Skills; + +// Stateless discovery over caller-visible external facts. Suggestions are not grants +// or persisted dependencies; channel authorization still requires explicit exact IDs. +public sealed class SkillServiceRecommendationService(ISkillServiceDiscoverySource source) + : ISkillServiceRecommendationService +{ + public async Task RecommendAsync( + string accessToken, string skillName, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(accessToken)) + throw new ArgumentException("Caller credentials are required.", nameof(accessToken)); + if (string.IsNullOrWhiteSpace(skillName) || skillName.Length > 128 || + skillName != skillName.Trim() || skillName is "." or "..") + throw new ArgumentException("Invalid skill name.", nameof(skillName)); + + var input = await source.ReadAsync(accessToken, skillName, ct); + if (!string.Equals(input.SkillName, skillName, StringComparison.Ordinal)) + throw new SkillServiceDiscoveryException(); + + var candidates = input.Catalog.ToDictionary(entry => entry.Slug, StringComparer.Ordinal); + foreach (var instance in input.Instances) + candidates.TryAdd(instance.Slug, new SkillServiceCatalogEntry + { + Slug = instance.Slug, + Name = instance.Label, + }); + if (input.LinkedServiceSlug.Length > 0) + candidates.TryAdd(input.LinkedServiceSlug, new SkillServiceCatalogEntry + { + Slug = input.LinkedServiceSlug, + Name = input.LinkedServiceSlug, + }); + + var result = new SkillServiceRecommendations { SkillName = skillName }; + var text = input.Description + "\n" + input.Instructions; + foreach (var entry in candidates.Values) + { + ct.ThrowIfCancellationRequested(); + var evidence = entry.Slug == input.LinkedServiceSlug + ? SkillServiceEvidence.Linked + : entry.RecommendedSkillNames.Contains(skillName) + ? SkillServiceEvidence.Catalog + : Mentions(text, entry.Slug) || Mentions(text, entry.Name) + ? SkillServiceEvidence.Mention + : SkillServiceEvidence.Unspecified; + if (evidence == SkillServiceEvidence.Unspecified) + continue; + var suggestion = new SkillServiceRecommendation + { + Slug = entry.Slug, + Label = entry.Name, + Evidence = evidence, + }; + suggestion.Instances.Add(input.Instances + .Where(instance => instance.Slug == entry.Slug) + .Select(instance => instance.Clone())); + result.Suggestions.Add(suggestion); + } + return result; + } + + private static bool Mentions(string text, string term) => + term.Trim().Length >= 3 && Regex.IsMatch( + text, + @"(? SearchAdminUsersAsync(string token, string email, Cancellati // ─── Catalog ─── - public Task ListCatalogAsync(string token, CancellationToken ct) => - GetAsync(token, "/api/v1/catalog", ct); + public Task ListCatalogAsync(string token, CancellationToken ct, bool includeAll = false) => + GetAsync(token, includeAll ? "/api/v1/catalog?include_all=true" : "/api/v1/catalog", ct); public Task GetCatalogEntryAsync(string token, string slug, CancellationToken ct) => GetAsync(token, $"/api/v1/catalog/{Uri.EscapeDataString(slug)}", ct); diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs new file mode 100644 index 000000000..e0ecf7a13 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.Skills; +using Aevatar.AI.ToolProviders.NyxId; +using Microsoft.Extensions.Logging; + +namespace Aevatar.AI.ToolProviders.Ornn; + +// External JSON is decoded only at this adapter boundary. The policy consumes +// protobuf values and never sees raw error bodies or arbitrary files. Credentials +// pass through the service as invocation parameters only. +public sealed class OrnnSkillServiceDiscoverySource( + OrnnSkillClient skills, + NyxIdApiClient nyx, + OrnnOptions options, + ILogger logger) : ISkillServiceDiscoverySource +{ + public async Task ReadAsync( + string accessToken, string skillName, CancellationToken ct = default) + { + using var timeout = new CancellationTokenSource(options.PerCallTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + try + { + using var detail = Parse(await nyx.ProxyRequestAsync( + accessToken, options.NyxIdSlug, + $"/api/v1/skills/{Uri.EscapeDataString(skillName)}", "GET", + null, null, linked.Token)); + var data = detail.RootElement.GetProperty("data"); + var guid = RequiredString(data, "guid"); + if (guid is "." or ".." || RequiredString(data, "name") != skillName) + throw new SkillServiceDiscoveryException(); + var package = await skills.GetSkillJsonAsync(accessToken, guid, linked.Token); + if (package?.Name != skillName || + package.Files is null || !package.Files.TryGetValue("SKILL.md", out var instructions) || + instructions is null || instructions.Length > 200_000) + throw new SkillServiceDiscoveryException(); + + using var catalog = Parse(await nyx.ListCatalogAsync(accessToken, linked.Token, includeAll: true)); + var inventory = NyxIdApiAccessResponseParser.ParseUserServices( + await nyx.ListUserServicesAsync(accessToken, linked.Token)); + if (!inventory.Succeeded) + throw new SkillServiceDiscoveryException(); + + var result = new SkillServiceDiscoveryInput + { + SkillName = skillName, + Description = OptionalString(data, "description"), + Instructions = instructions, + LinkedServiceSlug = OptionalString(data, "nyxidServiceSlug").Trim(), + }; + var slugs = new HashSet(StringComparer.Ordinal); + foreach (var entry in catalog.RootElement.GetProperty("entries").EnumerateArray()) + { + var candidate = new SkillServiceCatalogEntry + { + Slug = RequiredString(entry, "slug"), + Name = RequiredString(entry, "name"), + }; + if (!slugs.Add(candidate.Slug)) + throw new SkillServiceDiscoveryException(); + if (entry.TryGetProperty("recommended_skills", out var recommended) && + recommended.ValueKind != JsonValueKind.Null) + foreach (var name in recommended.EnumerateArray()) + candidate.RecommendedSkillNames.Add(name.GetString() + ?? throw new SkillServiceDiscoveryException()); + result.Catalog.Add(candidate); + } + foreach (var service in inventory.Value!.Services) + { + var source = service.CredentialSource; + result.Instances.Add(new SkillServiceInstance + { + UserServiceId = service.Id, + Slug = service.Slug, + Label = FirstLabel(service.Label, service.CatalogServiceName, service.Slug), + Active = service.IsActive, + AccountAccessAllowed = source.Kind == NyxIdUserServiceCredentialSourceKind.Personal || + source.Kind == NyxIdUserServiceCredentialSourceKind.Organization && source.Allowed, + CredentialSource = source.Kind switch + { + NyxIdUserServiceCredentialSourceKind.Personal => SkillServiceCredentialSource.Personal, + NyxIdUserServiceCredentialSourceKind.Organization => SkillServiceCredentialSource.Organization, + _ => SkillServiceCredentialSource.Unspecified, + }, + OrganizationName = source.OrganizationName ?? string.Empty, + }); + } + return result; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Do not log upstream messages, credential material, or private instructions. + logger.LogWarning("Skill service discovery failed ({FailureType})", ex.GetType().Name); + throw new SkillServiceDiscoveryException(); + } + } + + private static JsonDocument Parse(string response) + { + var document = JsonDocument.Parse(response); + if (document.RootElement.ValueKind == JsonValueKind.Object && + (!document.RootElement.TryGetProperty("error", out var error) || + error.ValueKind is JsonValueKind.Null or JsonValueKind.False)) + return document; + document.Dispose(); + throw new SkillServiceDiscoveryException(); + } + + private static string RequiredString(JsonElement row, string property) + { + var value = row.GetProperty(property).GetString(); + return !string.IsNullOrWhiteSpace(value) && value == value.Trim() + ? value + : throw new SkillServiceDiscoveryException(); + } + + private static string OptionalString(JsonElement row, string property) => + !row.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null + ? string.Empty + : value.GetString() ?? string.Empty; + + private static string FirstLabel(string? label, string? catalogName, string slug) => + !string.IsNullOrWhiteSpace(label) ? label.Trim() + : !string.IsNullOrWhiteSpace(catalogName) ? catalogName.Trim() : slug; +} diff --git a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs index f9e690c40..722e18b44 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ +using Aevatar.AI.Abstractions.Skills; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.Core.AgentProfiles; +using Aevatar.AI.Core.Skills; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.Ornn.Publishing; using Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay; @@ -68,6 +70,8 @@ public static IServiceCollection AddOrnnSkillClient( sp.GetService>()); }); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(sp => sp.GetRequiredService()); return services; diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index c95eaa653..385fad111 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -714,6 +714,7 @@ public static WebApplication MapAevatarMainnetHost(this WebApplication app) app.MapProjectionVersionRegressionRepairAdminEndpoints(); app.MapManagedCodexCredentialEndpoints(); app.MapWorkflowSkillsEndpoints(); + app.MapSkillServiceRecommendations(); app.MapStatusEndpoints(); // Voice service registration is conditional on a configured provider diff --git a/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs b/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs new file mode 100644 index 000000000..a71db6ec7 --- /dev/null +++ b/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs @@ -0,0 +1,78 @@ +using Aevatar.AI.Abstractions.Skills; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace Aevatar.Mainnet.Host.Api.Skills; + +internal static class SkillServiceRecommendationEndpoints +{ + public static IEndpointRouteBuilder MapSkillServiceRecommendations(this IEndpointRouteBuilder app) + { + app.MapGet("/api/skills/service-recommendations", GetRecommendations) + .WithTags("Skills") + .WithName("GetSkillServiceRecommendations") + .WithSummary("Discover advisory services for a caller-visible Skill without changing authorization.") + .RequireAuthorization(); + return app; + } + + internal static async Task GetRecommendations( + HttpContext http, + [FromServices] ISkillServiceRecommendationService service, + string skillName, + CancellationToken ct = default) + { + var authorization = http.Request.Headers.Authorization.ToString(); + const string prefix = "Bearer "; + if (http.User.Identity?.IsAuthenticated != true) + return Results.Unauthorized(); + if (!authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(authorization[prefix.Length..])) + return Results.Unauthorized(); + http.Response.Headers.CacheControl = "no-store"; + try + { + var result = await service.RecommendAsync(authorization[prefix.Length..].Trim(), skillName, ct); + return Results.Json(new SkillServiceRecommendationsResponse( + result.SkillName, + result.Suggestions.Select(item => new SkillServiceSuggestionResponse( + item.Slug, item.Label, + item.Evidence switch + { + SkillServiceEvidence.Linked => "linked", + SkillServiceEvidence.Catalog => "catalog", + SkillServiceEvidence.Mention => "mention", + _ => throw new SkillServiceDiscoveryException(), + }, + item.Instances.Select(instance => new SkillServiceInstanceResponse( + instance.UserServiceId, instance.Slug, instance.Label, + instance.Active, instance.AccountAccessAllowed, + instance.CredentialSource switch + { + SkillServiceCredentialSource.Personal => "personal", + SkillServiceCredentialSource.Organization => "organization", + _ => "unknown", + }, + instance.OrganizationName)).ToArray())).ToArray())); + } + catch (ArgumentException) + { + return Results.BadRequest(new SkillServiceRecommendationError("invalid_skill_name")); + } + catch (SkillServiceDiscoveryException) + { + return Results.Json(new SkillServiceRecommendationError("skill_service_discovery_unavailable"), + statusCode: StatusCodes.Status502BadGateway); + } + } +} + +public sealed record SkillServiceRecommendationsResponse( + string SkillName, IReadOnlyList Suggestions); +public sealed record SkillServiceSuggestionResponse( + string Slug, string Label, string Evidence, IReadOnlyList Instances); +public sealed record SkillServiceInstanceResponse( + string Id, string Slug, string Label, bool Active, bool Allowed, string Source, string OrganizationName); +public sealed record SkillServiceRecommendationError(string Code); diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs new file mode 100644 index 000000000..139b4d5f4 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using Aevatar.AI.Abstractions.Skills; +using Aevatar.AI.Core.Skills; +using Aevatar.AI.ToolProviders.NyxId; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.AI.ToolProviders.Ornn.Tests; + +public sealed class SkillServiceRecommendationTests +{ + private const string Detail = """ + {"data":{"guid":"guid+support","name":"support","description":"Use Slack for updates.", + "nyxidServiceSlug":"api-github","nyxidServiceId":"catalog-not-a-user-service"}} + """; + private const string Package = """ + {"data":{"name":"support","files":{"SKILL.md":"Use Linear. gmail and mail-helper are different. TEST_ONLY_PRIVATE", + "private.txt":"TEST_ONLY_SECRET"}}} + """; + private const string Catalog = """ + {"entries":[ + {"slug":"api-github","name":"GitHub"}, + {"slug":"slack","name":"Slack"}, + {"slug":"linear","name":"Linear"}, + {"slug":"drive","name":"Drive","recommended_skills":["support"]}, + {"slug":"mail","name":"Mail"}, + {"slug":"unused","name":"Unused","recommended_skills":["support-extra"]} + ]} + """; + private const string Inventory = """ + {"services":[ + {"id":"us-personal","slug":"api-github","label":"Personal GitHub","is_active":true, + "credential_source":{"type":"personal"},"api_key":"TEST_ONLY_SECRET"}, + {"id":"us-org","slug":"api-github","label":"Team GitHub","is_active":false, + "credential_source":{"type":"org","org_id":"org-1","org_name":"Acme","role":"viewer","allowed":false}} + ]} + """; + + [Fact] + public async Task RegisteredService_DiscoversEvidenceAndExactInstances_WithoutLeakingInputsOrMutatingGrants() + { + var handler = Responses(Detail, Package, Catalog, Inventory); + using var http = new HttpClient(handler); + var nyx = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, http); + var services = new ServiceCollection(); + services.AddSingleton(nyx); + services.AddSingleton>(NullLogger.Instance); + services.AddOrnnSkillClient(); + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + + var result = await service.RecommendAsync("caller-token", "support"); + + result.SkillName.Should().Be("support"); + result.Suggestions.Select(item => (item.Slug, item.Evidence)).Should().Equal( + ("api-github", SkillServiceEvidence.Linked), + ("slack", SkillServiceEvidence.Mention), + ("linear", SkillServiceEvidence.Mention), + ("drive", SkillServiceEvidence.Catalog)); + var instances = result.Suggestions[0].Instances; + instances.Select(item => item.UserServiceId).Should().Equal("us-personal", "us-org"); + instances[0].CredentialSource.Should().Be(SkillServiceCredentialSource.Personal); + instances[1].CredentialSource.Should().Be(SkillServiceCredentialSource.Organization); + instances[1].Active.Should().BeFalse(); + instances[1].AccountAccessAllowed.Should().BeFalse(); + result.Suggestions[1].Instances.Should().BeEmpty(); + result.ToString().Should().NotContain("TEST_ONLY").And.NotContain("catalog-not-a-user-service"); + handler.Requests.Should().OnlyContain(request => + request.Method == HttpMethod.Get && request.Authorization!.Parameter == "caller-token"); + handler.Requests.Select(request => request.RequestUri!.PathAndQuery).Should().Equal( + "/api/v1/proxy/s/ornn-api/api/v1/skills/support", + "/api/v1/proxy/s/ornn-api/api/v1/skills/guid%2Bsupport/json", + "/api/v1/catalog?include_all=true", + "/api/v1/user-services"); + } + + [Theory] + [InlineData("""{"data":{"name":"another-skill","files":{"SKILL.md":"Slack"}}}""")] + [InlineData("""{"data":{"name":"support","files":{}}}""")] + public async Task Discovery_RejectsMismatchedOrMissingSkillContents(string package) + { + var handler = Responses(Detail, package); + var service = Create(handler); + var action = () => service.RecommendAsync("caller-token", "support"); + await action.Should().ThrowAsync() + .WithMessage("Skill service discovery is unavailable."); + handler.Requests.Should().HaveCount(2); + } + + [Fact] + public async Task Discovery_FailsClosedForUnavailableCatalog_AndDoesNotReturnPartialSuccess() + { + var handler = Responses(Detail, Package, """{"error":true,"status":403,"body":"TEST_ONLY_SECRET"}"""); + var action = () => Create(handler).RecommendAsync("caller-token", "support"); + await action.Should().ThrowAsync() + .WithMessage("Skill service discovery is unavailable."); + handler.Requests.Should().HaveCount(3); + } + + [Fact] + public async Task Discovery_PropagatesCancellation_AndDoesNotContinueReading() + { + var handler = OrnnTestHttpMessageHandler.HangingUntilCanceled(); + using var cancellation = new CancellationTokenSource(); + var task = Create(handler).RecommendAsync("caller-token", "support", cancellation.Token); + await handler.RequestStarted; + await cancellation.CancelAsync(); + var action = async () => await task; + await action.Should().ThrowAsync(); + handler.Requests.Should().ContainSingle(); + } + + [Fact] + public async Task Discovery_IsolatesCallers_AndRefreshesExternalFactsWithoutSharedState() + { + var handler = Responses(Detail, Package, Catalog, Inventory, + Detail, Package, """{"entries":[]}""", """{"services":[]}"""); + var service = Create(handler); + var first = await service.RecommendAsync("first-caller", "support"); + var second = await service.RecommendAsync("second-caller", "support"); + first.Suggestions[0].Instances.Should().HaveCount(2); + second.Suggestions.Should().ContainSingle().Which.Instances.Should().BeEmpty(); + handler.Requests.Skip(4).Should().OnlyContain(request => + request.Authorization!.Parameter == "second-caller"); + } + + private static OrnnTestHttpMessageHandler Responses(params string[] responses) => + new(responses.Select>( + response => _ => OrnnTestHttpMessageHandler.JsonResponse(response, HttpStatusCode.OK)).ToArray()); + + private static SkillServiceRecommendationService Create(OrnnTestHttpMessageHandler handler) + { + var nyx = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, new HttpClient(handler)); + var options = new OrnnOptions(); + return new SkillServiceRecommendationService(new OrnnSkillServiceDiscoverySource( + new OrnnSkillClient(options, nyx), nyx, options, + NullLogger.Instance)); + } +} diff --git a/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs b/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs new file mode 100644 index 000000000..515b792de --- /dev/null +++ b/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs @@ -0,0 +1,82 @@ +using System.Security.Claims; +using Aevatar.AI.Abstractions.Skills; +using Aevatar.AI.Core.Skills; +using Aevatar.Mainnet.Host.Api.Skills; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace Aevatar.Capabilities.Tests; + +public sealed class SkillServiceRecommendationEndpointTests +{ + [Fact] + public async Task Endpoint_MapsTypedRecommendations_AndForwardsCallerAndCancellation() + { + var source = new RecordingSource(); + var http = Context(); + using var cancellation = new CancellationTokenSource(); + var response = await SkillServiceRecommendationEndpoints.GetRecommendations( + http, new SkillServiceRecommendationService(source), "support", cancellation.Token); + + var body = response.Should().BeOfType>() + .Which.Value!; + body.SkillName.Should().Be("support"); + body.Suggestions.Should().ContainSingle().Which.Evidence.Should().Be("linked"); + body.Suggestions[0].Instances.Should().BeEmpty(); + source.Token.Should().Be("caller-token"); + source.Cancellation.Should().Be(cancellation.Token); + http.Response.Headers.CacheControl.ToString().Should().Be("no-store"); + } + + [Fact] + public async Task Endpoint_RejectsUnauthenticatedOrInvalidInput_BeforeExternalReads() + { + var source = new RecordingSource(); + var service = new SkillServiceRecommendationService(source); + var http = Context(); + http.User = new ClaimsPrincipal(); + (await SkillServiceRecommendationEndpoints.GetRecommendations(http, service, "support")) + .Should().BeOfType(); + (await SkillServiceRecommendationEndpoints.GetRecommendations(Context(), service, "..")) + .Should().BeOfType>(); + source.Token.Should().BeNull(); + } + + [Fact] + public async Task Endpoint_ReturnsRetryableFailure_WithoutPretendingNoDependencies() + { + var source = new RecordingSource { Fail = true }; + var response = await SkillServiceRecommendationEndpoints.GetRecommendations( + Context(), new SkillServiceRecommendationService(source), "support"); + var result = response.Should().BeOfType>().Subject; + result.StatusCode.Should().Be(502); + result.Value!.Code.Should().Be("skill_service_discovery_unavailable"); + } + + private static DefaultHttpContext Context() + { + var http = new DefaultHttpContext(); + http.Request.Headers.Authorization = "Bearer caller-token"; + http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "user-alpha")], "test")); + return http; + } + + private sealed class RecordingSource : ISkillServiceDiscoverySource + { + public bool Fail { get; init; } + public string? Token { get; private set; } + public CancellationToken Cancellation { get; private set; } + public Task ReadAsync(string accessToken, string skillName, CancellationToken ct) + { + Token = accessToken; + Cancellation = ct; + if (Fail) throw new SkillServiceDiscoveryException(); + return Task.FromResult(new SkillServiceDiscoveryInput + { + SkillName = skillName, + LinkedServiceSlug = "api-github", + }); + } + } +} From a9f2a9972212af75035a625eb0ca76be9bbb1563 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:48:15 +0800 Subject: [PATCH 3/4] Separate backend recommendations from the console PR --- .../2026-09-28-channel-skill-services.md | 8 +- .../skill-service-recommendations.md | 99 ------------ .../Aevatar.AI.Abstractions.csproj | 1 - .../Skills/ISkillServiceRecommendations.cs | 20 --- .../skill_service_recommendations.proto | 56 ------- .../SkillServiceRecommendationService.cs | 72 --------- .../NyxIdApiClient.cs | 4 +- .../OrnnSkillServiceDiscoverySource.cs | 129 ---------------- .../ServiceCollectionExtensions.cs | 4 - .../Hosting/MainnetHostBuilderExtensions.cs | 1 - .../SkillServiceRecommendationEndpoints.cs | 78 ---------- .../SkillServiceRecommendationTests.cs | 142 ------------------ ...SkillServiceRecommendationEndpointTests.cs | 82 ---------- 13 files changed, 9 insertions(+), 687 deletions(-) delete mode 100644 docs/contracts/skill-service-recommendations.md delete mode 100644 src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs delete mode 100644 src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto delete mode 100644 src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs delete mode 100644 src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs delete mode 100644 src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs delete mode 100644 test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs delete mode 100644 test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md index fa0714687..e7df582ea 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md @@ -6,9 +6,15 @@ Issue: https://github.com/aevatarAI/aevatar/issues/3678 The console calls `GET /api/skills/service-recommendations?skillName=...` on Mainnet. The backend owns Skill resolution, service discovery and evidence. -See the [backend contract](../../../../../docs/contracts/skill-service-recommendations.md) +See the [backend contract](https://github.com/aevatarAI/aevatar/blob/3da66b8813c03a7b1d87616889dd3ff343dcde1a/docs/contracts/skill-service-recommendations.md) for the response, source contracts, layering and limitations. +The backend is delivered in [PR #3683](https://github.com/aevatarAI/aevatar/pull/3683) +against `feature/integrate`. This console change is delivered separately in +[PR #3679](https://github.com/aevatarAI/aevatar/pull/3679) against +`feat/2026-08-04_workflow-activity-vnext`. The backend endpoint must be merged +and deployed for recommendations to become available. + The browser does not fetch `SKILL.md`, load the catalog for inference, or match service names against Skill content. Its API adapter validates the selected Skill identity, evidence enums and exact UserService instance IDs. A missing or diff --git a/docs/contracts/skill-service-recommendations.md b/docs/contracts/skill-service-recommendations.md deleted file mode 100644 index 4406b7aa0..000000000 --- a/docs/contracts/skill-service-recommendations.md +++ /dev/null @@ -1,99 +0,0 @@ -# Skill service recommendations - -Skill-to-service discovery is backend behavior shared by all clients. Channels -consumes the result and owns only display, selection and explicit save. Clients -must not download Skill instructions or recreate dependency inference. - -## Endpoint - -`GET /api/skills/service-recommendations?skillName=` requires an -authenticated caller and their NyxID bearer credential. It is served by Mainnet -and returns `Cache-Control: no-store`. The name is the same Skill name stored by -channel registration, limited to 128 characters and excluding dot path segments. - -```json -{ - "skillName": "support", - "suggestions": [{ - "slug": "api-github", - "label": "GitHub", - "evidence": "linked", - "instances": [{ - "id": "user-service-example", - "slug": "api-github", - "label": "Team GitHub", - "active": true, - "allowed": true, - "source": "organization", - "organizationName": "Example team" - }] - }] -} -``` - -`evidence` is `linked`, `catalog`, or `mention`. These are advisory sources, -not required/optional dependency declarations. Empty `instances` means no -matching connection in the caller's account inventory. `allowed` reports -account-level access only; it does not grant access to the current session or -channel. `source` is `personal`, `organization`, or `unknown`. Existing -channel authorization still uses explicitly selected exact UserService IDs. -Catalog IDs and Skill association IDs must never become authorization IDs. -Inventory availability is not credential-validity evidence. - -Successful empty discovery returns an empty `suggestions` array. Missing -authentication is 401; malformed names return 400 with -`{"code":"invalid_skill_name"}`; inaccessible, malformed, mismatched or -unavailable upstream data returns 502 with -`{"code":"skill_service_discovery_unavailable"}`. Failures never become -successful empty or partial recommendations and never expose upstream errors, -private instructions, file contents, or credentials. Cancellation propagates. - -## Ownership and source contracts - -- `Aevatar.AI.Abstractions.Skills`: protobuf input/output, evidence and instance - contracts; caller credential parameters remain outside serialized data. -- `Aevatar.AI.Core.Skills.SkillServiceRecommendationService`: stateless discovery - policy behind `ISkillServiceDiscoverySource`. No Host/HTTP dependency. -- `Aevatar.AI.ToolProviders.Ornn.OrnnSkillServiceDiscoverySource`: external - adaptation through existing NyxID/Ornn clients. JSON is decoded at this boundary. -- `Aevatar.Mainnet.Host.Api.Skills.SkillServiceRecommendationEndpoints`: - authentication, HTTP result/error mapping, and response DTOs only. -- Console `channelSkillServicesApi`: response validation only; the UI cannot - invent recommendations or evidence categories. - -The adapter resolves the selected name using Ornn `GET /api/v1/skills/:name`, -verifies identity, then loads `GET /api/v1/skills/:guid/json`. It reads the -description, root `SKILL.md`, and optional `nyxidServiceSlug`. It reads NyxID -`GET /api/v1/catalog?include_all=true` and `GET /api/v1/user-services` using -the same invocation's caller credential. Existing source contracts were -compared with `feature/integrate`; no external-product changes are required. -The adapter uses the configured Ornn per-call timeout as the discovery budget, -and rejects root instructions above 200,000 characters. - -Evidence priority is explicit Skill association, then exact catalog -`recommended_skills` association, then whole service-name/slug mention in the -description or root instructions. Names shorter than three characters are -excluded from text inference. Catalog and inventory candidates are combined; -all exact same-slug instances remain distinct. Skill instructions are data, -never executed. No LLM inference, recursive Skill loading, grant mutation, -credential creation, or channel update occurs during discovery. - -These APIs provide no exhaustive mandatory-dependency contract. Literal matching -can miss aliases or include incidental mentions. Every result is advisory and -may be incomplete. Adding authoritative required dependencies later must extend -the typed contract and source evidence, rather than infer a requirement from -text. Platform-required services retain their current separate contract. - -All collections are invocation-local. There is no shared credential/result -cache, actor state, persisted derived dependency list, event replay, projection -priming or query-time materialization. This is transient discovery over external -caller-visible catalog facts, not a new authority for channel configuration. - -## Verification - -Provider integration tests exercise real policy and adapters against an HTTP -boundary: evidence precedence, bounded name matching, distinct instance IDs, -sanitized failures, identity mismatches, cancellation and caller isolation. -Host tests cover response mapping, authentication, validation, cancellation -forwarding and retryable errors. Console tests cover API decoding, stale-response -isolation, manual selection, exact save IDs and recovery. diff --git a/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj b/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj index 41189f117..4fed2ecbf 100644 --- a/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj +++ b/src/Aevatar.AI.Abstractions/Aevatar.AI.Abstractions.csproj @@ -22,6 +22,5 @@ - diff --git a/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs b/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs deleted file mode 100644 index 412723a45..000000000 --- a/src/Aevatar.AI.Abstractions/Skills/ISkillServiceRecommendations.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Aevatar.AI.Abstractions.Skills; - -// Caller credentials are per invocation, never retained in service state or protobuf data. -public interface ISkillServiceDiscoverySource -{ - Task ReadAsync( - string accessToken, string skillName, CancellationToken ct = default); -} - -public interface ISkillServiceRecommendationService -{ - Task RecommendAsync( - string accessToken, string skillName, CancellationToken ct = default); -} - -public sealed class SkillServiceDiscoveryException : Exception -{ - public SkillServiceDiscoveryException() - : base("Skill service discovery is unavailable.") { } -} diff --git a/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto b/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto deleted file mode 100644 index 6a5d016ec..000000000 --- a/src/Aevatar.AI.Abstractions/Skills/skill_service_recommendations.proto +++ /dev/null @@ -1,56 +0,0 @@ -syntax = "proto3"; - -package aevatar.ai.skills; -option csharp_namespace = "Aevatar.AI.Abstractions.Skills"; - -// Advisory discovery evidence; none of these sources declares a mandatory dependency. -enum SkillServiceEvidence { - SKILL_SERVICE_EVIDENCE_UNSPECIFIED = 0; - SKILL_SERVICE_EVIDENCE_LINKED = 1; - SKILL_SERVICE_EVIDENCE_CATALOG = 2; - SKILL_SERVICE_EVIDENCE_MENTION = 3; -} - -enum SkillServiceCredentialSource { - SKILL_SERVICE_CREDENTIAL_SOURCE_UNSPECIFIED = 0; - SKILL_SERVICE_CREDENTIAL_SOURCE_PERSONAL = 1; - SKILL_SERVICE_CREDENTIAL_SOURCE_ORGANIZATION = 2; -} - -message SkillServiceInstance { - string user_service_id = 1; - string slug = 2; - string label = 3; - bool active = 4; - bool account_access_allowed = 5; - SkillServiceCredentialSource credential_source = 6; - string organization_name = 7; -} - -message SkillServiceCatalogEntry { - string slug = 1; - string name = 2; - repeated string recommended_skill_names = 3; -} - -// Transient typed input from external adapters. Never persisted or returned to clients. -message SkillServiceDiscoveryInput { - string skill_name = 1; - string description = 2; - string instructions = 3; - string linked_service_slug = 4; - repeated SkillServiceCatalogEntry catalog = 5; - repeated SkillServiceInstance instances = 6; -} - -message SkillServiceRecommendation { - string slug = 1; - string label = 2; - SkillServiceEvidence evidence = 3; - repeated SkillServiceInstance instances = 4; -} - -message SkillServiceRecommendations { - string skill_name = 1; - repeated SkillServiceRecommendation suggestions = 2; -} diff --git a/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs b/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs deleted file mode 100644 index 854cd1632..000000000 --- a/src/Aevatar.AI.Core/Skills/SkillServiceRecommendationService.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Text.RegularExpressions; -using Aevatar.AI.Abstractions.Skills; - -namespace Aevatar.AI.Core.Skills; - -// Stateless discovery over caller-visible external facts. Suggestions are not grants -// or persisted dependencies; channel authorization still requires explicit exact IDs. -public sealed class SkillServiceRecommendationService(ISkillServiceDiscoverySource source) - : ISkillServiceRecommendationService -{ - public async Task RecommendAsync( - string accessToken, string skillName, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(accessToken)) - throw new ArgumentException("Caller credentials are required.", nameof(accessToken)); - if (string.IsNullOrWhiteSpace(skillName) || skillName.Length > 128 || - skillName != skillName.Trim() || skillName is "." or "..") - throw new ArgumentException("Invalid skill name.", nameof(skillName)); - - var input = await source.ReadAsync(accessToken, skillName, ct); - if (!string.Equals(input.SkillName, skillName, StringComparison.Ordinal)) - throw new SkillServiceDiscoveryException(); - - var candidates = input.Catalog.ToDictionary(entry => entry.Slug, StringComparer.Ordinal); - foreach (var instance in input.Instances) - candidates.TryAdd(instance.Slug, new SkillServiceCatalogEntry - { - Slug = instance.Slug, - Name = instance.Label, - }); - if (input.LinkedServiceSlug.Length > 0) - candidates.TryAdd(input.LinkedServiceSlug, new SkillServiceCatalogEntry - { - Slug = input.LinkedServiceSlug, - Name = input.LinkedServiceSlug, - }); - - var result = new SkillServiceRecommendations { SkillName = skillName }; - var text = input.Description + "\n" + input.Instructions; - foreach (var entry in candidates.Values) - { - ct.ThrowIfCancellationRequested(); - var evidence = entry.Slug == input.LinkedServiceSlug - ? SkillServiceEvidence.Linked - : entry.RecommendedSkillNames.Contains(skillName) - ? SkillServiceEvidence.Catalog - : Mentions(text, entry.Slug) || Mentions(text, entry.Name) - ? SkillServiceEvidence.Mention - : SkillServiceEvidence.Unspecified; - if (evidence == SkillServiceEvidence.Unspecified) - continue; - var suggestion = new SkillServiceRecommendation - { - Slug = entry.Slug, - Label = entry.Name, - Evidence = evidence, - }; - suggestion.Instances.Add(input.Instances - .Where(instance => instance.Slug == entry.Slug) - .Select(instance => instance.Clone())); - result.Suggestions.Add(suggestion); - } - return result; - } - - private static bool Mentions(string text, string term) => - term.Trim().Length >= 3 && Regex.IsMatch( - text, - @"(? SearchAdminUsersAsync(string token, string email, Cancellati // ─── Catalog ─── - public Task ListCatalogAsync(string token, CancellationToken ct, bool includeAll = false) => - GetAsync(token, includeAll ? "/api/v1/catalog?include_all=true" : "/api/v1/catalog", ct); + public Task ListCatalogAsync(string token, CancellationToken ct) => + GetAsync(token, "/api/v1/catalog", ct); public Task GetCatalogEntryAsync(string token, string slug, CancellationToken ct) => GetAsync(token, $"/api/v1/catalog/{Uri.EscapeDataString(slug)}", ct); diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs deleted file mode 100644 index e0ecf7a13..000000000 --- a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillServiceDiscoverySource.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Text.Json; -using Aevatar.AI.Abstractions.Skills; -using Aevatar.AI.ToolProviders.NyxId; -using Microsoft.Extensions.Logging; - -namespace Aevatar.AI.ToolProviders.Ornn; - -// External JSON is decoded only at this adapter boundary. The policy consumes -// protobuf values and never sees raw error bodies or arbitrary files. Credentials -// pass through the service as invocation parameters only. -public sealed class OrnnSkillServiceDiscoverySource( - OrnnSkillClient skills, - NyxIdApiClient nyx, - OrnnOptions options, - ILogger logger) : ISkillServiceDiscoverySource -{ - public async Task ReadAsync( - string accessToken, string skillName, CancellationToken ct = default) - { - using var timeout = new CancellationTokenSource(options.PerCallTimeout); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); - try - { - using var detail = Parse(await nyx.ProxyRequestAsync( - accessToken, options.NyxIdSlug, - $"/api/v1/skills/{Uri.EscapeDataString(skillName)}", "GET", - null, null, linked.Token)); - var data = detail.RootElement.GetProperty("data"); - var guid = RequiredString(data, "guid"); - if (guid is "." or ".." || RequiredString(data, "name") != skillName) - throw new SkillServiceDiscoveryException(); - var package = await skills.GetSkillJsonAsync(accessToken, guid, linked.Token); - if (package?.Name != skillName || - package.Files is null || !package.Files.TryGetValue("SKILL.md", out var instructions) || - instructions is null || instructions.Length > 200_000) - throw new SkillServiceDiscoveryException(); - - using var catalog = Parse(await nyx.ListCatalogAsync(accessToken, linked.Token, includeAll: true)); - var inventory = NyxIdApiAccessResponseParser.ParseUserServices( - await nyx.ListUserServicesAsync(accessToken, linked.Token)); - if (!inventory.Succeeded) - throw new SkillServiceDiscoveryException(); - - var result = new SkillServiceDiscoveryInput - { - SkillName = skillName, - Description = OptionalString(data, "description"), - Instructions = instructions, - LinkedServiceSlug = OptionalString(data, "nyxidServiceSlug").Trim(), - }; - var slugs = new HashSet(StringComparer.Ordinal); - foreach (var entry in catalog.RootElement.GetProperty("entries").EnumerateArray()) - { - var candidate = new SkillServiceCatalogEntry - { - Slug = RequiredString(entry, "slug"), - Name = RequiredString(entry, "name"), - }; - if (!slugs.Add(candidate.Slug)) - throw new SkillServiceDiscoveryException(); - if (entry.TryGetProperty("recommended_skills", out var recommended) && - recommended.ValueKind != JsonValueKind.Null) - foreach (var name in recommended.EnumerateArray()) - candidate.RecommendedSkillNames.Add(name.GetString() - ?? throw new SkillServiceDiscoveryException()); - result.Catalog.Add(candidate); - } - foreach (var service in inventory.Value!.Services) - { - var source = service.CredentialSource; - result.Instances.Add(new SkillServiceInstance - { - UserServiceId = service.Id, - Slug = service.Slug, - Label = FirstLabel(service.Label, service.CatalogServiceName, service.Slug), - Active = service.IsActive, - AccountAccessAllowed = source.Kind == NyxIdUserServiceCredentialSourceKind.Personal || - source.Kind == NyxIdUserServiceCredentialSourceKind.Organization && source.Allowed, - CredentialSource = source.Kind switch - { - NyxIdUserServiceCredentialSourceKind.Personal => SkillServiceCredentialSource.Personal, - NyxIdUserServiceCredentialSourceKind.Organization => SkillServiceCredentialSource.Organization, - _ => SkillServiceCredentialSource.Unspecified, - }, - OrganizationName = source.OrganizationName ?? string.Empty, - }); - } - return result; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - // Do not log upstream messages, credential material, or private instructions. - logger.LogWarning("Skill service discovery failed ({FailureType})", ex.GetType().Name); - throw new SkillServiceDiscoveryException(); - } - } - - private static JsonDocument Parse(string response) - { - var document = JsonDocument.Parse(response); - if (document.RootElement.ValueKind == JsonValueKind.Object && - (!document.RootElement.TryGetProperty("error", out var error) || - error.ValueKind is JsonValueKind.Null or JsonValueKind.False)) - return document; - document.Dispose(); - throw new SkillServiceDiscoveryException(); - } - - private static string RequiredString(JsonElement row, string property) - { - var value = row.GetProperty(property).GetString(); - return !string.IsNullOrWhiteSpace(value) && value == value.Trim() - ? value - : throw new SkillServiceDiscoveryException(); - } - - private static string OptionalString(JsonElement row, string property) => - !row.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null - ? string.Empty - : value.GetString() ?? string.Empty; - - private static string FirstLabel(string? label, string? catalogName, string slug) => - !string.IsNullOrWhiteSpace(label) ? label.Trim() - : !string.IsNullOrWhiteSpace(catalogName) ? catalogName.Trim() : slug; -} diff --git a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs index 722e18b44..f9e690c40 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs @@ -1,7 +1,5 @@ -using Aevatar.AI.Abstractions.Skills; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.Core.AgentProfiles; -using Aevatar.AI.Core.Skills; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.Ornn.Publishing; using Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay; @@ -70,8 +68,6 @@ public static IServiceCollection AddOrnnSkillClient( sp.GetService>()); }); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(sp => sp.GetRequiredService()); return services; diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index 385fad111..c95eaa653 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -714,7 +714,6 @@ public static WebApplication MapAevatarMainnetHost(this WebApplication app) app.MapProjectionVersionRegressionRepairAdminEndpoints(); app.MapManagedCodexCredentialEndpoints(); app.MapWorkflowSkillsEndpoints(); - app.MapSkillServiceRecommendations(); app.MapStatusEndpoints(); // Voice service registration is conditional on a configured provider diff --git a/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs b/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs deleted file mode 100644 index a71db6ec7..000000000 --- a/src/Aevatar.Mainnet.Host.Api/Skills/SkillServiceRecommendationEndpoints.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Aevatar.AI.Abstractions.Skills; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace Aevatar.Mainnet.Host.Api.Skills; - -internal static class SkillServiceRecommendationEndpoints -{ - public static IEndpointRouteBuilder MapSkillServiceRecommendations(this IEndpointRouteBuilder app) - { - app.MapGet("/api/skills/service-recommendations", GetRecommendations) - .WithTags("Skills") - .WithName("GetSkillServiceRecommendations") - .WithSummary("Discover advisory services for a caller-visible Skill without changing authorization.") - .RequireAuthorization(); - return app; - } - - internal static async Task GetRecommendations( - HttpContext http, - [FromServices] ISkillServiceRecommendationService service, - string skillName, - CancellationToken ct = default) - { - var authorization = http.Request.Headers.Authorization.ToString(); - const string prefix = "Bearer "; - if (http.User.Identity?.IsAuthenticated != true) - return Results.Unauthorized(); - if (!authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || - string.IsNullOrWhiteSpace(authorization[prefix.Length..])) - return Results.Unauthorized(); - http.Response.Headers.CacheControl = "no-store"; - try - { - var result = await service.RecommendAsync(authorization[prefix.Length..].Trim(), skillName, ct); - return Results.Json(new SkillServiceRecommendationsResponse( - result.SkillName, - result.Suggestions.Select(item => new SkillServiceSuggestionResponse( - item.Slug, item.Label, - item.Evidence switch - { - SkillServiceEvidence.Linked => "linked", - SkillServiceEvidence.Catalog => "catalog", - SkillServiceEvidence.Mention => "mention", - _ => throw new SkillServiceDiscoveryException(), - }, - item.Instances.Select(instance => new SkillServiceInstanceResponse( - instance.UserServiceId, instance.Slug, instance.Label, - instance.Active, instance.AccountAccessAllowed, - instance.CredentialSource switch - { - SkillServiceCredentialSource.Personal => "personal", - SkillServiceCredentialSource.Organization => "organization", - _ => "unknown", - }, - instance.OrganizationName)).ToArray())).ToArray())); - } - catch (ArgumentException) - { - return Results.BadRequest(new SkillServiceRecommendationError("invalid_skill_name")); - } - catch (SkillServiceDiscoveryException) - { - return Results.Json(new SkillServiceRecommendationError("skill_service_discovery_unavailable"), - statusCode: StatusCodes.Status502BadGateway); - } - } -} - -public sealed record SkillServiceRecommendationsResponse( - string SkillName, IReadOnlyList Suggestions); -public sealed record SkillServiceSuggestionResponse( - string Slug, string Label, string Evidence, IReadOnlyList Instances); -public sealed record SkillServiceInstanceResponse( - string Id, string Slug, string Label, bool Active, bool Allowed, string Source, string OrganizationName); -public sealed record SkillServiceRecommendationError(string Code); diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs deleted file mode 100644 index 139b4d5f4..000000000 --- a/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillServiceRecommendationTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Net; -using Aevatar.AI.Abstractions.Skills; -using Aevatar.AI.Core.Skills; -using Aevatar.AI.ToolProviders.NyxId; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.AI.ToolProviders.Ornn.Tests; - -public sealed class SkillServiceRecommendationTests -{ - private const string Detail = """ - {"data":{"guid":"guid+support","name":"support","description":"Use Slack for updates.", - "nyxidServiceSlug":"api-github","nyxidServiceId":"catalog-not-a-user-service"}} - """; - private const string Package = """ - {"data":{"name":"support","files":{"SKILL.md":"Use Linear. gmail and mail-helper are different. TEST_ONLY_PRIVATE", - "private.txt":"TEST_ONLY_SECRET"}}} - """; - private const string Catalog = """ - {"entries":[ - {"slug":"api-github","name":"GitHub"}, - {"slug":"slack","name":"Slack"}, - {"slug":"linear","name":"Linear"}, - {"slug":"drive","name":"Drive","recommended_skills":["support"]}, - {"slug":"mail","name":"Mail"}, - {"slug":"unused","name":"Unused","recommended_skills":["support-extra"]} - ]} - """; - private const string Inventory = """ - {"services":[ - {"id":"us-personal","slug":"api-github","label":"Personal GitHub","is_active":true, - "credential_source":{"type":"personal"},"api_key":"TEST_ONLY_SECRET"}, - {"id":"us-org","slug":"api-github","label":"Team GitHub","is_active":false, - "credential_source":{"type":"org","org_id":"org-1","org_name":"Acme","role":"viewer","allowed":false}} - ]} - """; - - [Fact] - public async Task RegisteredService_DiscoversEvidenceAndExactInstances_WithoutLeakingInputsOrMutatingGrants() - { - var handler = Responses(Detail, Package, Catalog, Inventory); - using var http = new HttpClient(handler); - var nyx = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, http); - var services = new ServiceCollection(); - services.AddSingleton(nyx); - services.AddSingleton>(NullLogger.Instance); - services.AddOrnnSkillClient(); - using var provider = services.BuildServiceProvider(); - var service = provider.GetRequiredService(); - - var result = await service.RecommendAsync("caller-token", "support"); - - result.SkillName.Should().Be("support"); - result.Suggestions.Select(item => (item.Slug, item.Evidence)).Should().Equal( - ("api-github", SkillServiceEvidence.Linked), - ("slack", SkillServiceEvidence.Mention), - ("linear", SkillServiceEvidence.Mention), - ("drive", SkillServiceEvidence.Catalog)); - var instances = result.Suggestions[0].Instances; - instances.Select(item => item.UserServiceId).Should().Equal("us-personal", "us-org"); - instances[0].CredentialSource.Should().Be(SkillServiceCredentialSource.Personal); - instances[1].CredentialSource.Should().Be(SkillServiceCredentialSource.Organization); - instances[1].Active.Should().BeFalse(); - instances[1].AccountAccessAllowed.Should().BeFalse(); - result.Suggestions[1].Instances.Should().BeEmpty(); - result.ToString().Should().NotContain("TEST_ONLY").And.NotContain("catalog-not-a-user-service"); - handler.Requests.Should().OnlyContain(request => - request.Method == HttpMethod.Get && request.Authorization!.Parameter == "caller-token"); - handler.Requests.Select(request => request.RequestUri!.PathAndQuery).Should().Equal( - "/api/v1/proxy/s/ornn-api/api/v1/skills/support", - "/api/v1/proxy/s/ornn-api/api/v1/skills/guid%2Bsupport/json", - "/api/v1/catalog?include_all=true", - "/api/v1/user-services"); - } - - [Theory] - [InlineData("""{"data":{"name":"another-skill","files":{"SKILL.md":"Slack"}}}""")] - [InlineData("""{"data":{"name":"support","files":{}}}""")] - public async Task Discovery_RejectsMismatchedOrMissingSkillContents(string package) - { - var handler = Responses(Detail, package); - var service = Create(handler); - var action = () => service.RecommendAsync("caller-token", "support"); - await action.Should().ThrowAsync() - .WithMessage("Skill service discovery is unavailable."); - handler.Requests.Should().HaveCount(2); - } - - [Fact] - public async Task Discovery_FailsClosedForUnavailableCatalog_AndDoesNotReturnPartialSuccess() - { - var handler = Responses(Detail, Package, """{"error":true,"status":403,"body":"TEST_ONLY_SECRET"}"""); - var action = () => Create(handler).RecommendAsync("caller-token", "support"); - await action.Should().ThrowAsync() - .WithMessage("Skill service discovery is unavailable."); - handler.Requests.Should().HaveCount(3); - } - - [Fact] - public async Task Discovery_PropagatesCancellation_AndDoesNotContinueReading() - { - var handler = OrnnTestHttpMessageHandler.HangingUntilCanceled(); - using var cancellation = new CancellationTokenSource(); - var task = Create(handler).RecommendAsync("caller-token", "support", cancellation.Token); - await handler.RequestStarted; - await cancellation.CancelAsync(); - var action = async () => await task; - await action.Should().ThrowAsync(); - handler.Requests.Should().ContainSingle(); - } - - [Fact] - public async Task Discovery_IsolatesCallers_AndRefreshesExternalFactsWithoutSharedState() - { - var handler = Responses(Detail, Package, Catalog, Inventory, - Detail, Package, """{"entries":[]}""", """{"services":[]}"""); - var service = Create(handler); - var first = await service.RecommendAsync("first-caller", "support"); - var second = await service.RecommendAsync("second-caller", "support"); - first.Suggestions[0].Instances.Should().HaveCount(2); - second.Suggestions.Should().ContainSingle().Which.Instances.Should().BeEmpty(); - handler.Requests.Skip(4).Should().OnlyContain(request => - request.Authorization!.Parameter == "second-caller"); - } - - private static OrnnTestHttpMessageHandler Responses(params string[] responses) => - new(responses.Select>( - response => _ => OrnnTestHttpMessageHandler.JsonResponse(response, HttpStatusCode.OK)).ToArray()); - - private static SkillServiceRecommendationService Create(OrnnTestHttpMessageHandler handler) - { - var nyx = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, new HttpClient(handler)); - var options = new OrnnOptions(); - return new SkillServiceRecommendationService(new OrnnSkillServiceDiscoverySource( - new OrnnSkillClient(options, nyx), nyx, options, - NullLogger.Instance)); - } -} diff --git a/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs b/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs deleted file mode 100644 index 515b792de..000000000 --- a/test/Aevatar.Capabilities.Tests/SkillServiceRecommendationEndpointTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Security.Claims; -using Aevatar.AI.Abstractions.Skills; -using Aevatar.AI.Core.Skills; -using Aevatar.Mainnet.Host.Api.Skills; -using FluentAssertions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; - -namespace Aevatar.Capabilities.Tests; - -public sealed class SkillServiceRecommendationEndpointTests -{ - [Fact] - public async Task Endpoint_MapsTypedRecommendations_AndForwardsCallerAndCancellation() - { - var source = new RecordingSource(); - var http = Context(); - using var cancellation = new CancellationTokenSource(); - var response = await SkillServiceRecommendationEndpoints.GetRecommendations( - http, new SkillServiceRecommendationService(source), "support", cancellation.Token); - - var body = response.Should().BeOfType>() - .Which.Value!; - body.SkillName.Should().Be("support"); - body.Suggestions.Should().ContainSingle().Which.Evidence.Should().Be("linked"); - body.Suggestions[0].Instances.Should().BeEmpty(); - source.Token.Should().Be("caller-token"); - source.Cancellation.Should().Be(cancellation.Token); - http.Response.Headers.CacheControl.ToString().Should().Be("no-store"); - } - - [Fact] - public async Task Endpoint_RejectsUnauthenticatedOrInvalidInput_BeforeExternalReads() - { - var source = new RecordingSource(); - var service = new SkillServiceRecommendationService(source); - var http = Context(); - http.User = new ClaimsPrincipal(); - (await SkillServiceRecommendationEndpoints.GetRecommendations(http, service, "support")) - .Should().BeOfType(); - (await SkillServiceRecommendationEndpoints.GetRecommendations(Context(), service, "..")) - .Should().BeOfType>(); - source.Token.Should().BeNull(); - } - - [Fact] - public async Task Endpoint_ReturnsRetryableFailure_WithoutPretendingNoDependencies() - { - var source = new RecordingSource { Fail = true }; - var response = await SkillServiceRecommendationEndpoints.GetRecommendations( - Context(), new SkillServiceRecommendationService(source), "support"); - var result = response.Should().BeOfType>().Subject; - result.StatusCode.Should().Be(502); - result.Value!.Code.Should().Be("skill_service_discovery_unavailable"); - } - - private static DefaultHttpContext Context() - { - var http = new DefaultHttpContext(); - http.Request.Headers.Authorization = "Bearer caller-token"; - http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "user-alpha")], "test")); - return http; - } - - private sealed class RecordingSource : ISkillServiceDiscoverySource - { - public bool Fail { get; init; } - public string? Token { get; private set; } - public CancellationToken Cancellation { get; private set; } - public Task ReadAsync(string accessToken, string skillName, CancellationToken ct) - { - Token = accessToken; - Cancellation = ct; - if (Fail) throw new SkillServiceDiscoveryException(); - return Task.FromResult(new SkillServiceDiscoveryInput - { - SkillName = skillName, - LinkedServiceSlug = "api-github", - }); - } - } -} From 415b40260e54dba3eb57efbe62e30def87a782f6 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:17 +0800 Subject: [PATCH 4/4] Allow active NyxID account services in channel forms --- .../2026-09-28-channel-service-access.md | 168 ++++--------- .../2026-09-28-channel-skill-services.md | 13 +- .../src/locales/channelMessages.en-US.ts | 23 +- .../src/locales/channelMessages.zh-CN.ts | 20 +- .../ChannelBindServiceAccess.test.tsx | 154 +----------- .../channels/ChannelConfigurationPage.tsx | 129 ++-------- .../channels/ChannelEditPage.test.tsx | 17 +- .../channels/ChannelServiceAccess.test.tsx | 224 ++++-------------- .../channels/ChannelServiceAccessNotice.tsx | 124 +++++----- .../channels/ChannelServicePicker.tsx | 52 +--- .../channels/ChannelSkillServices.test.tsx | 15 +- .../channels/ChannelSkillServices.tsx | 20 +- .../channels/connectionStyles.ts | 3 - .../channels/queries.ts | 8 +- .../channels/serviceAccessDraft.ts | 77 ------ .../src/shared/api/channelServicesApi.test.ts | 41 ++-- .../src/shared/api/channelServicesApi.ts | 26 +- .../src/shared/auth/serviceGrants.ts | 59 ----- 18 files changed, 263 insertions(+), 910 deletions(-) delete mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts delete mode 100644 apps/aevatar-console-web/src/shared/auth/serviceGrants.ts diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md index 5124a744a..7f5399273 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md @@ -1,134 +1,68 @@ -# Channel configuration service access recovery +# Channel service selection -Channel service selection and the user's NyxID authorization are separate -decisions. The editor shows the services the current session can select; -Manage service access opens the existing full NyxID consent flow. It never -promises a consent page limited to the services named in a link. +Bind and Edit list active services the user's NyxID account can use, regardless +of which services the user selected at login. The Services header has no Manage +service access action, and the channel form does not redirect into OAuth consent. +This supersedes the former login-grant filtering and consent-return draft flow. -## Link contract +## Source and authorization boundary -Use repeated `requiredServiceId` query parameters on the canonical edit or Bind URL: +The authenticated `GET /api/v1/user-services` inventory owns service identity, +activity and account access. Human-session inventory is independent of OAuth +service selections. The console does not decode `allowed_service_ids` or +`allow_all_services` to constrain channel selection. Personal services and +organization services allowed by membership are selectable when active; NyxID +organization viewer entries (`credential_source.allowed=false`) remain unavailable. -```text -/scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId&requiredServiceId=:anotherUserServiceId -/scopes/:scopeId/channels/bind/:botId?skillId=:optionalSkillId&requiredServiceId=:userServiceId&requiredServiceId=:anotherUserServiceId -``` - -Each value is the exact NyxID **UserService ID**, obtained from an authoritative -service reference. A catalog ID, display name, slug, channel registration ID, -or Aevatar published-service ID is not interchangeable with this identity. -The editor trims and deduplicates hints, accepts at most 20 nonempty values of -at most 128 characters each, and ignores invalid values. Hints are not grants, -do not select a service automatically, and do not become channel requirements. - -Names and slugs come from the authenticated NyxID user-service inventory. -Effective availability requires an active service, account access, and the -current bearer grant for that exact ID (or an explicit all-services grant). -Another service with the same slug cannot satisfy the hint. Missing inventory -entries are shown as unresolved services with the requested ID behind a -details disclosure; unavailable services are not presented as selectable. - -## User path - -1. The user opens the edit or Bind link. Services lists the missing requested access - above the existing searchable channel selection. -2. Manage service access saves the non-secret, unsaved label, skill name and - selected IDs in tab-scoped session storage before leaving. A storage or - redirect failure keeps the editor open with a retryable error. -3. The existing `NyxIDAuthClient` starts `serviceAccessReview` with the complete - configuration path, query and fragment as `returnTo`. It uses the existing PKCE, - callback and backend finalization flow. It supplies no targeted `resource` - or `preselect_service_ids` parameters. -4. NyxID currently displays the full consent page. The editor directs users to - **Customize** under **Service access**, retain services they still need, - select the listed services, and choose **Allow**. Viewing that page does not - imply that any permission was granted. -5. Returning reloads the actual service inventory/grants and restores the - editor draft. New available services are marked Requested but remain - unselected until the user chooses them. Partial or cancelled authorization - leaves the remaining access needs visible. The callback's return action is - labeled Back to previous page because it may return to this editor. - Restored edits receive one short inline reminder to review selections and - save. Do not show a generic permission-check success heading or panel; - only unresolved access needs warrant a separate notice and service list. -6. The user selects services and clicks **Save changes** or **Bind bot** explicitly. - Both actions follow the channel's existing accepted-to-observed confirmation. - Access review itself never saves or binds the channel. After a successful access - refresh, deleted, inactive or unauthorized services disappear from the selection - list and are removed from the draft, selected count and next submission. There - are no Unavailable placeholder rows or manual-deselection warnings. Later - reauthorization makes a service selectable again without restoring its old - selection. Pending or failed access requests never erase draft choices. - Required built-in services still block submission when unavailable. A missing - service explicitly requested by the URL remains in the separate access notice. +Both Bind and Edit submit exact UserService IDs with +`authorization_mode=explicit_service_allowlist`. Saving uses the existing backend +registration authorization planner: it verifies active instances and ownership, +asks NyxID for the Agent Key scope plan, and creates or updates that independent +channel credential. This behavior was checked against `feature/integrate`; +no backend contract change is needed. Account access does not prove credential +health, and the server revalidates each submitted selection. -Both routes reuse `ChannelConfigurationPage`, `ChannelServicePicker` and -`ChannelServiceAccessNotice`, including loading, retry and revoked-selection cleanup -behavior. Bind needs no saved channel registration to review access. +## Link hints and user path -The temporary draft is keyed by account subject, scope and a typed target: -`bind + botId` for an unbound bot, or `edit + registrationId` for a saved channel. -These identities never substitute for each other, even if raw ID strings coincide. -The draft has a one-hour expiry and is cleared after restoration, completed save -or binding, or explicit discard. It contains no credentials and cannot establish -authorization or a saved channel fact. Browser history restoration resets pending -review state and refreshes service access. +Repeated `requiredServiceId` parameters on the canonical Bind or Edit URL remain +advisory hints for exact UserService IDs: -On Bind, a restored skill choice takes precedence over the link's `skillId` -default, including an explicitly cleared choice. Authorization return preserves -the complete link but does not reapply the default or automatically select newly -authorized services. Binding still targets the original bot ID and completes only -after the returned registration ID and bot ID are observed with the submitted -configuration. - -## Current NyxID review limitation - -The ordinary `prompt=consent` page can initialize from the app's default -services instead of the user's latest saved consent. On 2026-09-29, the live -review page showed the original six services even though the latest Authorized -Apps entry contained the two additionally granted UserService IDs. Both extra -services were available but unchecked under Customize. Merely opening the -review did not remove the saved grant. - -The user must include every service they intend to retain before submitting -that ordinary review. Its consent decision replaces the selected service -boundary; the channel's draft selections do not initialize NyxID's picker. -Do not send the consent page's server-generated `preselect_service_ids` as -an invented `/oauth/authorize` contract, or substitute slug-based `resource` -parameters: resource requests can narrow issued authority and cannot reliably -represent distinct same-slug UserServices. +```text +/scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId +/scopes/:scopeId/channels/bind/:botId?skillId=:optionalSkillId&requiredServiceId=:userServiceId +``` -[NyxID PR #1683](https://github.com/ChronoAIProject/NyxID/pull/1683) introduces -explicit incremental consent with `service_access_mode=incremental` and exact -repeated `requested_service_ids`. It was open during this investigation. -After the backend and consent UI deploy, Aevatar must integrate that contract -and verify repeated consent preserves the accumulated grant. This full-review -fallback does not claim that capability. +The editor trims and deduplicates hints, accepts at most 20 nonempty values of at +most 128 characters, and ignores invalid values. Hints neither select services +nor add channel requirements. Active matching instances are marked Requested; +a same-slug instance never substitutes for the requested ID. Missing or inactive +hints get an availability notice with unresolved IDs behind a details disclosure. +There are no instructions to customize login consent. + +Users search and select services, then explicitly click Bind bot or Save changes. +The optional Skill's backend suggestions use this same inventory boundary. +Required built-in services retain their existing selection and validation rules. +A successful inventory refresh removes missing, inactive or account-denied +selections from the draft, selected count and next submission. Active services +outside login consent remain selected. Failed or pending refreshes preserve edits +and block saving. Reactivation makes services selectable without reselecting them. + +There is no channel consent redirect or temporary session-storage draft. Unsaved +navigation retains its ordinary discard protection. Saving still completes only +when the submitted configuration is observed, not when a command is accepted. ## Verification and visual direction -Keep the existing compact white work surface, AlibabaSans typography, blue -actions and token-based amber access notice. Missing services are a short list -with readable names and slugs; the existing search and checkboxes remain the -channel-selection controls. Actions and rows wrap at mobile widths. - -Route integration tests exercise exact-ID hints, duplicate slugs, partial -authorization, explicit selection/save, draft restoration, cancellation, -redirect/storage failure and account isolation. Existing adapter and callback -tests protect grant validation and the shared return flow. Browser history -restoration coverage verifies fresh grants, temporary draft cleanup and the -removal of revoked selections from the list, count and submission, while failed -refreshes preserve the draft. Bind route coverage -also verifies the complete return URL, explicit binding after refreshed grants, -accepted-versus-observed completion, restored skill overrides and clearing, and -isolation across bots and edit registrations. Full frontend -typecheck, suite and production build are delegated to GitHub CI. +Keep the compact Channels form, search, selected count, typography and design +tokens. Route integration tests cover Bind and Edit with empty login grants, +exact-instance selection, explicit submission, Bind observation, inactive/deleted +cleanup, failed-refresh preservation and reactivation. Adapter tests cover account +inventory, organization availability, authentication rejection and token refresh. +Full frontend typecheck, suite and production build are delegated to GitHub CI. -Design baseline: -`../../design-baselines/workflow-activity-vnext/`, primary +Design baseline: `../../design-baselines/workflow-activity-vnext/`, primary `aevatar-workflow-activity-vnext.excalidraw`, SHA-256 `30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. Contract: `2026-08-04-workflow-activity-vnext-design.md`. User paths: `2026-08-04-workflow-activity-vnext-user-paths.md`. -Existing auth/session/returnTo and Umi localization remain authoritative. Production data comes from real APIs and acknowledged user actions only. diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md index e7df582ea..c80566240 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md @@ -26,8 +26,11 @@ manual picker still usable; there is no local inference fallback. Binding and editing show suggestions after a Skill is selected. Reasons name the backend's evidence category without exposing private instructions. Account instances show personal/organization source, activity and availability. -The existing authorized-service choices determine which exact IDs are selectable; -backend `allowed` is account access and does not replace current bearer grants. +Active services available to the NyxID account determine which exact IDs are +selectable, including services omitted at login. Account-level organization +restrictions still apply. Login service grants do not constrain a channel +Agent Key selection. Both Bind and Edit use this same inventory boundary; see +[Channel service selection](2026-09-28-channel-service-access.md). Selecting an instance adds its exact UserService ID to the form; normal save commits it. Discovery never selects, authorizes or removes services. Changing or @@ -35,9 +38,9 @@ clearing the Skill preserves manual choices. Queries are keyed by scope and Skill name and consume abort signals, so late results do not replace the current Skill's recommendations. -No connection, inactive access and missing session authorization offer NyxID -connection management or existing Account service-access review in a new tab. -Refresh reloads backend discovery and selectable access after recovery. +Missing connections and inactive services offer NyxID connection management in a +new tab. Channel forms no longer show an OAuth service-access review action or +consent instructions. Refresh reloads backend discovery and account inventory. Pending and failed discovery stay within the suggestion region. The current backend sources do not declare mandatory dependencies. Suggestions diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts index 0517a0f1c..06f041910 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -17,8 +17,8 @@ export default { 'channels.suggestions.inactive': 'Inactive — manage this service in NyxID.', 'channels.suggestions.unavailable': 'Access unavailable — check with the service owner.', - 'channels.suggestions.unauthorized': - 'Not authorized for this session — review service access.', + 'channels.suggestions.missingInstance': + 'No active connection found. Refresh or check this service in NyxID.', 'channels.suggestions.notSelected': 'Not selected', 'channels.suggestions.selected': 'Selected', 'channels.suggestions.selectNamed': 'Select {service}', @@ -28,7 +28,6 @@ export default { 'channels.suggestions.empty': 'No related services identified. You can still select services manually below.', 'channels.suggestions.manage': 'Manage connections in NyxID ↗', - 'channels.suggestions.reviewAccess': 'Review service access ↗', 'channels.column.owner': 'Owner', 'channels.owner.organization': 'Organization', 'channels.owner.id': 'Owner ID', @@ -112,7 +111,7 @@ export default { 'channels.edit.servicesError': 'Could not load your services. Try again before saving.', 'channels.edit.servicesEmpty': - 'No services are available with your current authorization.', + 'No active services are available in your NyxID account.', 'channels.edit.missingServices': 'Some saved services are no longer available. Deselect them before saving.', 'channels.edit.selectionError': @@ -232,12 +231,12 @@ export default { 'channels.connect.selectAll': 'Select all', 'channels.connect.selectAllResults': 'Select all results', 'channels.connect.servicesHelp': - 'Choose from your authorized services, or connect additional services in NyxID.', + 'Choose active services from your NyxID account, including services you did not select when signing in.', 'channels.connect.servicesLoading': 'Loading services', 'channels.connect.servicesError': 'Could not load your services. Retry before connecting.', 'channels.connect.servicesEmpty': - 'No services are available with your current NyxID authorization.', + 'No active services are available in your NyxID account.', 'channels.connect.serviceRequired': 'Required', 'channels.connect.requiredServicesMissing': 'Required services unavailable: {services}. Check your NyxID access, then retry.', @@ -281,18 +280,12 @@ export default { 'channels.connect.stay': 'Stay', 'channels.connect.discardHelp': 'Your bot token and unsaved choices will be cleared.', - 'channels.access.manage': 'Manage service access', - 'channels.access.help': - 'Missing a service? Click Manage service access to authorize it.', - 'channels.access.needed': 'Service access needed', + 'channels.access.needed': 'Requested services unavailable', 'channels.access.instructions': - 'In NyxID, choose Customize under Service access. Keep the services you still use selected and add the services below, then choose Allow.', + 'Check these services in NyxID. Active services your account can use can be selected here.', 'channels.access.unknown': 'Service not found', 'channels.access.requestedIdentity': 'Requested service ID', 'channels.access.unavailable': 'Check availability in NyxID', - 'channels.access.notAuthorized': 'Access needed', - 'channels.access.restored': 'Your changes have been kept. Review and save.', - 'channels.access.startFailed': - 'Could not open NyxID. Your changes are still here. Try again.', + 'channels.access.notAuthorized': 'Account access unavailable', 'channels.access.requested': 'Requested', }; diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts index 4073e7529..040c54242 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -15,7 +15,8 @@ export default { 'channels.suggestions.accessError': '暂时无法检查访问权限,请刷新重试。', 'channels.suggestions.inactive': '服务未启用,请前往 NyxID 管理。', 'channels.suggestions.unavailable': '暂无访问权限,请联系服务所有者。', - 'channels.suggestions.unauthorized': '当前会话尚未授权,请检查服务访问权限。', + 'channels.suggestions.missingInstance': + '未找到 active 状态的连接,请刷新或在 NyxID 检查该服务。', 'channels.suggestions.notSelected': '尚未选择', 'channels.suggestions.selected': '已选择', 'channels.suggestions.selectNamed': '选择 {service}', @@ -24,7 +25,6 @@ export default { '账号中未找到此服务的连接,请前往 NyxID 添加。', 'channels.suggestions.empty': '未识别到相关服务。你仍可在下方手动选择。', 'channels.suggestions.manage': '在 NyxID 管理连接 ↗', - 'channels.suggestions.reviewAccess': '检查服务访问权限 ↗', 'channels.column.owner': '归属', 'channels.owner.organization': '组织', 'channels.owner.id': '归属 ID', @@ -100,7 +100,7 @@ export default { 'channels.edit.useDefaults': '使用 NyxID 默认授权', 'channels.edit.unavailableService': '不可用的服务', 'channels.edit.servicesError': '无法加载服务,请重试后再保存。', - 'channels.edit.servicesEmpty': '当前授权范围内暂无可用服务。', + 'channels.edit.servicesEmpty': 'NyxID 账号中暂无 active 状态的可用服务。', 'channels.edit.missingServices': '部分已保存的服务已不可用,请取消勾选后再保存。', 'channels.edit.selectionError': '请检查选中的服务后重试。', @@ -206,10 +206,10 @@ export default { 'channels.connect.selectAll': '全选', 'channels.connect.selectAllResults': '全选搜索结果', 'channels.connect.servicesHelp': - '选择已授权的服务,或前往 NyxID 连接更多服务。', + '可选择 NyxID 账号中处于 active 状态的可用服务,无需在登录时勾选。', 'channels.connect.servicesLoading': '正在加载服务', 'channels.connect.servicesError': '无法加载服务,请重试后再连接。', - 'channels.connect.servicesEmpty': '当前 NyxID 授权范围内暂无可用服务。', + 'channels.connect.servicesEmpty': 'NyxID 账号中暂无 active 状态的可用服务。', 'channels.connect.serviceRequired': '必选', 'channels.connect.requiredServicesMissing': '必选服务不可用:{services}。请检查 NyxID 访问权限后重试。', @@ -247,16 +247,12 @@ export default { 'channels.connect.discard': '放弃', 'channels.connect.stay': '继续填写', 'channels.connect.discardHelp': '机器人令牌和未保存的选择将被清除。', - 'channels.access.manage': '管理服务权限', - 'channels.access.help': '缺少服务?点击「管理服务权限」补充授权。', - 'channels.access.needed': '需要补充服务权限', + 'channels.access.needed': '所需服务暂不可用', 'channels.access.instructions': - '在 NyxID 的 Service access 中点击 Customize。保留仍需使用的已选服务,勾选以下服务,再点击 Allow。', + '请在 NyxID 检查以下服务,账号中处于 active 状态的可用服务可直接在此选择。', 'channels.access.unknown': '未找到该服务', 'channels.access.requestedIdentity': '所需服务 ID', 'channels.access.unavailable': '请在 NyxID 检查服务是否可用', - 'channels.access.notAuthorized': '需要授权', - 'channels.access.restored': '修改已保留,确认后请保存。', - 'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。', + 'channels.access.notAuthorized': '账号暂无使用权限', 'channels.access.requested': '本次需要', }; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx index d912f83cd..72980489c 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx @@ -1,6 +1,5 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; -import { NyxIDAuthClient } from '@/shared/auth/client'; import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; @@ -73,10 +72,6 @@ const writes = () => fetchMock.mock.calls.filter(([, init]) => ['POST', 'PATCH'].includes(init?.method ?? ''), ); -const detailReads = () => - fetchMock.mock.calls.filter(([input]) => - String(input).endsWith(`/skills/${skillId}`), - ); function grant(ids = baseIds) { persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: ids })); } @@ -138,43 +133,22 @@ beforeEach(() => { }); }); -it('restores an unbound bot draft and fresh requested access after consent, then binds only on explicit submission and confirms the observed result', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - await screen.findByText('Service access needed'); - expect(screen.getByText('Firecrawl')).toBeInTheDocument(); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); +it('binds with active services outside login consent only after explicit selection and confirms the observed result', async () => { + grant([]); + const { queryClient } = mount(); + const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ }); + expect(firecrawl).toBeEnabled(); + expect(firecrawl).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Lark Bot API/ })).toBeEnabled(); expect( - screen.queryByRole('checkbox', { name: /Firecrawl/ }), + screen.queryByRole('button', { name: /Manage service access/ }), ).not.toBeInTheDocument(); + expect(screen.queryByText(/Missing a service/)).not.toBeInTheDocument(); + await screen.findByText('linked-default'); await chooseSupport(); fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => - expect(review).toHaveBeenCalledWith({ - flow: 'serviceAccessReview', - returnTo: bindHref(), - }), - ); - expect(writes()).toHaveLength(0); - first.unmount(); - - grant([...baseIds, 'us-firecrawl']); - const second = mount(); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); - expect(screen.queryByText('linked-default')).not.toBeInTheDocument(); - expect(detailReads()).toHaveLength(1); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); - const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ }); - expect(firecrawl).not.toBeChecked(); - expect(writes()).toHaveLength(0); fireEvent.click(firecrawl); + expect(writes()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: 'Bind bot' })); await screen.findByText('Confirming your changes...'); expect(writes()).toHaveLength(1); @@ -206,7 +180,7 @@ it('restores an unbound bot draft and fresh requested access after consent, then : normalFetch(input, init), ); await act(async () => { - await second.queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: ['channels', 'scope-alpha', 'confirmation', 'cmd-alpha'], }); }); @@ -217,107 +191,3 @@ it('restores an unbound bot draft and fresh requested access after consent, then '/scopes/scope-alpha/channels/reg-alpha', ); }); - -it('preserves an explicitly cleared default skill after cancelled consent without binding', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - await screen.findByText('linked-default'); - fireEvent.mouseDown(screen.getByRole('img', { name: 'close-circle' })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - mount(); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('Select a skill')).toBeInTheDocument(); - expect(detailReads()).toHaveLength(1); - expect(screen.getByText('Service access needed')).toBeInTheDocument(); - expect(writes()).toHaveLength(0); - expect(screen.getByRole('button', { name: 'Bind bot' })).toBeEnabled(); -}); - -it('removes a deleted service from the restored Bind draft, count and submission', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - await screen.findByText('Service access needed'); - await chooseSupport(); - fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - const normalFetch = fetchMock.getMockImplementation(); - if (!normalFetch) throw new Error('Missing request fixture'); - fetchMock.mockImplementation((input, init) => - String(input).endsWith('/user-services') - ? Promise.resolve( - response({ - services: inventory.filter((item) => item.id !== 'us-github'), - }), - ) - : normalFetch(input, init), - ); - mount(); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); - expect( - screen.queryByRole('checkbox', { name: /GitHub|us-github/ }), - ).not.toBeInTheDocument(); - expect( - screen.queryByText('Unavailable', { exact: true }), - ).not.toBeInTheDocument(); - expect(screen.getByText('2 selected')).toBeInTheDocument(); - expect(writes()).toHaveLength(0); - fireEvent.click(screen.getByRole('button', { name: 'Bind bot' })); - await screen.findByText('Confirming your changes...'); - expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ - nyx_channel_bot_id: 'bot-alpha', - skill_name: 'support', - authorization_mode: 'explicit_service_allowlist', - service_ids: ['us-llm', 'us-ornn'], - }); -}); - -it('isolates bind drafts by bot and from edit registrations even when their raw IDs coincide', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(bindHref('reg-alpha')); - await screen.findByText('Service access needed'); - await chooseSupport(); - fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - const other = mount(bindHref('bot-beta')); - await screen.findByText('linked-default'); - expect( - screen.queryByText(/Your changes have been kept/), - ).not.toBeInTheDocument(); - expect( - await screen.findByRole('checkbox', { name: /GitHub/ }), - ).not.toBeChecked(); - other.unmount(); - const edit = mount('/scopes/scope-alpha/channels/reg-alpha/edit'); - await screen.findByText('saved-skill'); - expect( - screen.queryByText(/Your changes have been kept/), - ).not.toBeInTheDocument(); - expect( - await screen.findByRole('checkbox', { name: /GitHub/ }), - ).not.toBeChecked(); - edit.unmount(); - mount(bindHref('reg-alpha')); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); - expect(writes()).toHaveLength(0); -}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx index 45cd2bba6..add475648 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx @@ -17,8 +17,6 @@ import { channelConfiguration, channelsApi, } from '@/shared/api/channelsApi'; -import { NyxIDAuthClient } from '@/shared/auth/client'; -import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { t } from '@/shared/i18n/messages'; import { history } from '@/shared/navigation/history'; import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; @@ -44,14 +42,8 @@ import { channelKeys, useChannelDetail, useChannelRegistrations, - useChannelServiceAccess, + useChannelServices, } from './queries'; -import { - type ChannelAccessDraftTarget, - clearChannelAccessDraft, - readChannelAccessDraft, - saveChannelAccessDraft, -} from './serviceAccessDraft'; import { channelsCss } from './styles'; type Target = @@ -77,13 +69,6 @@ export default function ChannelConfigurationPage({ readonly requestedServiceIds?: readonly string[]; }) { const editing = Boolean(target.registrationId); - const draftTarget = React.useMemo( - () => - target.botId !== undefined - ? { kind: 'bind', botId: target.botId } - : { kind: 'edit', registrationId: target.registrationId }, - [target.botId, target.registrationId], - ); const list = useChannelRegistrations(scopeId, !editing); const detail = useChannelDetail(scopeId, target.registrationId ?? ''); const query = editing ? detail : list; @@ -170,7 +155,7 @@ export default function ChannelConfigurationPage({ void> >; }) { - const editing = draftTarget.kind === 'edit'; const baseline = channelConfiguration(initial); - const [restoredDraft] = React.useState(() => - readChannelAccessDraft(scopeId, draftTarget), - ); - const [reviewPending, setReviewPending] = React.useState(false); - const reviewLeaving = React.useRef(false); - const reviewLock = React.useRef(false); const requestedIds = [ ...new Set( requestedServiceIds @@ -229,10 +207,8 @@ function ConfigurationForm({ ].slice(0, 20); const [initialSkillId] = React.useState(editing ? undefined : defaultSkillId); // Undefined means the link's default has not been resolved or overridden yet. - const [skillName, setSkillName] = React.useState( - () => - restoredDraft?.skillName ?? - (initialSkillId ? undefined : (initial.skill?.name ?? '')), + const [skillName, setSkillName] = React.useState(() => + initialSkillId ? undefined : (initial.skill?.name ?? ''), ); const skillReady = skillName !== undefined; const defaultSkill = useQuery({ @@ -260,11 +236,9 @@ function ConfigurationForm({ defaultSkill.data, ]); const [chosenServiceIds, setServiceIds] = React.useState( - restoredDraft?.serviceIds ?? baseline?.serviceIds ?? [], - ); - const [label, setLabel] = React.useState( - restoredDraft?.label ?? initial.label ?? '', + baseline?.serviceIds ?? [], ); + const [label, setLabel] = React.useState(initial.label ?? ''); const [savedLabel, setSavedLabel] = React.useState(initial.label ?? ''); const [receipt, setReceipt] = React.useState(null); const [submitted, setSubmitted] = React.useState( @@ -279,31 +253,16 @@ function ConfigurationForm({ const completed = React.useRef(false); const mounted = React.useRef(true); const labelId = React.useId(); - const services = useChannelServiceAccess(scopeId); - React.useEffect(() => { - if (restoredDraft) clearChannelAccessDraft(scopeId, draftTarget); - }, [restoredDraft, scopeId, draftTarget]); - React.useEffect(() => { - const resume = (event: PageTransitionEvent) => { - if (!event.persisted || !reviewLeaving.current) return; - reviewLeaving.current = false; - reviewLock.current = false; - setReviewPending(false); - clearChannelAccessDraft(scopeId, draftTarget); - void services.refetch(); - }; - window.addEventListener('pageshow', resume); - return () => window.removeEventListener('pageshow', resume); - }, [services.refetch, scopeId, draftTarget]); + const services = useChannelServices(scopeId); const availableServices = (services.data ?? []).filter( (service) => service.active && service.allowed, ); - const hasFreshServiceAccess = + const hasFreshServices = services.isFetchedAfterMount && services.isSuccess && !services.isFetching; React.useEffect(() => { - if (!hasFreshServiceAccess) return; - // Only confirmed access changes remove draft choices. A failed or pending - // refresh must not erase them, and later reauthorization must not reselect them. + if (!hasFreshServices) return; + // Only a successful inventory refresh removes unavailable choices. Failed + // requests preserve edits; reactivated services require a new selection. const availableIds = new Set( services.data ?.filter((service) => service.active && service.allowed) @@ -313,7 +272,7 @@ function ConfigurationForm({ const remaining = selected.filter((id) => availableIds.has(id)); return remaining.length === selected.length ? selected : remaining; }); - }, [hasFreshServiceAccess, services.data]); + }, [hasFreshServices, services.data]); const requiredServices = availableServices.filter((service) => requiredServiceSlugs.some((slug) => service.slug === slug), ); @@ -325,14 +284,13 @@ function ConfigurationForm({ ...new Set([ ...chosenServiceIds.filter( (id) => - !hasFreshServiceAccess || + !hasFreshServices || availableServices.some((service) => service.id === id), ), ...requiredIds, ]), ]; - const servicesReady = - hasFreshServiceAccess && missingRequiredSlugs.length === 0; + const servicesReady = hasFreshServices && missingRequiredSlugs.length === 0; const toast = useConsoleToast(); const client = useQueryClient(); const listHref = buildWorkflowActivitySectionHref(scopeId, 'channels'); @@ -350,7 +308,7 @@ function ConfigurationForm({ const dirty = editing ? configDirty || labelDirty : Boolean(skillName || chosenServiceIds.length); - const busy = submitting || Boolean(receipt) || uncertain || reviewPending; + const busy = submitting || Boolean(receipt) || uncertain; React.useEffect(() => { mounted.current = true; return () => { @@ -373,7 +331,6 @@ function ConfigurationForm({ React.useEffect(() => { if (!dirty || receipt || uncertain) return; const warn = (event: BeforeUnloadEvent) => { - if (reviewLeaving.current) return; event.preventDefault(); event.returnValue = ''; }; @@ -431,53 +388,13 @@ function ConfigurationForm({ completed.current = true; client.setQueryData(channelKeys.detail(scopeId, actual.id), actual); void client.invalidateQueries({ queryKey: channelKeys.list(scopeId) }); - clearChannelAccessDraft(scopeId, draftTarget); toast.success( editing ? t('channels.edit.saved', 'Channel changes saved.') : t('channels.bind.success', 'Bot bound successfully.'), ); history.replace(buildChannelDetailsHref(scopeId, actual.id)); - }, [ - observation.data, - submitted, - initial, - editing, - client, - scopeId, - toast, - draftTarget, - ]); - - async function reviewServiceAccess() { - if (busy || reviewLock.current || !skillReady) return; - reviewLock.current = true; - setReviewPending(true); - try { - // Persist before navigation. If storage fails, keep the editor open. - saveChannelAccessDraft(scopeId, draftTarget, { - label, - skillName: skillName ?? '', - serviceIds, - }); - reviewLeaving.current = true; - await new NyxIDAuthClient(getNyxIDRuntimeConfig()).loginWithRedirect({ - flow: 'serviceAccessReview', - returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`, - }); - } catch { - reviewLeaving.current = false; - reviewLock.current = false; - setReviewPending(false); - clearChannelAccessDraft(scopeId, draftTarget); - toast.error( - t( - 'channels.access.startFailed', - 'Could not open NyxID. Your changes are still here. Try again.', - ), - ); - } - } + }, [observation.data, submitted, initial, editing, client, scopeId, toast]); async function save(event: React.FormEvent) { event.preventDefault(); @@ -536,7 +453,6 @@ function ConfigurationForm({ void client.invalidateQueries({ queryKey: channelKeys.detail(scopeId, initial.id ?? ''), }); - clearChannelAccessDraft(scopeId, draftTarget); toast.success(t('channels.edit.saved', 'Channel changes saved.')); history.replace(returnHref); } @@ -662,11 +578,6 @@ function ConfigurationForm({ ) : null} void reviewServiceAccess(), - pending: reviewPending, - disabled: busy || !skillReady, - }} accessNotice={ services.isFetchedAfterMount && !services.isFetching && @@ -674,7 +585,6 @@ function ConfigurationForm({ ) : undefined } @@ -696,7 +606,7 @@ function ConfigurationForm({ setLeaveTarget(null)} onOk={() => { if (leaveTarget) { - clearChannelAccessDraft(scopeId, draftTarget); history.push(leaveTarget); } setLeaveTarget(null); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx index 13bef286e..9bd06b62c 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx @@ -450,23 +450,12 @@ it('locks required services through individual, bulk and filtered deselection an it.each([ ['missing', 'ornn-api'], ['inactive', 'chrono-llm-public'], - ['unauthorized', 'ornn-api'], + ['account access denied', 'ornn-api'], ['similar slug', 'chrono-llm-public'], ])('blocks submission with a %s required service and recovers on retry', async (condition, slug) => { jest.useFakeTimers(); try { let restored = false; - const unavailableId = serviceCatalogue.find( - (service) => service.slug === slug, - )?.id; - if (condition === 'unauthorized') - persistAuthSession( - createNyxIDServiceSession({ - allowed_service_ids: selectedServiceIds.filter( - (id) => id !== unavailableId, - ), - }), - ); const unavailableServices = serviceCatalogue .filter((service) => condition !== 'missing' || service.slug !== slug) .map((service) => @@ -474,6 +463,10 @@ it.each([ ? service : { ...service, + credential_source: + condition === 'account access denied' + ? { type: 'org', allowed: false } + : service.credential_source, is_active: condition !== 'inactive', slug: condition === 'similar slug' ? `${slug}-other` : slug, label: slug, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx index 17bccdde4..82b3e5f8e 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx @@ -1,12 +1,5 @@ -import { - act, - cleanup, - fireEvent, - screen, - waitFor, -} from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; -import { NyxIDAuthClient } from '@/shared/auth/client'; import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; @@ -116,170 +109,46 @@ beforeEach(() => { }); }); -it('shows exact missing services from the link without selecting or granting them, even when another account has the same slug', async () => { - grant([...baseIds, 'us-firecrawl-other']); +it('lets Edit select active services omitted at login and saves exact IDs without an access review', async () => { + grant([]); mount( `${editHref}&requiredServiceId=us-firecrawl&requiredServiceId=unknown-service`, ); - await screen.findByText('Service access needed'); - expect(screen.getAllByText('Firecrawl', { exact: true })).toHaveLength(1); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); - expect(screen.getByText('Service not found')).toBeInTheDocument(); - expect( - screen.getByText(/choose Customize under Service access/), - ).toBeInTheDocument(); - expect( - screen.queryByRole('checkbox', { name: /^Firecrawl/ }), - ).not.toBeInTheDocument(); + const firecrawl = await screen.findByRole('checkbox', { name: /^Firecrawl/ }); + expect(firecrawl).toBeEnabled(); + expect(firecrawl).not.toBeChecked(); expect( screen.getByRole('checkbox', { name: /Other Firecrawl account/ }), ).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); expect(screen.getByText('3 selected')).toBeInTheDocument(); - expect(writes()).toHaveLength(0); -}); - -it('restores the draft after full consent, checks fresh grants and requires selecting the newly available service before saving', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - fireEvent.change(await screen.findByLabelText('Label'), { - target: { value: 'Edited label' }, - }); - fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => - expect(review).toHaveBeenCalledWith({ - flow: 'serviceAccessReview', - returnTo: editHref, - }), - ); - expect(writes()).toHaveLength(0); - const leaving = new Event('beforeunload', { cancelable: true }); - window.dispatchEvent(leaving); - expect(leaving.defaultPrevented).toBe(false); - first.unmount(); - grant([...baseIds, 'us-firecrawl']); - mount(); - expect(await screen.findByLabelText('Label')).toHaveValue('Edited label'); + expect(screen.getByText('Service not found')).toBeInTheDocument(); expect( - await screen.findByText(/Your changes have been kept/), - ).toBeInTheDocument(); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); - expect(screen.queryByText('Service access checked')).not.toBeInTheDocument(); - const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ }); - expect(firecrawl).not.toBeChecked(); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked(); + screen.queryByRole('button', { name: /Manage service access/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/choose Customize under Service access/), + ).not.toBeInTheDocument(); + expect(writes()).toHaveLength(0); fireEvent.click(firecrawl); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await screen.findByText('Confirming your changes...'); - const post = writes().find(([, init]) => init?.method === 'POST'); - expect(JSON.parse(String(post?.[1]?.body))).toMatchObject({ - service_ids: ['us-firecrawl', 'us-llm', 'us-ornn'], + expect(writes()).toHaveLength(1); + expect(writes()[0][0]).toBe('/api/channels/registrations/reg-alpha'); + expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-firecrawl', 'us-github', 'us-llm', 'us-ornn'], }); expect(mockToast.success).not.toHaveBeenCalled(); }); -it('retains missing access and draft after cancellation, and recovers from a failed review launch', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - fireEvent.change(await screen.findByLabelText('Label'), { - target: { value: 'Edited label' }, - }); - fireEvent.click( - await screen.findByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - mount(); - expect( - await screen.findByText(/Your changes have been kept/), - ).toBeInTheDocument(); - expect(screen.getByText('Service access needed')).toBeInTheDocument(); - expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - review.mockRejectedValueOnce(new Error('TEST_PRIVATE_ERROR')); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => - expect(mockToast.error).toHaveBeenCalledWith( - 'Could not open NyxID. Your changes are still here. Try again.', - ), - ); - expect( - screen.getByRole('button', { name: /Manage service access/ }), - ).toBeEnabled(); - expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - const leaving = new Event('beforeunload', { cancelable: true }); - window.dispatchEvent(leaving); - expect(leaving.defaultPrevented).toBe(true); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(3)); - expect(writes()).toHaveLength(0); -}); - -it('keeps the editor open when draft storage is unavailable and does not restore a draft for another account', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - fireEvent.change(await screen.findByLabelText('Label'), { - target: { value: 'Edited label' }, - }); - const storage = jest - .spyOn(Storage.prototype, 'setItem') - .mockImplementation(() => { - throw new Error('Storage blocked'); - }); - fireEvent.click( - await screen.findByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(mockToast.error).toHaveBeenCalled()); - expect(review).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - storage.mockRestore(); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - const other = createNyxIDServiceSession({ - sub: 'user-other', - allowed_service_ids: baseIds, - }); - persistAuthSession({ ...other, user: { sub: 'user-other' } }); - mount(); - expect(await screen.findByLabelText('Label')).toHaveValue('Support bot'); - await screen.findByText('Service access needed'); - expect( - screen.queryByText(/Your changes have been kept/), - ).not.toBeInTheDocument(); -}); - -it('preserves choices on failed history refresh, then removes revoked selections without reselecting them on later authorization', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); +it('preserves edits during failed inventory refresh, then removes inactive choices without reselecting them on reactivation', async () => { const { queryClient } = mount(); fireEvent.change(await screen.findByLabelText('Label'), { target: { value: 'Edited label' }, }); - await screen.findByText('Service access needed'); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - const draftKeys = () => - Object.keys(window.sessionStorage).filter((key) => - key.startsWith('aevatar:channel-access-draft:'), - ); - expect(draftKeys()).toHaveLength(1); + await screen.findByRole('checkbox', { name: /GitHub/ }); const normalFetch = fetchMock.getMockImplementation(); if (!normalFetch) throw new Error('Missing request fixture'); fetchMock.mockImplementation((input, init) => { @@ -287,34 +156,34 @@ it('preserves choices on failed history refresh, then removes revoked selections return Promise.resolve({ ok: false, status: 503 } as Response); return normalFetch(input, init); }); - grant(['us-ornn', 'us-llm', 'us-firecrawl']); - act(() => { - const restored = new Event('pageshow'); - Object.defineProperty(restored, 'persisted', { value: true }); - window.dispatchEvent(restored); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: channelKeys.services('scope-alpha'), + }); }); await screen.findByText( 'Could not load your services. Try again before saving.', ); expect(screen.getByText('3 selected')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); - fetchMock.mockImplementation(normalFetch); + fetchMock.mockImplementation((input, init) => + String(input).endsWith('/user-services') + ? Promise.resolve( + response({ + services: inventory.map((item) => + item.id === 'us-github' ? { ...item, is_active: false } : item, + ), + }), + ) + : normalFetch(input, init), + ); fireEvent.click(screen.getByRole('button', { name: 'Try again' })); await screen.findByText('2 selected'); expect( screen.queryByRole('checkbox', { name: /GitHub/ }), ).not.toBeInTheDocument(); - expect( - screen.queryByText('Unavailable', { exact: true }), - ).not.toBeInTheDocument(); - expect(draftKeys()).toHaveLength(0); expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked(); - expect( - screen.getByRole('button', { name: /Manage service access/ }), - ).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled(); - grant([...baseIds, 'us-firecrawl']); + fetchMock.mockImplementation(normalFetch); await act(async () => { await queryClient.invalidateQueries({ queryKey: channelKeys.services('scope-alpha'), @@ -335,8 +204,8 @@ it('preserves choices on failed history refresh, then removes revoked selections ]); }); -it('removes saved missing, inactive and unauthorized services on initial load while retaining URL access hints', async () => { - grant(baseIds.filter((id) => id !== 'us-github').concat('us-firecrawl')); +it('removes saved missing and inactive services while preserving active selections outside login consent', async () => { + grant([]); const normalFetch = fetchMock.getMockImplementation(); if (!normalFetch) throw new Error('Missing request fixture'); const saved = { @@ -359,22 +228,19 @@ it('removes saved missing, inactive and unauthorized services on initial load wh return normalFetch(input, init); }); mount(); - await screen.findByText('Service access needed'); + await screen.findByText('Requested services unavailable'); expect(screen.getByText('Firecrawl', { exact: true })).toBeInTheDocument(); expect( - screen.queryByRole('checkbox', { name: /GitHub|Firecrawl|us-deleted/ }), + screen.queryByRole('checkbox', { name: /^Firecrawl|us-deleted/ }), ).not.toBeInTheDocument(); - expect( - screen.queryByText('Unavailable', { exact: true }), - ).not.toBeInTheDocument(); - expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); + expect(screen.getByText('3 selected')).toBeInTheDocument(); expect(writes()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await screen.findByText('Confirming your changes...'); expect(JSON.parse(String(writes()[0][1]?.body)).service_ids).toEqual([ + 'us-github', 'us-llm', 'us-ornn', ]); }); - -afterEach(cleanup); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx index 30c594f26..44fd2e430 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx @@ -6,11 +6,9 @@ import { t } from '@/shared/i18n/messages'; export default function ChannelServiceAccessNotice({ requestedIds, services, - restored, }: { readonly requestedIds: readonly string[]; readonly services: readonly ChannelServiceChoice[]; - readonly restored: boolean; }) { const missing = requestedIds.filter( (id) => @@ -18,73 +16,63 @@ export default function ChannelServiceAccessNotice({ (service) => service.id === id && service.active && service.allowed, ), ); - const draftNotice = restored ? ( -

- {t( - 'channels.access.restored', - 'Your changes have been kept. Review and save.', - )} -

- ) : null; - if (!missing.length) return draftNotice; + if (!missing.length) return null; return ( - <> - {draftNotice} -
-
-
-

- {t( - 'channels.access.instructions', - 'In NyxID, choose Customize under Service access. Keep the services you still use selected and add the services below, then choose Allow.', - )} -

-
    - {missing.map((id) => { - const service = services.find((item) => item.id === id); - return ( -
  • - - - {service?.label ?? - t('channels.access.unknown', 'Service not found')} - - {service ? ( - - {service.slug} - - ) : ( -
    - - {t( - 'channels.access.requestedIdentity', - 'Requested service ID', - )} - - {id} -
    - )} -
    - - {!service || !service.active - ? t( - 'channels.access.unavailable', - 'Check availability in NyxID', - ) - : t('channels.access.notAuthorized', 'Access needed')} - -
  • - ); - })} -
+
+
+
- +

+ {t( + 'channels.access.instructions', + 'Check these services in NyxID. Active services your account can use can be selected here.', + )} +

+
    + {missing.map((id) => { + const service = services.find((item) => item.id === id); + return ( +
  • + + + {service?.label ?? + t('channels.access.unknown', 'Service not found')} + + {service ? ( + {service.slug} + ) : ( +
    + + {t( + 'channels.access.requestedIdentity', + 'Requested service ID', + )} + + {id} +
    + )} +
    + + {!service || !service.active + ? t( + 'channels.access.unavailable', + 'Check availability in NyxID', + ) + : t( + 'channels.access.notAuthorized', + 'Account access unavailable', + )} + +
  • + ); + })} +
+
); } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx index e0bd5d8e0..50788dbff 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx @@ -1,8 +1,4 @@ -import { - ExportOutlined, - SafetyCertificateOutlined, - SearchOutlined, -} from '@ant-design/icons'; +import { SearchOutlined } from '@ant-design/icons'; import { Button, Checkbox, Input } from 'antd'; import * as React from 'react'; import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; @@ -11,7 +7,6 @@ import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; export default function ChannelServicePicker({ services, - accessAction, accessNotice, suggestedIds = [], selectedIds, @@ -27,11 +22,6 @@ export default function ChannelServicePicker({ replacesDefaults = false, suggestions, }: { - readonly accessAction?: { - readonly onReview: () => void; - readonly pending: boolean; - readonly disabled: boolean; - }; readonly accessNotice?: React.ReactNode; readonly suggestedIds?: readonly string[]; readonly services: readonly ChannelServiceChoice[]; @@ -69,35 +59,17 @@ export default function ChannelServicePicker({

{t('channels.connect.services', 'Services')}

-
- - {t('channels.connect.selected', '{count} selected', { - count: selectedIds.length, - })} - - {accessAction ? ( - - ) : null} -
+ + {t('channels.connect.selected', '{count} selected', { + count: selectedIds.length, + })} +

- {accessAction - ? t( - 'channels.access.help', - 'Missing a service? Click Manage service access to authorize it.', - ) - : t( - 'channels.connect.servicesHelp', - 'Only services available through your current NyxID authorization are shown.', - )} + {t( + 'channels.connect.servicesHelp', + 'Choose active services from your NyxID account, including services you did not select when signing in.', + )}

{accessNotice} {suggestions} @@ -129,11 +101,11 @@ export default function ChannelServicePicker({ {editing ? t( 'channels.edit.servicesEmpty', - 'No services are available with your current authorization.', + 'No active services are available in your NyxID account.', ) : t( 'channels.connect.servicesEmpty', - 'No services are available with your current NyxID authorization.', + 'No active services are available in your NyxID account.', )} ) : ( diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx index 2aca31440..9fa27f9d8 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx @@ -213,21 +213,19 @@ it('displays backend recommendations and access gaps, then saves only the explic expect(related.getAllByText(/may be needed for some tasks/)).not.toHaveLength( 0, ); - expect( - related.getByText(/Not authorized for this session/), - ).toBeInTheDocument(); + expect(related.getByRole('button', { name: 'Select Linear' })).toBeEnabled(); expect(related.getByText(/Inactive —/)).toBeInTheDocument(); expect(related.getByText(/Access unavailable —/)).toBeInTheDocument(); expect(related.getAllByText(/No connection found/)).toHaveLength(2); expect( - related.queryByRole('button', { name: 'Select Linear' }), + related.queryByRole('button', { name: 'Select Owner service' }), ).not.toBeInTheDocument(); expect( related.getByRole('link', { name: /Manage connections/ }), ).toHaveAttribute('href', 'https://nyx.chrono-ai.fun/services'); expect( - related.getByRole('link', { name: /Review service access/ }), - ).toHaveAttribute('href', '/scopes/scope-alpha/settings?section=account'); + related.queryByRole('link', { name: /Review service access/ }), + ).not.toBeInTheDocument(); expect( fetchMock.mock.calls.some(([url]) => /\/skills\/.*\/json|\/catalog\?/.test(String(url)), @@ -235,8 +233,9 @@ it('displays backend recommendations and access gaps, then saves only the explic ).toBe(false); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); expect(writes()).toHaveLength(0); + fireEvent.click(related.getByRole('button', { name: 'Select Linear' })); fireEvent.click(related.getByRole('button', { name: 'Select Team GitHub' })); - expect(related.getByText('Selected')).toBeInTheDocument(); + expect(related.getAllByText('Selected')).toHaveLength(2); expect( screen.getByRole('checkbox', { name: /Personal GitHub/ }), ).not.toBeChecked(); @@ -247,7 +246,7 @@ it('displays backend recommendations and access gaps, then saves only the explic expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ skill_name: 'support', authorization_mode: 'explicit_service_allowlist', - service_ids: ['us-llm', 'us-manual', 'us-org', 'us-ornn'], + service_ids: ['us-linear', 'us-llm', 'us-manual', 'us-org', 'us-ornn'], }); }); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx index 93fb3113c..32b68322d 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx @@ -5,7 +5,6 @@ import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; import { getChannelSkillServices } from '@/shared/api/channelSkillServicesApi'; import { t } from '@/shared/i18n/messages'; import { AevatarLoadingDots } from '@/shared/ui/AevatarLoading'; -import { buildWorkflowActivitySettingsHref } from '../navigation'; import { channelKeys } from './queries'; export default function ChannelSkillServices({ @@ -39,7 +38,11 @@ export default function ChannelSkillServices({ refetchOnReconnect: false, }); if (!skillName) return null; - const authorizedIds = new Set(services.map((service) => service.id)); + const availableIds = new Set( + services + .filter((service) => service.active && service.allowed) + .map((service) => service.id), + ); const refreshing = query.isFetching || checkingAccess; return (
@@ -108,7 +111,7 @@ export default function ChannelSkillServices({ {recommendation.instances.length ? ( recommendation.instances.map((instance) => { const selected = selectedIds.includes(instance.id); - const selectable = authorizedIds.has(instance.id); + const selectable = availableIds.has(instance.id); return (
{t('channels.suggestions.manage', 'Manage connections in NyxID ↗')} - - {t('channels.suggestions.reviewAccess', 'Review service access ↗')} -
); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts index 1126c520f..dcc849730 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts @@ -36,8 +36,6 @@ export const channelConnectionCss = ` .channels__suggestion-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; } .channels__suggestion-links a { color: var(--wa-blue); font-size: 11px; line-height: 18px; } .channels__suggestion-links a:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } -.channels__services-actions > span { color: var(--wa-blue); font-size: 11px; } -.channels__services-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-end; } .channels__access-notice { background: var(--wa-blue-bg); border: 1px solid var(--channels-border); border-left: 3px solid var(--wa-blue); border-radius: var(--wa-radius); color: var(--wa-ink); font-size: 12px; line-height: 20px; margin-top: 16px; padding: 16px; } .channels__access-notice--needed { background: var(--wa-amber-bg); border-left-color: var(--wa-amber); } .channels__access-notice-heading { align-items: center; display: flex; gap: 8px; } @@ -79,7 +77,6 @@ export const channelConnectionCss = ` .channels__bot-summary .channels__dot { display: none; } @media (max-width: 767px) { .channels__services-heading { align-items: flex-start; flex-wrap: wrap; } - .channels__services-actions { flex: 1 1 220px; } .channels__name-fields { gap: 0; grid-template-columns: 1fr; } .channels__main--connect .channels__content { padding-top: 20px; } .channels__connect-heading h1 { font-size: 24px; line-height: 32px; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts index edc2e08af..3b2737304 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts @@ -1,12 +1,12 @@ import { useQuery } from '@tanstack/react-query'; -import { listChannelServiceAccess } from '@/shared/api/channelServicesApi'; +import { listChannelServices } from '@/shared/api/channelServicesApi'; import { ChannelApiError, channelsApi } from '@/shared/api/channelsApi'; export const channelKeys = { detail: (scopeId: string, id: string) => ['channels', scopeId, 'detail', id] as const, services: (scopeId: string) => - ['channels', scopeId, 'service-access'] as const, + ['channels', scopeId, 'service-inventory'] as const, skills: (scopeId: string, search: string) => ['channels', scopeId, 'skills', search] as const, skill: (scopeId: string, id: string) => @@ -60,11 +60,11 @@ export function useChannelDetail(scopeId: string, id: string) { }); } -export function useChannelServiceAccess(scopeId: string) { +export function useChannelServices(scopeId: string) { return useQuery({ ...queryOptions, queryKey: channelKeys.services(scopeId), - queryFn: ({ signal }) => listChannelServiceAccess(signal), + queryFn: ({ signal }) => listChannelServices(signal), enabled: Boolean(scopeId), refetchOnMount: 'always', }); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts deleted file mode 100644 index 50f371a5b..000000000 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { loadRestorableAuthSession } from '@/shared/auth/session'; - -export type ChannelAccessDraftTarget = - | { readonly kind: 'bind'; readonly botId: string } - | { readonly kind: 'edit'; readonly registrationId: string }; - -export interface ChannelAccessDraft { - readonly label: string; - readonly skillName: string; - readonly serviceIds: readonly string[]; -} - -function storageKey(scopeId: string, target: ChannelAccessDraftTarget): string { - const subject = loadRestorableAuthSession()?.user.sub; - if (!subject) throw new Error('A signed-in account is required.'); - const id = target.kind === 'bind' ? target.botId : target.registrationId; - return `aevatar:channel-access-draft:${JSON.stringify([subject, scopeId, target.kind, id])}`; -} - -export function saveChannelAccessDraft( - scopeId: string, - target: ChannelAccessDraftTarget, - draft: ChannelAccessDraft, -): void { - window.sessionStorage.setItem( - storageKey(scopeId, target), - JSON.stringify({ ...draft, expiresAt: Date.now() + 60 * 60 * 1000 }), - ); -} - -export function clearChannelAccessDraft( - scopeId: string, - target: ChannelAccessDraftTarget, -): void { - try { - window.sessionStorage.removeItem(storageKey(scopeId, target)); - } catch { - // A blocked browser store must not prevent leaving or saving the editor. - } -} - -export function readChannelAccessDraft( - scopeId: string, - target: ChannelAccessDraftTarget, -): ChannelAccessDraft | null { - try { - const raw = window.sessionStorage.getItem(storageKey(scopeId, target)); - if (!raw) return null; - const draft: unknown = JSON.parse(raw); - if (!draft || typeof draft !== 'object') return null; - if ( - !('expiresAt' in draft) || - typeof draft.expiresAt !== 'number' || - draft.expiresAt <= Date.now() - ) { - clearChannelAccessDraft(scopeId, target); - return null; - } - if ( - !('label' in draft) || - typeof draft.label !== 'string' || - !('skillName' in draft) || - typeof draft.skillName !== 'string' || - !('serviceIds' in draft) || - !Array.isArray(draft.serviceIds) || - !draft.serviceIds.every((id): id is string => typeof id === 'string') - ) - return null; - return { - label: draft.label, - skillName: draft.skillName, - serviceIds: draft.serviceIds, - }; - } catch { - return null; - } -} diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts index ca02c947d..0b74c10f2 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts @@ -2,8 +2,8 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; import { - listChannelServiceAccess, listChannelServiceIdentities, + listChannelServices, } from './channelServicesApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); @@ -54,7 +54,7 @@ afterEach(() => { global.fetch = originalFetch; }); -it('includes authorized LLM services and matches exact UserService grants without requiring an MCP tool entry', async () => { +it('lists account services independently of login grants while preserving exact IDs and organization access', async () => { const session = createNyxIDServiceSession({ allowed_service_ids: [ 'us-work', @@ -108,19 +108,21 @@ it('includes authorized LLM services and matches exact UserService grants withou }), ); const signal = new AbortController().signal; - const result = await listChannelServiceAccess(signal); + const result = await listChannelServices(signal); expect( result .filter((service) => service.active && service.allowed) .map(({ id, label }) => ({ id, label })), ).toEqual([ { id: 'us-work', label: 'GitHub work' }, + { id: 'us-other-account-key', label: 'GitHub work' }, + { id: 'us-catalog-reference', label: 'GitHub work' }, { id: 'us-model', label: 'Chrono Public' }, { id: 'us-org', label: 'Google Drive' }, ]); expect( result.find((service) => service.id === 'us-other-account-key')?.allowed, - ).toBe(false); + ).toBe(true); expect(result.find((service) => service.id === 'us-viewer')?.allowed).toBe( false, ); @@ -140,7 +142,7 @@ it('includes authorized LLM services and matches exact UserService grants withou it.each([ true, false, -])('respects explicit allow_all_services=%s with an empty ID list', async (allowAll) => { +])('keeps active account services selectable with allow_all_services=%s and no login service selections', async (allowAll) => { persistAuthSession( createNyxIDServiceSession({ allow_all_services: allowAll, @@ -148,36 +150,21 @@ it.each([ }), ); fetchMock.mockResolvedValue(response({ services: [personal] })); - const result = await listChannelServiceAccess(); + const result = await listChannelServices(); expect(result.map(({ id, allowed }) => ({ id, allowed }))).toEqual([ - { id: 'us-work', allowed: allowAll }, + { id: 'us-work', allowed: true }, ]); }); -it.each([ - ['missing grant mode', { allow_all_services: undefined }], - ['malformed ID list', { allowed_service_ids: ['us-work', null] }], - ['wrong account subject', { sub: 'another-user' }], - ['expired JWT', { exp: 1 }], -] as const)('fails closed for %s without exposing token payloads or reading the inventory', async (_name, claims) => { - persistAuthSession( - createNyxIDServiceSession({ ...claims, note: 'TEST_ONLY_SECRET' }), - ); - await expect(listChannelServiceAccess()).rejects.toThrow( - 'Could not read the current NyxID service authorization.', - ); - expect(fetchMock).not.toHaveBeenCalled(); -}); - -it('does not treat locally decoded claims as successful server authorization', async () => { +it('propagates rejected NyxID authentication without returning inventory', async () => { persistAuthSession(createNyxIDServiceSession({ allow_all_services: true })); fetchMock.mockResolvedValue(response({ services: [personal] }, 401)); - await expect(listChannelServiceAccess()).rejects.toMatchObject({ + await expect(listChannelServices()).rejects.toMatchObject({ status: 401, }); }); -it('refreshes an expired session before filtering and pins the inventory request to the refreshed bearer', async () => { +it('refreshes an expired session before reading account services and pins the inventory request to the refreshed bearer', async () => { const old = createNyxIDServiceSession({ allowed_service_ids: ['us-old'] }); const fresh = createNyxIDServiceSession({ allowed_service_ids: ['us-work'] }); persistAuthSession({ @@ -200,10 +187,10 @@ it('refreshes an expired session before filtering and pins the inventory request response({ services: [personal, { ...personal, id: 'us-old' }] }), ); expect( - (await listChannelServiceAccess()) + (await listChannelServices()) .filter((service) => service.active && service.allowed) .map(({ id }) => id), - ).toEqual(['us-work']); + ).toEqual(['us-work', 'us-old']); expect(tokenFetch).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( inventoryPath, diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts index 7713e7160..b5ea3cee0 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts @@ -1,7 +1,6 @@ import { ensureActiveAuthSession } from '@/shared/auth/client'; import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { authFetch } from '@/shared/auth/fetch'; -import { readAccessTokenServiceGrants } from '@/shared/auth/serviceGrants'; import { ChannelApiError } from './channelsApi'; import { expectArray, @@ -34,8 +33,8 @@ function decodeService(value: unknown): ChannelServiceChoice { if (!id.trim() || !slug.trim()) throw new Error('Missing service identity.'); const personal = source.type === 'personal'; const organization = source.type === 'org'; - // This is account-level availability only. Current bearer access is checked - // separately against the current access token before returning choices. + // Channel selection uses account availability, independently of the services + // selected when signing in. Organization membership still controls access. return { id, slug, @@ -90,25 +89,12 @@ export async function listChannelServiceIdentities( return services.map(({ id, slug, label }) => ({ id, slug, label })); } -export async function listChannelServiceAccess( +// NyxID owns the account inventory. Login consent limits the console session's +// proxy access, not the explicit service selection for a channel's Agent Key. +export async function listChannelServices( signal?: AbortSignal, ): Promise { const session = await ensureActiveAuthSession(); if (!session) throw new ChannelApiError(401); - const grants = readAccessTokenServiceGrants( - session.tokens.accessToken, - session.user.sub, - ); - // Pin inventory and selectable choices to the same authenticated bearer. - const services = await readChannelServiceInventory( - session.tokens.accessToken, - signal, - ); - const authorizedIds = new Set(grants.allowedServiceIds); - return services.map((service) => ({ - ...service, - allowed: - service.allowed && - (grants.allowAllServices || authorizedIds.has(service.id)), - })); + return readChannelServiceInventory(session.tokens.accessToken, signal); } diff --git a/apps/aevatar-console-web/src/shared/auth/serviceGrants.ts b/apps/aevatar-console-web/src/shared/auth/serviceGrants.ts deleted file mode 100644 index 28d7a9d49..000000000 --- a/apps/aevatar-console-web/src/shared/auth/serviceGrants.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - expectArray, - expectBoolean, - expectRecord, - expectString, -} from '@/shared/api/http/decoders'; - -export interface NyxIDServiceGrants { - readonly allowAllServices: boolean; - readonly allowedServiceIds: readonly string[]; -} - -// Display filtering only: this does not verify a JWT signature or authorize an -// operation. NyxID validates the same bearer on the inventory request, and the -// registration endpoint remains responsible for enforcing delegation limits. -export function readAccessTokenServiceGrants( - accessToken: string, - expectedSubject: string, -): NyxIDServiceGrants { - try { - const parts = accessToken.split('.'); - if (parts.length !== 3 || parts.some((part) => !part)) throw new Error(); - const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const bytes = Uint8Array.from(atob(payload), (character) => - character.charCodeAt(0), - ); - const claims = expectRecord( - JSON.parse(new TextDecoder().decode(bytes)), - 'Access claims', - ); - if ( - claims.token_type !== 'access' || - claims.relay === true || - !expectedSubject || - claims.sub !== expectedSubject || - typeof claims.exp !== 'number' || - !Number.isFinite(claims.exp) || - claims.exp * 1000 <= Date.now() - ) - throw new Error(); - const allowAllServices = expectBoolean( - claims.allow_all_services, - 'Service grant mode', - ); - const allowedServiceIds = expectArray( - claims.allowed_service_ids, - 'Service grants', - (value) => { - const id = expectString(value, 'User service ID'); - if (!id.trim()) throw new Error(); - return id; - }, - ); - return { allowAllServices, allowedServiceIds }; - } catch { - // JSON/parser errors must never expose any part of the bearer payload. - throw new Error('Could not read the current NyxID service authorization.'); - } -}