From 41b875da6f561b73ea70a6d7ef681cf36b5d8e7d Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:22:58 +0000 Subject: [PATCH] feat(npx-skill-connector): add service auth, integration tests, and catalog provider docs Add httpAuth dependency to the npx skill connector plugin to make service-to-service authentication intent explicit. Add per-source diagnostic logging at startup for operational visibility. Add startConnector test helper and NpxConnectorTestConfig type to testUtils following the OCI connector pattern. Add comprehensive integration test suite covering service auth enforcement, unknown source handling, snapshot lifecycle (loading/ready/empty/failed/partial states), source isolation, and scheduled refresh. Expand README with catalog provider integration guide, combined OCI+npx source configuration, and RHESS demo instructions. Ref: RHIDP-18493 Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 --- workspaces/ai-integrations/app-config.yaml | 4 +- .../npx-skill-connector-backend/README.md | 139 ++++++ .../src/plugin.integration.test.ts | 428 ++++++++++++++++++ .../npx-skill-connector-backend/src/plugin.ts | 10 +- .../src/testUtils.ts | 39 ++ 5 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.integration.test.ts diff --git a/workspaces/ai-integrations/app-config.yaml b/workspaces/ai-integrations/app-config.yaml index 9ac72164ee..9c97ef5714 100644 --- a/workspaces/ai-integrations/app-config.yaml +++ b/workspaces/ai-integrations/app-config.yaml @@ -194,8 +194,8 @@ catalog: - connectorPluginId: skill-image-connector sourceId: skillimage-skills sourceType: oci - # Add this source after enabling npxSkillConnector below to show its - # verified skills in the catalog. + # Uncomment after enabling npxSkillConnector below to show its + # verified skills in the catalog alongside OCI sources. # - connectorPluginId: npx-skill-connector # sourceId: public-agent-skills # sourceType: npx diff --git a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/README.md b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/README.md index 2342e4c245..76d51097ff 100644 --- a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/README.md +++ b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/README.md @@ -7,6 +7,12 @@ verified against their index SHA-256 digest before decoding, and normalized usin the shared metadata normalizer. Snapshots reflect actual discovered skills, or partial/failed status on errors. +All endpoints require Backstage service-to-service authentication. + +## Configuration + +### Source configuration + Configure sources in backend `app-config.yaml`: ```yaml @@ -29,3 +35,136 @@ five redirects, a 5 MiB index limit, and a 1 MiB skill file limit. The connector does not inspect destination addresses. Deployments that need to restrict destinations should use a network egress policy. + +### Integrating with the skills catalog provider + +The npx connector serves normalized snapshots that the common skills catalog +provider consumes to produce `AiResource` catalog entities. Wire both together +in `app-config.yaml`: + +```yaml +# 1. Configure the npx connector source. +npxSkillConnector: + sources: + - id: public-agent-skills + indexUrl: https://evilmartians.com/.well-known/agent-skills/index.json + schedule: + frequency: { minutes: 10 } + timeout: { minutes: 10 } + initialDelay: { seconds: 0 } + +# 2. Configure the catalog provider to consume the npx snapshot. +catalog: + providers: + skillsCatalogProvider: + production: + sources: + - connectorPluginId: npx-skill-connector + sourceId: public-agent-skills # Must match npxSkillConnector.sources[].id. + sourceType: npx + defaultOwner: team-skills + defaultLifecycle: production + schedule: + frequency: { minutes: 10 } + timeout: { minutes: 10 } +``` + +The `connectorPluginId` must be `npx-skill-connector` (the plugin ID registered +by this backend plugin). The `sourceId` must match the `id` field of one of +the configured npx sources. The provider resolves the connector's base URL +through Backstage service discovery and obtains a service token targeted to +`npx-skill-connector` for each snapshot request. + +npx skill entities do not receive a `backstage.io/techdocs-ref` annotation. + +### Combining OCI and npx sources + +Both source types use the same common provider. Configure them together for a +unified catalog view: + +```yaml +catalog: + providers: + skillsCatalogProvider: + production: + sources: + - connectorPluginId: skill-image-connector + sourceId: quay-public + sourceType: oci + - connectorPluginId: npx-skill-connector + sourceId: public-agent-skills + sourceType: npx + defaultOwner: team-skills + defaultLifecycle: production + schedule: + frequency: { minutes: 10 } + timeout: { minutes: 10 } +``` + +An HTTP or authentication failure for one source does not stop the other. +Each source has independent acquisition, reconciliation, and catalog ownership. + +## RHESS demo + +To run the demo with a public Agent Skills v0.2 index: + +1. Add the npx connector and the skills catalog provider to your backend + packages: + + ```ts + // packages/backend/src/index.ts + backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-npx-skill-connector-backend' + ), + ); + backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-catalog-backend-module-skills-catalog-provider' + ), + ); + ``` + +2. Add the configuration to `app-config.yaml`: + + ```yaml + npxSkillConnector: + sources: + - id: public-agent-skills + indexUrl: https://evilmartians.com/.well-known/agent-skills/index.json + schedule: + frequency: { minutes: 10 } + timeout: { minutes: 10 } + initialDelay: { seconds: 0 } + + catalog: + providers: + skillsCatalogProvider: + demo: + sources: + - connectorPluginId: npx-skill-connector + sourceId: public-agent-skills + sourceType: npx + defaultOwner: user:default/guest + defaultLifecycle: experimental + schedule: + frequency: { minutes: 1 } + timeout: { minutes: 10 } + initialDelay: { seconds: 0 } + ``` + +3. Start the backend with `yarn dev`. The connector snapshot will appear at + `/api/npx-skill-connector/skills/public-agent-skills` and the catalog + provider will create `AiResource` skill entities with `npx` + source/reference annotations within the first provider cycle. + +4. Verify the snapshot endpoint (requires a valid service token): + + ```bash + curl -H "Authorization: Bearer " \ + http://localhost:7007/api/npx-skill-connector/skills/public-agent-skills + ``` + + Before the first completed refresh, the endpoint returns `loading` with + empty arrays and `observedAt: null`. After a successful refresh, it returns + `ready` with normalized skill records and a completion timestamp. diff --git a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.integration.test.ts b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.integration.test.ts new file mode 100644 index 0000000000..fb564b144c --- /dev/null +++ b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.integration.test.ts @@ -0,0 +1,428 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import request from 'supertest'; +import { type NpxConnectorTestConfig, startConnector } from './testUtils'; +import { npxSkillConnectorPlugin } from './plugin'; + +jest.mock('./services/discoverIndex', () => ({ + discoverIndex: jest.fn(), +})); + +jest.mock('./services/processArtifacts', () => ({ + processArtifacts: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { discoverIndex } = require('./services/discoverIndex'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { processArtifacts } = require('./services/processArtifacts'); + +const logger = mockServices.logger.mock(); + +beforeEach(() => { + jest.resetAllMocks(); + logger.child.mockReturnValue(logger); +}); + +const defaultSource: NpxConnectorTestConfig = { + sources: [ + { + id: 'rhess', + indexUrl: 'https://skills.example.com/index.json', + schedule: { + frequency: { minutes: 10 }, + timeout: { minutes: 10 }, + initialDelay: { seconds: 0 }, + }, + }, + ], +}; + +describe('service authentication', () => { + it('rejects unauthenticated requests to GET /skills/:sourceId', async () => { + discoverIndex.mockResolvedValue({ + entries: [], + failedKeys: [], + hasUsablePage: true, + complete: true, + }); + processArtifacts.mockResolvedValue({ records: [], failedKeys: [] }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server).get( + '/api/npx-skill-connector/skills/rhess', + ); + + expect(response.status).toBe(401); + }); + + it('accepts authenticated requests to GET /skills/:sourceId', async () => { + discoverIndex.mockResolvedValue({ + entries: [], + failedKeys: [], + hasUsablePage: true, + complete: true, + }); + processArtifacts.mockResolvedValue({ records: [], failedKeys: [] }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/rhess') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(200); + }); +}); + +describe('unknown source handling', () => { + it('returns 404 for an unknown source ID', async () => { + discoverIndex.mockResolvedValue({ + entries: [], + failedKeys: [], + hasUsablePage: true, + complete: true, + }); + processArtifacts.mockResolvedValue({ records: [], failedKeys: [] }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/nonexistent') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Unknown source: nonexistent' }); + }); +}); + +describe('snapshot lifecycle', () => { + it('returns loading before the first refresh completes', async () => { + // Use a scheduler that does NOT run initial tasks on startup + const lazyScheduler = mockServices.scheduler.factory(); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + lazyScheduler, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/rhess') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'loading', + skills: [], + failedSkillKeys: [], + observedAt: null, + }); + }); + + it('returns ready with normalized records after refresh', async () => { + discoverIndex.mockResolvedValue({ + entries: [ + { + name: 'my-skill', + type: 'skill-md', + url: 'https://skills.example.com/my/SKILL.md', + digest: 'sha256:abc123', + }, + ], + failedKeys: [], + hasUsablePage: true, + complete: true, + }); + + processArtifacts.mockResolvedValue({ + records: [ + { + key: 'my-skill', + name: 'My Skill', + sourceUri: 'https://skills.example.com/my/SKILL.md', + digest: 'sha256:abc123', + extensions: { npx: { type: 'skill-md' } }, + }, + ], + failedKeys: [], + }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/rhess') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('ready'); + expect(response.body.skills).toHaveLength(1); + expect(response.body.skills[0].key).toBe('my-skill'); + expect(response.body.failedSkillKeys).toEqual([]); + expect(response.body.observedAt).toBeTruthy(); + }); + + it('returns ready with empty skills for a successful empty index', async () => { + discoverIndex.mockResolvedValue({ + entries: [], + failedKeys: [], + hasUsablePage: true, + complete: true, + }); + processArtifacts.mockResolvedValue({ records: [], failedKeys: [] }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/rhess') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'ready', + skills: [], + failedSkillKeys: [], + }); + expect(response.body.observedAt).toBeTruthy(); + }); +}); + +describe('atomic publication', () => { + it('publishes a failed snapshot when discovery returns no usable page', async () => { + discoverIndex.mockResolvedValue({ + entries: [], + failedKeys: [], + hasUsablePage: false, + complete: false, + }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/rhess') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('failed'); + expect(response.body.skills).toEqual([]); + expect(response.body.observedAt).toBeTruthy(); + }); + + it('publishes a partial snapshot with failed keys', async () => { + discoverIndex.mockResolvedValue({ + entries: [ + { + name: 'good-skill', + type: 'skill-md', + url: 'https://skills.example.com/good/SKILL.md', + digest: 'sha256:abc123', + }, + ], + failedKeys: ['discovery-fail'], + hasUsablePage: true, + complete: false, + }); + + processArtifacts.mockResolvedValue({ + records: [ + { + key: 'good-skill', + name: 'Good Skill', + sourceUri: 'https://skills.example.com/good/SKILL.md', + digest: 'sha256:abc123', + extensions: { npx: { type: 'skill-md' } }, + }, + ], + failedKeys: ['artifact-fail'], + }); + + const { server } = await startConnector( + defaultSource, + logger, + npxSkillConnectorPlugin, + ); + + const response = await request(server) + .get('/api/npx-skill-connector/skills/rhess') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('partial'); + expect(response.body.skills).toHaveLength(1); + expect(response.body.failedSkillKeys).toEqual( + expect.arrayContaining(['discovery-fail', 'artifact-fail']), + ); + }); +}); + +describe('source isolation', () => { + it('isolates failures between independent sources', async () => { + const multiSource: NpxConnectorTestConfig = { + sources: [ + { + id: 'source-a', + indexUrl: 'https://a.example.com/index.json', + schedule: { + frequency: { minutes: 10 }, + timeout: { minutes: 10 }, + initialDelay: { seconds: 0 }, + }, + }, + { + id: 'source-b', + indexUrl: 'https://b.example.com/index.json', + schedule: { + frequency: { minutes: 10 }, + timeout: { minutes: 10 }, + initialDelay: { seconds: 0 }, + }, + }, + ], + }; + + // source-a succeeds, source-b fails + discoverIndex.mockImplementation(async (source: { id: string }) => { + if (source.id === 'source-a') { + return { + entries: [ + { + name: 'skill-a', + type: 'skill-md', + url: 'https://a.example.com/skill/SKILL.md', + digest: 'sha256:aaa', + }, + ], + failedKeys: [], + hasUsablePage: true, + complete: true, + }; + } + // source-b: total discovery failure + return { + entries: [], + failedKeys: [], + hasUsablePage: false, + complete: false, + }; + }); + + processArtifacts.mockImplementation( + async (_entries: Array<{ name: string }>, source: { id: string }) => { + if (source.id === 'source-a') { + return { + records: [ + { + key: 'skill-a', + name: 'Skill A', + sourceUri: 'https://a.example.com/skill/SKILL.md', + digest: 'sha256:aaa', + extensions: { npx: { type: 'skill-md' } }, + }, + ], + failedKeys: [], + }; + } + return { records: [], failedKeys: [] }; + }, + ); + + const { server } = await startConnector( + multiSource, + logger, + npxSkillConnectorPlugin, + ); + + // source-a should be ready with records + const responseA = await request(server) + .get('/api/npx-skill-connector/skills/source-a') + .set('Authorization', mockCredentials.service.header()); + + expect(responseA.status).toBe(200); + expect(responseA.body.status).toBe('ready'); + expect(responseA.body.skills).toHaveLength(1); + expect(responseA.body.skills[0].key).toBe('skill-a'); + + // source-b should be failed, independent of source-a + const responseB = await request(server) + .get('/api/npx-skill-connector/skills/source-b') + .set('Authorization', mockCredentials.service.header()); + + expect(responseB.status).toBe(200); + expect(responseB.body.status).toBe('failed'); + expect(responseB.body.skills).toEqual([]); + }); +}); + +describe('scheduled refresh', () => { + it('registers scheduler tasks for each configured source', async () => { + discoverIndex.mockResolvedValue({ + entries: [], + failedKeys: [], + hasUsablePage: true, + complete: true, + }); + processArtifacts.mockResolvedValue({ records: [], failedKeys: [] }); + + await startConnector( + { + sources: [ + { + id: 'scheduled-source', + indexUrl: 'https://skills.example.com/index.json', + schedule: { + frequency: { minutes: 5 }, + timeout: { minutes: 5 }, + initialDelay: { seconds: 0 }, + }, + }, + ], + }, + logger, + npxSkillConnectorPlugin, + ); + + // The scheduler was invoked, which calls discoverIndex + expect(discoverIndex).toHaveBeenCalled(); + }); +}); diff --git a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.ts b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.ts index 773544129f..f25c8c65f9 100644 --- a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.ts +++ b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/plugin.ts @@ -30,7 +30,7 @@ import type { NpxSourceConfig } from './services/types'; * * Registers public Agent Skills v0.2 index sources, discovers entries, * retrieves and verifies artifacts, normalizes metadata, and serves - * snapshots via GET /skills/:sourceId. + * authenticated snapshots via GET /skills/:sourceId. * * It does not create AiResource entities or mutate the catalog. * Public sources only; no npx execution, Git crawling, or @@ -44,6 +44,7 @@ export const npxSkillConnectorPlugin = createBackendPlugin({ env.registerInit({ deps: { httpRouter: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, logger: coreServices.logger, config: coreServices.rootConfig, scheduler: coreServices.scheduler, @@ -75,6 +76,13 @@ export const npxSkillConnectorPlugin = createBackendPlugin({ }); } + for (const source of sources) { + logger.info( + `Source '${source.id}': indexUrl=${source.indexUrl}, ` + + `schedule=${JSON.stringify(source.schedule)}`, + ); + } + logger.info( `npx skill connector initialized with ${sources.length} source(s)`, ); diff --git a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/testUtils.ts b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/testUtils.ts index 78a057bd31..260e23e76d 100644 --- a/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/testUtils.ts +++ b/workspaces/ai-integrations/plugins/npx-skill-connector-backend/src/testUtils.ts @@ -14,7 +14,11 @@ * limitations under the License. */ +import type { BackendFeature } from '@backstage/backend-plugin-api'; import type { LoggerService } from '@backstage/backend-plugin-api'; +// eslint-disable-next-line @backstage/no-undeclared-imports -- Shared fixtures are imported only by tests. +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import type { Config } from '../config'; import type { NpxSourceConfig } from './services/types'; /** Minimal mock logger for tests. */ @@ -39,3 +43,38 @@ export const testSource: NpxSourceConfig = { scope: 'local', }, }; + +// Expand imported config interfaces into structural JSON objects accepted by +// Backstage's config test service while retaining schema checking at call sites. +type JsonConfig = T extends object + ? { [K in keyof T]: JsonConfig } + : T; +export type NpxConnectorTestConfig = JsonConfig< + NonNullable +>; + +/** Start a connector backend for integration testing. */ +export function startConnector( + settings: NpxConnectorTestConfig, + logger: ReturnType, + plugin: BackendFeature, + scheduler: BackendFeature = mockServices.scheduler.factory({ + includeInitialDelayedTasksOnStartup: true, + }), +) { + return startTestBackend({ + features: [ + plugin, + mockServices.rootLogger.factory(), + logger.factory, + scheduler, + mockServices.rootConfig.factory({ + data: { + app: { baseUrl: 'http://localhost:3000' }, + backend: { baseUrl: 'http://localhost:7007' }, + npxSkillConnector: settings, + }, + }), + ], + }); +}