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, + }, + }), + ], + }); +}