diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 978cb14062c62..9d3e091b55a34 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -3096,6 +3096,291 @@ paths: x-metaTags: - content: Kibana, Elastic Cloud Serverless name: product_name + /api/agent_builder/plugins: + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/agent_builder/plugins
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + List all installed plugins and their managed assets. Plugins are installable packages that bundle agent capabilities such as skills, following the [Claude agent plugin specification](https://code.claude.com/docs/en/plugins).

[Required authorization] Route required privileges: agentBuilder:read. + operationId: get-agent-builder-plugins + parameters: [] + responses: + '200': + content: + application/json: + examples: + listPluginsResponseExample: + description: Example response that returns one installed plugin + value: + results: + - created_at: '2025-01-01T00:00:00.000Z' + description: Financial analysis tools and skills for Claude + id: financial-analysis + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + keywords: + - finance + - analysis + repository: https://github.com/anthropics/financial-services-plugins + name: financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + unmanaged_assets: + agents: [] + commands: [] + hooks: [] + lsp_servers: [] + mcp_servers: [] + output_styles: [] + updated_at: '2025-01-01T00:00:00.000Z' + version: 1.0.0 + description: Indicates a successful response + summary: List plugins + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X GET "${KIBANA_URL}/api/agent_builder/plugins" \ + -H "Authorization: ApiKey ${API_KEY}" + - lang: Console + source: | + GET kbn://api/agent_builder/plugins + x-state: Technical Preview + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name + /api/agent_builder/plugins/{pluginId}: + delete: + description: |- + **Spaces method and path for this operation:** + +
delete /s/{space_id}/api/agent_builder/plugins/{pluginId}
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Delete an installed plugin by ID. This action cannot be undone.

[Required authorization] Route required privileges: agentBuilder:write. + operationId: delete-agent-builder-plugins-pluginid + parameters: + - description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + - description: The unique identifier of the plugin. + in: path + name: pluginId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + examples: + deletePluginResponseExample: + description: Example response showing that deletion of the plugin has been successful + value: + success: true + description: Indicates a successful response + summary: Delete a plugin + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X DELETE "${KIBANA_URL}/api/agent_builder/plugins/{id}" \ + -H "Authorization: ApiKey ${API_KEY}" \ + -H "kbn-xsrf: true" + - lang: Console + source: | + DELETE kbn://api/agent_builder/plugins/{id} + x-state: Technical Preview + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/agent_builder/plugins/{pluginId}
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Get a specific plugin by ID.

[Required authorization] Route required privileges: agentBuilder:read. + operationId: get-agent-builder-plugins-pluginid + parameters: + - description: The unique identifier of the plugin. + in: path + name: pluginId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + examples: + getPluginByIdResponseExample: + description: Example response returning a single installed plugin + value: + created_at: '2025-01-01T00:00:00.000Z' + description: Financial analysis tools and skills for Claude + id: financial-analysis + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + keywords: + - finance + - analysis + repository: https://github.com/anthropics/financial-services-plugins + name: financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + unmanaged_assets: + agents: [] + commands: [] + hooks: [] + lsp_servers: [] + mcp_servers: [] + output_styles: [] + updated_at: '2025-01-01T00:00:00.000Z' + version: 1.0.0 + description: Indicates a successful response + summary: Get a plugin by id + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X GET "${KIBANA_URL}/api/agent_builder/plugins/{id}" \ + -H "Authorization: ApiKey ${API_KEY}" + - lang: Console + source: | + GET kbn://api/agent_builder/plugins/{id} + x-state: Technical Preview + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name + /api/agent_builder/plugins/install: + post: + description: |- + **Spaces method and path for this operation:** + +
post /s/{space_id}/api/agent_builder/plugins/install
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Install a plugin from a [GitHub Claude plugin URL](https://code.claude.com/docs/en/plugins) or a direct ZIP URL. Plugins bundle agent capabilities such as skills.

[Required authorization] Route required privileges: agentBuilder:write. + operationId: post-agent-builder-plugins-install + parameters: + - description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + requestBody: + content: + application/json: + examples: + installPluginFromGithubExample: + description: Example request for installing a plugin from a GitHub URL + value: + url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + installPluginFromZipExample: + description: Example request for installing a plugin from a direct zip URL + value: + url: https://my-server.example.com/my-plugin.zip + installPluginWithNameOverrideExample: + description: Example request for installing a plugin with a custom name + value: + plugin_name: my-custom-plugin-name + url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + schema: + additionalProperties: false + type: object + properties: + plugin_name: + description: Optional name override for the plugin. Defaults to the manifest name. + type: string + url: + description: URL to install the plugin from (GitHub URL or direct zip URL). + type: string + required: + - url + responses: + '200': + content: + application/json: + examples: + installPluginResponseExample: + description: Example response returning the definition of the installed plugin + value: + created_at: '2025-01-01T00:00:00.000Z' + description: Financial analysis tools and skills for Claude + id: financial-analysis + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + keywords: + - finance + - analysis + repository: https://github.com/anthropics/financial-services-plugins + name: financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + unmanaged_assets: + agents: [] + commands: [] + hooks: [] + lsp_servers: [] + mcp_servers: [] + output_styles: [] + updated_at: '2025-01-01T00:00:00.000Z' + version: 1.0.0 + description: Indicates a successful response + summary: Install a plugin + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X POST "${KIBANA_URL}/api/agent_builder/plugins/install" \ + -H "Authorization: ApiKey ${API_KEY}" \ + -H "kbn-xsrf: true" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis" + }' + - lang: Console + source: | + POST kbn://api/agent_builder/plugins/install + { + "url": "https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis" + } + x-state: Technical Preview + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name /api/agent_builder/skills: get: description: |- diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 68ecac3549d40..2dd60718cb1b0 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -3167,6 +3167,291 @@ paths: x-metaTags: - content: Kibana name: product_name + /api/agent_builder/plugins: + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/agent_builder/plugins
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + List all installed plugins and their managed assets. Plugins are installable packages that bundle agent capabilities such as skills, following the [Claude agent plugin specification](https://code.claude.com/docs/en/plugins).

[Required authorization] Route required privileges: agentBuilder:read. + operationId: get-agent-builder-plugins + parameters: [] + responses: + '200': + content: + application/json: + examples: + listPluginsResponseExample: + description: Example response that returns one installed plugin + value: + results: + - created_at: '2025-01-01T00:00:00.000Z' + description: Financial analysis tools and skills for Claude + id: financial-analysis + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + keywords: + - finance + - analysis + repository: https://github.com/anthropics/financial-services-plugins + name: financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + unmanaged_assets: + agents: [] + commands: [] + hooks: [] + lsp_servers: [] + mcp_servers: [] + output_styles: [] + updated_at: '2025-01-01T00:00:00.000Z' + version: 1.0.0 + description: Indicates a successful response + summary: List plugins + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X GET "${KIBANA_URL}/api/agent_builder/plugins" \ + -H "Authorization: ApiKey ${API_KEY}" + - lang: Console + source: | + GET kbn://api/agent_builder/plugins + x-state: Technical Preview; added in 9.4.0 + x-metaTags: + - content: Kibana + name: product_name + /api/agent_builder/plugins/{pluginId}: + delete: + description: |- + **Spaces method and path for this operation:** + +
delete /s/{space_id}/api/agent_builder/plugins/{pluginId}
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Delete an installed plugin by ID. This action cannot be undone.

[Required authorization] Route required privileges: agentBuilder:write. + operationId: delete-agent-builder-plugins-pluginid + parameters: + - description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + - description: The unique identifier of the plugin. + in: path + name: pluginId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + examples: + deletePluginResponseExample: + description: Example response showing that deletion of the plugin has been successful + value: + success: true + description: Indicates a successful response + summary: Delete a plugin + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X DELETE "${KIBANA_URL}/api/agent_builder/plugins/{id}" \ + -H "Authorization: ApiKey ${API_KEY}" \ + -H "kbn-xsrf: true" + - lang: Console + source: | + DELETE kbn://api/agent_builder/plugins/{id} + x-state: Technical Preview; added in 9.4.0 + x-metaTags: + - content: Kibana + name: product_name + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/agent_builder/plugins/{pluginId}
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Get a specific plugin by ID.

[Required authorization] Route required privileges: agentBuilder:read. + operationId: get-agent-builder-plugins-pluginid + parameters: + - description: The unique identifier of the plugin. + in: path + name: pluginId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + examples: + getPluginByIdResponseExample: + description: Example response returning a single installed plugin + value: + created_at: '2025-01-01T00:00:00.000Z' + description: Financial analysis tools and skills for Claude + id: financial-analysis + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + keywords: + - finance + - analysis + repository: https://github.com/anthropics/financial-services-plugins + name: financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + unmanaged_assets: + agents: [] + commands: [] + hooks: [] + lsp_servers: [] + mcp_servers: [] + output_styles: [] + updated_at: '2025-01-01T00:00:00.000Z' + version: 1.0.0 + description: Indicates a successful response + summary: Get a plugin by id + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X GET "${KIBANA_URL}/api/agent_builder/plugins/{id}" \ + -H "Authorization: ApiKey ${API_KEY}" + - lang: Console + source: | + GET kbn://api/agent_builder/plugins/{id} + x-state: Technical Preview; added in 9.4.0 + x-metaTags: + - content: Kibana + name: product_name + /api/agent_builder/plugins/install: + post: + description: |- + **Spaces method and path for this operation:** + +
post /s/{space_id}/api/agent_builder/plugins/install
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Install a plugin from a [GitHub Claude plugin URL](https://code.claude.com/docs/en/plugins) or a direct ZIP URL. Plugins bundle agent capabilities such as skills.

[Required authorization] Route required privileges: agentBuilder:write. + operationId: post-agent-builder-plugins-install + parameters: + - description: A required header to protect against CSRF attacks + in: header + name: kbn-xsrf + required: true + schema: + example: 'true' + type: string + requestBody: + content: + application/json: + examples: + installPluginFromGithubExample: + description: Example request for installing a plugin from a GitHub URL + value: + url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + installPluginFromZipExample: + description: Example request for installing a plugin from a direct zip URL + value: + url: https://my-server.example.com/my-plugin.zip + installPluginWithNameOverrideExample: + description: Example request for installing a plugin with a custom name + value: + plugin_name: my-custom-plugin-name + url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + schema: + additionalProperties: false + type: object + properties: + plugin_name: + description: Optional name override for the plugin. Defaults to the manifest name. + type: string + url: + description: URL to install the plugin from (GitHub URL or direct zip URL). + type: string + required: + - url + responses: + '200': + content: + application/json: + examples: + installPluginResponseExample: + description: Example response returning the definition of the installed plugin + value: + created_at: '2025-01-01T00:00:00.000Z' + description: Financial analysis tools and skills for Claude + id: financial-analysis + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + keywords: + - finance + - analysis + repository: https://github.com/anthropics/financial-services-plugins + name: financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + unmanaged_assets: + agents: [] + commands: [] + hooks: [] + lsp_servers: [] + mcp_servers: [] + output_styles: [] + updated_at: '2025-01-01T00:00:00.000Z' + version: 1.0.0 + description: Indicates a successful response + summary: Install a plugin + tags: + - agent builder + x-codeSamples: + - lang: curl + source: | + curl \ + -X POST "${KIBANA_URL}/api/agent_builder/plugins/install" \ + -H "Authorization: ApiKey ${API_KEY}" \ + -H "kbn-xsrf: true" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis" + }' + - lang: Console + source: | + POST kbn://api/agent_builder/plugins/install + { + "url": "https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis" + } + x-state: Technical Preview; added in 9.4.0 + x-metaTags: + - content: Kibana + name: product_name /api/agent_builder/skills: get: description: |- diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/base/errors.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/base/errors.ts index b1525cdc3f1b4..10b8063146b3d 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/base/errors.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/base/errors.ts @@ -20,6 +20,7 @@ export enum AgentBuilderErrorCode { skillNotFound = 'skillNotFound', agentNotFound = 'agentNotFound', conversationNotFound = 'conversationNotFound', + pluginNotFound = 'pluginNotFound', agentExecutionError = 'agentExecutionError', requestAborted = 'requestAborted', hookExecutionError = 'hookExecutionError', @@ -211,6 +212,35 @@ export const createConversationNotFoundError = ({ ); }; +/** + * Error thrown when trying to retrieve a plugin not present in the current context. + */ +export type AgentBuilderPluginNotFoundError = + AgentBuilderError; + +/** + * Checks if the given error is a {@link AgentBuilderPluginNotFoundError} + */ +export const isPluginNotFoundError = (err: unknown): err is AgentBuilderPluginNotFoundError => { + return isAgentBuilderError(err) && err.code === AgentBuilderErrorCode.pluginNotFound; +}; + +export const createPluginNotFoundError = ({ + pluginId, + customMessage, + meta = {}, +}: { + pluginId: string; + customMessage?: string; + meta?: Record; +}): AgentBuilderPluginNotFoundError => { + return new AgentBuilderError( + AgentBuilderErrorCode.pluginNotFound, + customMessage ?? `Plugin ${pluginId} not found`, + { ...meta, pluginId, statusCode: 404 } + ); +}; + /** * Represents an internal error */ @@ -356,6 +386,7 @@ export const AgentBuilderErrorUtils = { isSkillNotFoundError, isAgentNotFoundError, isConversationNotFoundError, + isPluginNotFoundError, isWorkflowAbortedError, isWorkflowExecutionError, isAgentExecutionError, @@ -365,6 +396,7 @@ export const AgentBuilderErrorUtils = { createSkillNotFoundError, createAgentNotFoundError, createConversationNotFoundError, + createPluginNotFoundError, createWorkflowAbortedError, createWorkflowExecutionError, createAgentExecutionError, diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/index.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/index.ts index 90cefcb2fa1b4..fc3807d6d5159 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/index.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/index.ts @@ -59,6 +59,7 @@ export { isAgentBuilderError, isAgentNotFoundError, isConversationNotFoundError, + isPluginNotFoundError, isBadRequestError, isRequestAbortedError, isWorkflowAbortedError, @@ -71,6 +72,7 @@ export { createSkillNotFoundError, createAgentNotFoundError, createConversationNotFoundError, + createPluginNotFoundError, createBadRequestError, createRequestAbortedError, createWorkflowAbortedError, @@ -81,6 +83,7 @@ export { type AgentBuilderSkillNotFoundError, type AgentBuilderAgentNotFoundError, type AgentBuilderConversationNotFoundError, + type AgentBuilderPluginNotFoundError, type AgentBuilderBadRequestError, type AgentBuilderRequestAbortedError, type AgentBuilderWorkflowAbortedError, @@ -198,3 +201,14 @@ export { type VersionedAttachment, type UpdateOriginResponse, } from './attachments'; +export { + type PluginManifestAuthor, + type PluginManifest, + type ParsedSkillMeta, + type ParsedSkillFile, + type ParsedSkillReferencedFile, + type UnmanagedPluginAssets, + type ParsedPluginArchive, + type PluginManifestMetadata, + type PluginDefinition, +} from './plugins'; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/index.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/index.ts new file mode 100644 index 0000000000000..be89167d79dde --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + PluginManifestAuthor, + UnmanagedPluginAssets, + PluginManifestMetadata, + PluginDefinition, +} from './plugin_definition'; + +export type { + PluginManifest, + ParsedSkillMeta, + ParsedSkillFile, + ParsedSkillReferencedFile, + ParsedPluginArchive, +} from './parsing'; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/parsing.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/parsing.ts new file mode 100644 index 0000000000000..c7a1270cc4286 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/parsing.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginManifestAuthor, UnmanagedPluginAssets } from './plugin_definition'; + +/** + * Claude plugin manifest schema. + * + * Follows the spec at https://code.claude.com/docs/en/plugins-reference#plugin-manifest-schema + * `name` is the only required field. + */ +export interface PluginManifest { + name: string; + version?: string; + description?: string; + author?: PluginManifestAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + commands?: string | string[]; + agents?: string | string[]; + skills?: string | string[]; + hooks?: string | string[]; + mcpServers?: string | string[]; + outputStyles?: string | string[]; + lspServers?: string | string[]; +} + +/** + * Metadata extracted from the YAML frontmatter of a skill's SKILL.md file. + */ +export interface ParsedSkillMeta { + name?: string; + description?: string; + disableModelInvocation?: boolean; + allowedTools?: string[]; +} + +/** + * A fully parsed skill from a plugin archive. + */ +export interface ParsedSkillFile { + /** Directory name of the skill within the archive (e.g. `pdf-processor`) */ + dirName: string; + /** Frontmatter metadata */ + meta: ParsedSkillMeta; + /** Markdown body content (without frontmatter) */ + content: string; + /** Sibling files found alongside SKILL.md */ + referencedFiles: ParsedSkillReferencedFile[]; +} + +export interface ParsedSkillReferencedFile { + relativePath: string; + content: string; +} + +/** + * Result of parsing and validating a Claude plugin zip archive. + */ +export interface ParsedPluginArchive { + manifest: PluginManifest; + skills: ParsedSkillFile[]; + unmanagedAssets: UnmanagedPluginAssets; +} diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/plugin_definition.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/plugin_definition.ts new file mode 100644 index 0000000000000..0fab335ffda21 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/plugins/plugin_definition.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Author information from the plugin manifest. + */ +export interface PluginManifestAuthor { + name: string; + email?: string; + url?: string; +} + +/** + * Assets present in the plugin archive that are not yet supported for installation. + * Each field contains the list of file paths found for that asset type. + */ +export interface UnmanagedPluginAssets { + commands: string[]; + agents: string[]; + hooks: string[]; + mcp_servers: string[]; + output_styles: string[]; + lsp_servers: string[]; +} + +/** + * Manifest metadata stored alongside a persisted plugin. + * Contains the optional manifest fields that are not promoted to + * top-level plugin fields. + */ +export interface PluginManifestMetadata { + author?: PluginManifestAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; +} + +/** + * Public API-facing representation of an installed plugin. + */ +export interface PluginDefinition { + id: string; + name: string; + version: string; + description: string; + manifest: PluginManifestMetadata; + source_url?: string; + skill_ids: string[]; + unmanaged_assets: UnmanagedPluginAssets; + created_at: string; + updated_at: string; +} diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/definition.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/definition.ts index f6a12ba8667c4..a517523a988c6 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/definition.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/definition.ts @@ -51,6 +51,10 @@ export interface PublicSkillDefinition { * Whether this skill is built-in (readonly) or user-created. */ readonly: boolean; + /** + * If this skill was installed from a plugin, the plugin name. + */ + plugin_id?: string; } /** @@ -81,6 +85,10 @@ export interface PersistedSkillCreateRequest { * Tool IDs from the tool registry. */ tool_ids: string[]; + /** + * If this skill is managed by a plugin, the plugin name. + */ + plugin_id?: string; } /** diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/internal.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/internal.ts index 4ce1d185ab7d1..abb22964e1f17 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/internal.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/internal.ts @@ -59,4 +59,8 @@ export interface InternalSkillDefinition { * Only available for builtin skills. */ getInlineTools?: () => MaybePromise; + /** + * If this skill was installed from a plugin, the plugin name. + */ + plugin_id?: string; } diff --git a/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts b/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts new file mode 100644 index 0000000000000..10e347671a30b --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginDefinition } from '@kbn/agent-builder-common'; + +export interface ListPluginsResponse { + results: PluginDefinition[]; +} + +export type GetPluginResponse = PluginDefinition; + +export type InstallPluginResponse = PluginDefinition; + +export interface DeletePluginResponse { + success: boolean; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/moon.yml b/x-pack/platform/plugins/shared/agent_builder/moon.yml index 9f117fd941cac..818deabef3eb6 100644 --- a/x-pack/platform/plugins/shared/agent_builder/moon.yml +++ b/x-pack/platform/plugins/shared/agent_builder/moon.yml @@ -114,6 +114,7 @@ dependsOn: - '@kbn/evals-plugin' - '@kbn/usage-api-plugin' - '@kbn/es-query' + - '@kbn/fs' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/agent_builder/server/config.ts b/x-pack/platform/plugins/shared/agent_builder/server/config.ts index c4a4ce2846a67..275a31cd1713a 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/config.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/config.ts @@ -10,6 +10,7 @@ import { schema, type TypeOf } from '@kbn/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), + githubBaseUrl: schema.string({ defaultValue: 'https://github.com' }), }); export type AgentBuilderConfig = TypeOf; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts b/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts index 0853f739e22c0..fc3bd4fa67785 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts @@ -43,9 +43,8 @@ export class AgentBuilderPlugin > { private logger: Logger; - // @ts-expect-error unused for now private config: AgentBuilderConfig; - private serviceManager = new ServiceManager(); + private serviceManager: ServiceManager; private usageCounter?: UsageCounter; private trackingService?: TrackingService; private analyticsService?: AnalyticsService; @@ -53,6 +52,7 @@ export class AgentBuilderPlugin constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); this.config = context.config.get(); + this.serviceManager = new ServiceManager(this.config); } setup( diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_delete.yaml b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_delete.yaml new file mode 100644 index 0000000000000..3279c6f2feb72 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_delete.yaml @@ -0,0 +1,20 @@ +responses: + 200: + description: "Indicates a successful response" + content: + application/json: + examples: + deletePluginResponseExample: + description: "Example response showing that deletion of the plugin has been successful" + value: + success: true +x-codeSamples: +- lang: curl + source: | + curl \ + -X DELETE "${KIBANA_URL}/api/agent_builder/plugins/{id}" \ + -H "Authorization: ApiKey ${API_KEY}" \ + -H "kbn-xsrf: true" +- lang: Console + source: | + DELETE kbn://api/agent_builder/plugins/{id} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_get_by_id.yaml b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_get_by_id.yaml new file mode 100644 index 0000000000000..f59f131aeb736 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_get_by_id.yaml @@ -0,0 +1,42 @@ +responses: + 200: + description: "Indicates a successful response" + content: + application/json: + examples: + getPluginByIdResponseExample: + description: "Example response returning a single installed plugin" + value: + id: financial-analysis + name: financial-analysis + version: "1.0.0" + description: Financial analysis tools and skills for Claude + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + repository: https://github.com/anthropics/financial-services-plugins + keywords: + - finance + - analysis + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + unmanaged_assets: + commands: [] + agents: [] + hooks: [] + mcp_servers: [] + output_styles: [] + lsp_servers: [] + created_at: "2025-01-01T00:00:00.000Z" + updated_at: "2025-01-01T00:00:00.000Z" +x-codeSamples: +- lang: curl + source: | + curl \ + -X GET "${KIBANA_URL}/api/agent_builder/plugins/{id}" \ + -H "Authorization: ApiKey ${API_KEY}" +- lang: Console + source: | + GET kbn://api/agent_builder/plugins/{id} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_install.yaml b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_install.yaml new file mode 100644 index 0000000000000..f9bfaa2dc9fc7 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_install.yaml @@ -0,0 +1,67 @@ +requestBody: + content: + application/json: + examples: + installPluginFromGithubExample: + description: "Example request for installing a plugin from a GitHub URL" + value: + url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + installPluginFromZipExample: + description: "Example request for installing a plugin from a direct zip URL" + value: + url: https://my-server.example.com/my-plugin.zip + installPluginWithNameOverrideExample: + description: "Example request for installing a plugin with a custom name" + value: + url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + plugin_name: my-custom-plugin-name +responses: + 200: + description: "Indicates a successful response" + content: + application/json: + examples: + installPluginResponseExample: + description: "Example response returning the definition of the installed plugin" + value: + id: financial-analysis + name: financial-analysis + version: "1.0.0" + description: Financial analysis tools and skills for Claude + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + repository: https://github.com/anthropics/financial-services-plugins + keywords: + - finance + - analysis + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + unmanaged_assets: + commands: [] + agents: [] + hooks: [] + mcp_servers: [] + output_styles: [] + lsp_servers: [] + created_at: "2025-01-01T00:00:00.000Z" + updated_at: "2025-01-01T00:00:00.000Z" +x-codeSamples: +- lang: curl + source: | + curl \ + -X POST "${KIBANA_URL}/api/agent_builder/plugins/install" \ + -H "Authorization: ApiKey ${API_KEY}" \ + -H "kbn-xsrf: true" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis" + }' +- lang: Console + source: | + POST kbn://api/agent_builder/plugins/install + { + "url": "https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis" + } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_list.yaml b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_list.yaml new file mode 100644 index 0000000000000..34dd5b3e5af26 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/examples/plugins_list.yaml @@ -0,0 +1,43 @@ +responses: + 200: + description: "Indicates a successful response" + content: + application/json: + examples: + listPluginsResponseExample: + description: "Example response that returns one installed plugin" + value: + results: + - id: financial-analysis + name: financial-analysis + version: "1.0.0" + description: Financial analysis tools and skills for Claude + manifest: + author: + name: Anthropic + url: https://www.anthropic.com + repository: https://github.com/anthropics/financial-services-plugins + keywords: + - finance + - analysis + source_url: https://github.com/anthropics/financial-services-plugins/tree/main/financial-analysis + skill_ids: + - financial-analysis-analyze-portfolio + unmanaged_assets: + commands: [] + agents: [] + hooks: [] + mcp_servers: [] + output_styles: [] + lsp_servers: [] + created_at: "2025-01-01T00:00:00.000Z" + updated_at: "2025-01-01T00:00:00.000Z" +x-codeSamples: +- lang: curl + source: | + curl \ + -X GET "${KIBANA_URL}/api/agent_builder/plugins" \ + -H "Authorization: ApiKey ${API_KEY}" +- lang: Console + source: | + GET kbn://api/agent_builder/plugins diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/index.ts index 886d0cee91f95..62bbe9836ac3e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/index.ts @@ -17,6 +17,7 @@ import { registerAttachmentRoutes } from './attachments'; import { registerMCPRoutes } from './mcp'; import { registerA2ARoutes } from './a2a'; import { registerSkillsRoutes } from './skills'; +import { registerPluginsRoutes } from './plugins'; export const registerRoutes = (dependencies: RouteDependencies) => { registerToolsRoutes(dependencies); @@ -30,4 +31,5 @@ export const registerRoutes = (dependencies: RouteDependencies) => { registerMCPRoutes(dependencies); registerA2ARoutes(dependencies); registerSkillsRoutes(dependencies); + registerPluginsRoutes(dependencies); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts new file mode 100644 index 0000000000000..202c78878de2c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Readable } from 'stream'; +import path from 'path'; +import { schema } from '@kbn/config-schema'; +import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids'; +import type { RouteDependencies } from './types'; +import { getHandlerWrapper } from './wrap_handler'; +import type { + ListPluginsResponse, + GetPluginResponse, + InstallPluginResponse, + DeletePluginResponse, +} from '../../common/http_api/plugins'; +import { publicApiPath, internalApiPath } from '../../common/constants'; +import { toPluginDefinition } from '../services/plugins'; +import { saveUploadedFile } from '../services/plugins/utils'; +import { AGENT_BUILDER_READ_SECURITY, AGENT_BUILDER_WRITE_SECURITY } from './route_security'; + +const pluginIdParamSchema = schema.object({ + pluginId: schema.string({ + meta: { description: 'The unique identifier of the plugin.' }, + }), +}); + +const installPluginBodySchema = schema.object({ + url: schema.string({ + meta: { description: 'URL to install the plugin from (GitHub URL or direct zip URL).' }, + }), + plugin_name: schema.maybe( + schema.string({ + meta: { + description: 'Optional name override for the plugin. Defaults to the manifest name.', + }, + }) + ), +}); + +const featureFlagConfig = { + featureFlag: AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID, +}; + +export function registerPluginsRoutes({ router, getInternalServices, logger }: RouteDependencies) { + const wrapHandler = getHandlerWrapper({ logger }); + + // list plugins + router.versioned + .get({ + path: `${publicApiPath}/plugins`, + security: AGENT_BUILDER_READ_SECURITY, + access: 'public', + summary: 'List plugins', + description: + 'List all installed plugins and their managed assets. Plugins are installable packages that bundle agent capabilities such as skills, following the [Claude agent plugin specification](https://code.claude.com/docs/en/plugins).', + options: { + tags: ['plugins', 'oas-tag:agent builder'], + availability: { + stability: 'experimental', + since: '9.4.0', + }, + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: false, + options: { + oasOperationObject: () => path.join(__dirname, 'examples/plugins_list.yaml'), + }, + }, + wrapHandler(async (ctx, request, response) => { + const { plugins: pluginService } = getInternalServices(); + const client = await pluginService.getScopedClient({ request }); + const plugins = await client.list(); + return response.ok({ + body: { + results: plugins.map(toPluginDefinition), + }, + }); + }, featureFlagConfig) + ); + + // get plugin by ID + router.versioned + .get({ + path: `${publicApiPath}/plugins/{pluginId}`, + security: AGENT_BUILDER_READ_SECURITY, + access: 'public', + summary: 'Get a plugin by id', + description: 'Get a specific plugin by ID.', + options: { + tags: ['plugins', 'oas-tag:agent builder'], + availability: { + stability: 'experimental', + since: '9.4.0', + }, + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + params: pluginIdParamSchema, + }, + }, + options: { + oasOperationObject: () => path.join(__dirname, 'examples/plugins_get_by_id.yaml'), + }, + }, + wrapHandler(async (ctx, request, response) => { + const { pluginId } = request.params; + const { plugins: pluginService } = getInternalServices(); + const client = await pluginService.getScopedClient({ request }); + const plugin = await client.get(pluginId); + return response.ok({ + body: toPluginDefinition(plugin), + }); + }, featureFlagConfig) + ); + + // delete plugin by ID + router.versioned + .delete({ + path: `${publicApiPath}/plugins/{pluginId}`, + security: AGENT_BUILDER_WRITE_SECURITY, + access: 'public', + summary: 'Delete a plugin', + description: 'Delete an installed plugin by ID. This action cannot be undone.', + options: { + tags: ['plugins', 'oas-tag:agent builder'], + availability: { + stability: 'experimental', + since: '9.4.0', + }, + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + params: pluginIdParamSchema, + }, + }, + options: { + oasOperationObject: () => path.join(__dirname, 'examples/plugins_delete.yaml'), + }, + }, + wrapHandler(async (ctx, request, response) => { + const { pluginId } = request.params; + const { plugins: pluginService } = getInternalServices(); + await pluginService.deletePlugin({ request, pluginId }); + return response.ok({ + body: { success: true }, + }); + }, featureFlagConfig) + ); + + // install plugin from URL + router.versioned + .post({ + path: `${publicApiPath}/plugins/install`, + security: AGENT_BUILDER_WRITE_SECURITY, + access: 'public', + summary: 'Install a plugin', + description: + 'Install a plugin from a [GitHub Claude plugin URL](https://code.claude.com/docs/en/plugins) or a direct ZIP URL. Plugins bundle agent capabilities such as skills.', + options: { + tags: ['plugins', 'oas-tag:agent builder'], + availability: { + stability: 'experimental', + since: '9.4.0', + }, + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + body: installPluginBodySchema, + }, + }, + options: { + oasOperationObject: () => path.join(__dirname, 'examples/plugins_install.yaml'), + }, + }, + wrapHandler(async (ctx, request, response) => { + const { url, plugin_name: pluginName } = request.body; + const { plugins: pluginService } = getInternalServices(); + const plugin = await pluginService.installPlugin({ + request, + source: { type: 'url', url }, + pluginName, + }); + return response.ok({ + body: toPluginDefinition(plugin), + }); + }, featureFlagConfig) + ); + + // upload plugin from zip file + router.post( + { + path: `${internalApiPath}/plugins/upload`, + validate: { + body: schema.object({ + file: schema.stream(), + plugin_name: schema.maybe(schema.string()), + }), + }, + options: { + access: 'internal', + body: { + accepts: ['multipart/form-data'], + output: 'stream', + maxBytes: 50 * 1024 * 1024, + }, + }, + security: AGENT_BUILDER_WRITE_SECURITY, + }, + wrapHandler(async (ctx, request, response) => { + const { file, plugin_name: pluginName } = request.body as { + file: Readable; + plugin_name?: string; + }; + const { filePath, cleanup } = await saveUploadedFile(file); + try { + const { plugins: pluginService } = getInternalServices(); + const plugin = await pluginService.installPlugin({ + request, + source: { type: 'file', filePath }, + pluginName, + }); + return response.ok({ + body: toPluginDefinition(plugin), + }); + } finally { + await cleanup(); + } + }, featureFlagConfig) + ); +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts index 9b9508aac93b1..fba81725ce9a7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts @@ -6,6 +6,7 @@ */ import type { Runner } from '@kbn/agent-builder-server'; +import type { AgentBuilderConfig } from '../config'; import type { InternalSetupServices, InternalStartServices, @@ -22,6 +23,7 @@ import { type SkillService, createSkillService } from './skills'; import { AuditLogService } from '../audit'; import { createAgentExecutionService, createTaskHandler } from './execution'; import { createMeteringService, type MeteringService } from './metering'; +import { type PluginsService, createPluginsService } from './plugins'; interface ServiceInstances { tools: ToolsService; @@ -29,6 +31,7 @@ interface ServiceInstances { attachments: AttachmentService; hooks: HooksService; skills: SkillService; + plugins: PluginsService; metering: MeteringService; } @@ -36,6 +39,11 @@ export class ServiceManager { private services?: ServiceInstances; public internalSetup?: InternalSetupServices; public internalStart?: InternalStartServices; + private readonly config: AgentBuilderConfig; + + constructor(config: AgentBuilderConfig) { + this.config = config; + } setupServices({ logger, @@ -49,6 +57,7 @@ export class ServiceManager { attachments: createAttachmentService(), hooks: new HooksService(), skills: createSkillService(), + plugins: createPluginsService(), metering: createMeteringService({ cloud, usageApi, logger: logger.get('metering') }), }; @@ -58,6 +67,7 @@ export class ServiceManager { attachments: this.services.attachments.setup(), hooks: this.services.hooks.setup({ logger: logger.get('hooks') }), skills: this.services.skills.setup(), + plugins: this.services.plugins.setup(), metering: this.services.metering, }; @@ -183,6 +193,13 @@ export class ServiceManager { meteringService: this.services.metering, }); + const plugins = this.services.plugins.start({ + logger: logger.get('plugins'), + elasticsearch, + spaces, + config: this.config, + }); + this.internalStart = { tools, agents, @@ -198,6 +215,7 @@ export class ServiceManager { featureFlags, uiSettings, savedObjects, + plugins, }; return this.internalStart; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/client.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/client.test.ts new file mode 100644 index 0000000000000..18b96eef87533 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/client.test.ts @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; +import { isPluginNotFoundError, type UnmanagedPluginAssets } from '@kbn/agent-builder-common'; +import { createClient, type PluginClient } from './client'; +import type { PluginProperties } from './storage'; + +const testSpace = 'default'; + +const emptyUnmanagedAssets: UnmanagedPluginAssets = { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], +}; + +const createMockPluginSource = (overrides?: Partial): PluginProperties => ({ + id: 'plugin-1', + name: 'test-plugin', + version: '1.0.0', + space: testSpace, + description: 'A test plugin', + manifest: { + author: { name: 'Author' }, + }, + skill_ids: [], + unmanaged_assets: emptyUnmanagedAssets, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + ...overrides, +}); + +const createMockPluginDoc = (overrides?: Partial) => ({ + _id: 'es-doc-id', + _source: createMockPluginSource(overrides), +}); + +interface MockEsClient { + search: jest.Mock; + index: jest.Mock; + delete: jest.Mock; +} + +const mockEsClient: MockEsClient = { + search: jest.fn(), + index: jest.fn(), + delete: jest.fn(), +}; + +jest.mock('./storage', () => { + const actual = jest.requireActual('./storage'); + return { + ...actual, + createStorage: jest.fn(() => ({ + getClient: jest.fn(() => mockEsClient), + })), + }; +}); + +jest.mock('crypto', () => ({ + randomUUID: jest.fn(() => 'generated-uuid'), +})); + +describe('PluginClient', () => { + let client: PluginClient; + + beforeEach(() => { + jest.clearAllMocks(); + + client = createClient({ + space: testSpace, + logger: loggerMock.create(), + esClient: {} as never, + }); + }); + + describe('get', () => { + it('returns the plugin when it exists', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc()] }, + }); + + const result = await client.get('plugin-1'); + + expect(result.id).toBe('plugin-1'); + expect(result.name).toBe('test-plugin'); + }); + + it('throws PluginNotFoundError when not found', async () => { + mockEsClient.search.mockResolvedValue({ hits: { hits: [] } }); + + try { + await client.get('non-existent'); + fail('Expected error to be thrown'); + } catch (e) { + expect(isPluginNotFoundError(e)).toBe(true); + } + }); + }); + + describe('list', () => { + it('returns all plugins in the space', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { + hits: [ + createMockPluginDoc({ id: 'p1', name: 'plugin-a' }), + createMockPluginDoc({ id: 'p2', name: 'plugin-b' }), + ], + total: { value: 2 }, + }, + }); + + const result = await client.list(); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('p1'); + expect(result[1].id).toBe('p2'); + }); + + it('returns empty list when no plugins exist', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [], total: { value: 0 } }, + }); + + const result = await client.list(); + expect(result).toEqual([]); + }); + }); + + describe('has', () => { + it('returns true when plugin exists', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc()] }, + }); + + expect(await client.has('plugin-1')).toBe(true); + }); + + it('returns false when plugin does not exist', async () => { + mockEsClient.search.mockResolvedValue({ hits: { hits: [] } }); + + expect(await client.has('non-existent')).toBe(false); + }); + }); + + describe('findByName', () => { + it('returns the plugin when found by name', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc()] }, + }); + + const result = await client.findByName('test-plugin'); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test-plugin'); + }); + + it('returns undefined when not found', async () => { + mockEsClient.search.mockResolvedValue({ hits: { hits: [] } }); + + const result = await client.findByName('non-existent'); + expect(result).toBeUndefined(); + }); + + it('queries with the name term filter', async () => { + mockEsClient.search.mockResolvedValue({ hits: { hits: [] } }); + + await client.findByName('my-plugin'); + + expect(mockEsClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: { + bool: { + filter: expect.arrayContaining([{ term: { name: 'my-plugin' } }]), + }, + }, + }) + ); + }); + }); + + describe('create', () => { + it('creates a plugin and returns the persisted definition', async () => { + // findByName returns no match (name is unique) + mockEsClient.search + .mockResolvedValueOnce({ hits: { hits: [] } }) + // get after create returns the new doc + .mockResolvedValueOnce({ + hits: { + hits: [ + createMockPluginDoc({ + id: 'generated-uuid', + name: 'new-plugin', + version: '1.0.0', + }), + ], + }, + }); + mockEsClient.index.mockResolvedValue({ result: 'created' }); + + const result = await client.create({ + name: 'new-plugin', + version: '1.0.0', + description: 'Brand new', + manifest: {}, + unmanaged_assets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + }); + + expect(result.id).toBe('generated-uuid'); + expect(result.name).toBe('new-plugin'); + expect(mockEsClient.index).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + id: 'generated-uuid', + name: 'new-plugin', + space: testSpace, + }), + }) + ); + }); + + it('throws BadRequestError when name already exists', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc({ id: 'existing-id', name: 'dup-plugin' })] }, + }); + + try { + await client.create({ + name: 'dup-plugin', + version: '1.0.0', + description: '', + manifest: {}, + unmanaged_assets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + }); + fail('Expected error to be thrown'); + } catch (e) { + expect((e as Error).message).toMatch(/already exists/); + } + + expect(mockEsClient.index).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('updates an existing plugin', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc()] }, + }); + mockEsClient.index.mockResolvedValue({ result: 'updated' }); + + const result = await client.update('plugin-1', { version: '2.0.0' }); + + expect(result.version).toBe('2.0.0'); + expect(mockEsClient.index).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'es-doc-id', + document: expect.objectContaining({ version: '2.0.0' }), + }) + ); + }); + + it('throws PluginNotFoundError when plugin does not exist', async () => { + mockEsClient.search.mockResolvedValue({ hits: { hits: [] } }); + + try { + await client.update('non-existent', { version: '2.0.0' }); + fail('Expected error to be thrown'); + } catch (e) { + expect(isPluginNotFoundError(e)).toBe(true); + } + }); + }); + + describe('delete', () => { + it('deletes an existing plugin', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc()] }, + }); + mockEsClient.delete.mockResolvedValue({ result: 'deleted' }); + + await expect(client.delete('plugin-1')).resolves.toBeUndefined(); + + expect(mockEsClient.delete).toHaveBeenCalledWith({ id: 'es-doc-id' }); + }); + + it('throws PluginNotFoundError when plugin does not exist', async () => { + mockEsClient.search.mockResolvedValue({ hits: { hits: [] } }); + + try { + await client.delete('non-existent'); + fail('Expected error to be thrown'); + } catch (e) { + expect(isPluginNotFoundError(e)).toBe(true); + } + }); + + it('throws PluginNotFoundError when ES returns not_found', async () => { + mockEsClient.search.mockResolvedValue({ + hits: { hits: [createMockPluginDoc()] }, + }); + mockEsClient.delete.mockResolvedValue({ result: 'not_found' }); + + try { + await client.delete('plugin-1'); + fail('Expected error to be thrown'); + } catch (e) { + expect(isPluginNotFoundError(e)).toBe(true); + } + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/client.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/client.ts new file mode 100644 index 0000000000000..fbf96f58e1871 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/client.ts @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { randomUUID } from 'crypto'; +import type { Logger } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { createBadRequestError, createPluginNotFoundError } from '@kbn/agent-builder-common'; +import { createSpaceDslFilter } from '../../../utils/spaces'; +import type { PluginStorage } from './storage'; +import { createStorage } from './storage'; +import { fromEs, createRequestToEs, updateRequestToEs } from './converters'; +import type { + PluginDocument, + PersistedPluginDefinition, + PluginCreateRequest, + PluginUpdateRequest, +} from './types'; + +const MAX_PLUGINS_PER_SPACE = 1000; + +export interface PluginClient { + get(pluginId: string): Promise; + list(): Promise; + has(pluginId: string): Promise; + findByName(name: string): Promise; + create(request: PluginCreateRequest): Promise; + update(pluginId: string, updates: PluginUpdateRequest): Promise; + delete(pluginId: string): Promise; +} + +export const createClient = ({ + space, + logger, + esClient, +}: { + space: string; + logger: Logger; + esClient: ElasticsearchClient; +}): PluginClient => { + const storage = createStorage({ logger, esClient }); + return new PluginClientImpl({ space, storage, logger }); +}; + +class PluginClientImpl implements PluginClient { + private readonly space: string; + private readonly storage: PluginStorage; + private readonly logger: Logger; + + constructor({ + space, + storage, + logger, + }: { + space: string; + storage: PluginStorage; + logger: Logger; + }) { + this.space = space; + this.storage = storage; + this.logger = logger; + } + + async get(pluginId: string): Promise { + const document = await this._getById(pluginId); + if (!document) { + throw createPluginNotFoundError({ pluginId }); + } + return fromEs(document); + } + + async list(): Promise { + const response = await this.storage.getClient().search({ + query: { + bool: { + filter: [createSpaceDslFilter(this.space)], + }, + }, + size: MAX_PLUGINS_PER_SPACE, + track_total_hits: true, + }); + + const total = + typeof response.hits.total === 'number' + ? response.hits.total + : response.hits.total?.value ?? 0; + + if (total > MAX_PLUGINS_PER_SPACE) { + this.logger.warn( + `Space "${this.space}" has ${total} plugins which exceeds the limit of ${MAX_PLUGINS_PER_SPACE}. Results are truncated.` + ); + } + + return response.hits.hits.map((hit) => fromEs(hit as PluginDocument)); + } + + async has(pluginId: string): Promise { + const document = await this._getById(pluginId); + return document !== undefined; + } + + async findByName(name: string): Promise { + const response = await this.storage.getClient().search({ + track_total_hits: false, + size: 1, + terminate_after: 1, + query: { + bool: { + filter: [createSpaceDslFilter(this.space), { term: { name } }], + }, + }, + }); + if (response.hits.hits.length === 0) { + return undefined; + } + return fromEs(response.hits.hits[0] as PluginDocument); + } + + async create(createRequest: PluginCreateRequest): Promise { + const existing = await this.findByName(createRequest.name); + if (existing) { + throw createBadRequestError( + `Plugin with name '${createRequest.name}' already exists (id: ${existing.id}).` + ); + } + + const id = randomUUID(); + const attributes = createRequestToEs({ + id, + createRequest, + space: this.space, + }); + + await this.storage.getClient().index({ + document: attributes, + }); + + return this.get(id); + } + + async update(pluginId: string, update: PluginUpdateRequest): Promise { + const document = await this._getById(pluginId); + if (!document) { + throw createPluginNotFoundError({ pluginId }); + } + + const updatedAttributes = updateRequestToEs({ + current: document._source!, + update, + }); + + await this.storage.getClient().index({ + id: document._id, + document: updatedAttributes, + }); + + return fromEs({ + _id: document._id, + _source: updatedAttributes, + }); + } + + async delete(pluginId: string): Promise { + const document = await this._getById(pluginId); + if (!document) { + throw createPluginNotFoundError({ pluginId }); + } + const result = await this.storage.getClient().delete({ id: document._id }); + if (result.result === 'not_found') { + throw createPluginNotFoundError({ pluginId }); + } + } + + private async _getById(pluginId: string): Promise { + const response = await this.storage.getClient().search({ + track_total_hits: false, + size: 1, + terminate_after: 1, + query: { + bool: { + filter: [createSpaceDslFilter(this.space), { term: { id: pluginId } }], + }, + }, + }); + if (response.hits.hits.length === 0) { + return undefined; + } + return response.hits.hits[0] as PluginDocument; + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/converters.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/converters.test.ts new file mode 100644 index 0000000000000..5faa8ff24b0d6 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/converters.test.ts @@ -0,0 +1,451 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ParsedPluginArchive } from '@kbn/agent-builder-common'; +import type { PluginProperties } from './storage'; +import type { PluginDocument } from './types'; +import { + fromEs, + createRequestToEs, + updateRequestToEs, + parsedArchiveToCreateRequest, + toPluginDefinition, +} from './converters'; + +const createPluginProperties = (overrides?: Partial): PluginProperties => ({ + id: 'plugin-1', + name: 'test-plugin', + version: '1.0.0', + space: 'default', + description: 'A test plugin', + manifest: { + author: { name: 'Test Author', email: 'test@example.com' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test', 'plugin'], + }, + source_url: 'https://github.com/test/plugin/archive/main.zip', + skill_ids: ['skill-1', 'skill-2'], + unmanaged_assets: { + commands: ['commands/cmd1.md'], + agents: [], + hooks: [], + mcp_servers: ['mcp-config.json'], + output_styles: [], + lsp_servers: [], + }, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + ...overrides, +}); + +const createPluginDocument = (overrides?: Partial): PluginDocument => ({ + _id: 'doc-id-1', + _source: createPluginProperties(overrides), +}); + +describe('plugin converters', () => { + describe('fromEs', () => { + it('converts a full ES document to a PersistedPluginDefinition', () => { + const result = fromEs(createPluginDocument()); + + expect(result).toEqual({ + id: 'plugin-1', + name: 'test-plugin', + version: '1.0.0', + description: 'A test plugin', + manifest: { + author: { name: 'Test Author', email: 'test@example.com' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test', 'plugin'], + }, + source_url: 'https://github.com/test/plugin/archive/main.zip', + skill_ids: ['skill-1', 'skill-2'], + unmanaged_assets: { + commands: ['commands/cmd1.md'], + agents: [], + hooks: [], + mcp_servers: ['mcp-config.json'], + output_styles: [], + lsp_servers: [], + }, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + }); + }); + + it('defaults skill_ids to empty array when missing', () => { + const doc = createPluginDocument(); + (doc._source as any).skill_ids = undefined; + + const result = fromEs(doc); + expect(result.skill_ids).toEqual([]); + }); + + it('throws when _source is missing', () => { + const doc: PluginDocument = { _id: 'doc-id-1', _source: undefined }; + expect(() => fromEs(doc)).toThrow('No source found on plugin document'); + }); + + it('excludes the space field from the result', () => { + const result = fromEs(createPluginDocument()); + expect(result).not.toHaveProperty('space'); + }); + }); + + describe('createRequestToEs', () => { + const fixedDate = new Date('2025-06-15T12:00:00.000Z'); + + it('converts a create request to PluginProperties', () => { + const result = createRequestToEs({ + id: 'new-id', + createRequest: { + name: 'my-plugin', + version: '2.0.0', + description: 'My plugin', + manifest: { + author: { name: 'Author' }, + license: 'Apache-2.0', + }, + source_url: 'https://example.com/plugin.zip', + skill_ids: ['s1'], + unmanaged_assets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + }, + space: 'my-space', + creationDate: fixedDate, + }); + + expect(result).toEqual({ + id: 'new-id', + name: 'my-plugin', + version: '2.0.0', + space: 'my-space', + description: 'My plugin', + manifest: { + author: { name: 'Author' }, + license: 'Apache-2.0', + }, + source_url: 'https://example.com/plugin.zip', + skill_ids: ['s1'], + unmanaged_assets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + created_at: '2025-06-15T12:00:00.000Z', + updated_at: '2025-06-15T12:00:00.000Z', + }); + }); + + it('defaults skill_ids to empty array when not provided', () => { + const result = createRequestToEs({ + id: 'new-id', + createRequest: { + name: 'my-plugin', + version: '1.0.0', + description: '', + manifest: {}, + unmanaged_assets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + }, + space: 'default', + creationDate: fixedDate, + }); + + expect(result.skill_ids).toEqual([]); + }); + + it('passes unmanaged_assets through directly', () => { + const result = createRequestToEs({ + id: 'new-id', + createRequest: { + name: 'p', + version: '1.0.0', + description: '', + manifest: {}, + unmanaged_assets: { + commands: ['cmd.md'], + agents: ['agent/'], + hooks: ['hooks.json'], + mcp_servers: ['mcp.json'], + output_styles: ['styles/'], + lsp_servers: ['lsp.json'], + }, + }, + space: 'default', + creationDate: fixedDate, + }); + + expect(result.unmanaged_assets).toEqual({ + commands: ['cmd.md'], + agents: ['agent/'], + hooks: ['hooks.json'], + mcp_servers: ['mcp.json'], + output_styles: ['styles/'], + lsp_servers: ['lsp.json'], + }); + }); + }); + + describe('parsedArchiveToCreateRequest', () => { + const baseArchive: ParsedPluginArchive = { + manifest: { + name: 'my-plugin', + version: '2.0.0', + description: 'A parsed plugin', + author: { name: 'Author' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test'], + skills: [], + }, + skills: [], + unmanagedAssets: { + commands: ['cmd.md'], + agents: [], + hooks: [], + mcp_servers: ['mcp.json'], + output_styles: [], + lsp_servers: [], + }, + }; + + it('converts a full parsed archive to a PluginCreateRequest', () => { + const result = parsedArchiveToCreateRequest({ + parsedArchive: baseArchive, + sourceUrl: 'https://github.com/test/plugin/archive/main.zip', + skillIds: [], + }); + + expect(result).toEqual({ + name: 'my-plugin', + version: '2.0.0', + description: 'A parsed plugin', + manifest: { + author: { name: 'Author' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test'], + }, + source_url: 'https://github.com/test/plugin/archive/main.zip', + skill_ids: [], + unmanaged_assets: { + commands: ['cmd.md'], + agents: [], + hooks: [], + mcp_servers: ['mcp.json'], + output_styles: [], + lsp_servers: [], + }, + }); + }); + + it('defaults version to 0.0.0 when not present', () => { + const archive: ParsedPluginArchive = { + ...baseArchive, + manifest: { ...baseArchive.manifest, version: undefined }, + }; + const result = parsedArchiveToCreateRequest({ + parsedArchive: archive, + sourceUrl: 'https://example.com/plugin.zip', + skillIds: [], + }); + expect(result.version).toBe('0.0.0'); + }); + + it('defaults description to empty string when not present', () => { + const archive: ParsedPluginArchive = { + ...baseArchive, + manifest: { ...baseArchive.manifest, description: undefined }, + }; + const result = parsedArchiveToCreateRequest({ + parsedArchive: archive, + sourceUrl: 'https://example.com/plugin.zip', + skillIds: [], + }); + expect(result.description).toBe(''); + }); + + it('uses the provided skillIds', () => { + const result = parsedArchiveToCreateRequest({ + parsedArchive: baseArchive, + sourceUrl: 'https://example.com/plugin.zip', + skillIds: ['my-plugin-skill-a', 'my-plugin-skill-b'], + }); + expect(result.skill_ids).toEqual(['my-plugin-skill-a', 'my-plugin-skill-b']); + }); + + it('uses nameOverride instead of manifest name when provided', () => { + const result = parsedArchiveToCreateRequest({ + parsedArchive: baseArchive, + sourceUrl: 'https://example.com/plugin.zip', + skillIds: [], + nameOverride: 'custom-name', + }); + expect(result.name).toBe('custom-name'); + }); + + it('falls back to manifest name when nameOverride is not provided', () => { + const result = parsedArchiveToCreateRequest({ + parsedArchive: baseArchive, + sourceUrl: 'https://example.com/plugin.zip', + skillIds: [], + }); + expect(result.name).toBe('my-plugin'); + }); + }); + + describe('updateRequestToEs', () => { + const fixedDate = new Date('2025-07-01T00:00:00.000Z'); + let current: PluginProperties; + + beforeEach(() => { + current = createPluginProperties(); + }); + + it('updates only the provided fields', () => { + const result = updateRequestToEs({ + current, + update: { version: '2.0.0' }, + updateDate: fixedDate, + }); + + expect(result.version).toBe('2.0.0'); + expect(result.name).toBe('test-plugin'); + expect(result.description).toBe('A test plugin'); + expect(result.updated_at).toBe('2025-07-01T00:00:00.000Z'); + }); + + it('merges manifest fields with existing values', () => { + const result = updateRequestToEs({ + current, + update: { manifest: { license: 'Apache-2.0' } }, + updateDate: fixedDate, + }); + + expect(result.manifest).toEqual({ + author: { name: 'Test Author', email: 'test@example.com' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/plugin', + license: 'Apache-2.0', + keywords: ['test', 'plugin'], + }); + }); + + it('updates skill_ids', () => { + const result = updateRequestToEs({ + current, + update: { skill_ids: ['new-skill'] }, + updateDate: fixedDate, + }); + + expect(result.skill_ids).toEqual(['new-skill']); + }); + + it('updates unmanaged_assets', () => { + const result = updateRequestToEs({ + current, + update: { + unmanaged_assets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: ['new-mcp.json'], + output_styles: [], + lsp_servers: [], + }, + }, + updateDate: fixedDate, + }); + + expect(result.unmanaged_assets).toEqual({ + commands: [], + agents: [], + hooks: [], + mcp_servers: ['new-mcp.json'], + output_styles: [], + lsp_servers: [], + }); + }); + + it('does not modify fields that are not in the update', () => { + const result = updateRequestToEs({ + current, + update: {}, + updateDate: fixedDate, + }); + + expect(result.version).toBe(current.version); + expect(result.description).toBe(current.description); + expect(result.manifest).toEqual(current.manifest); + expect(result.skill_ids).toBe(current.skill_ids); + expect(result.unmanaged_assets).toBe(current.unmanaged_assets); + expect(result.updated_at).toBe('2025-07-01T00:00:00.000Z'); + }); + }); + + describe('toPluginDefinition', () => { + it('converts a PersistedPluginDefinition to a PluginDefinition', () => { + const persisted = fromEs(createPluginDocument()); + const result = toPluginDefinition(persisted); + + expect(result).toEqual({ + id: 'plugin-1', + name: 'test-plugin', + version: '1.0.0', + description: 'A test plugin', + manifest: { + author: { name: 'Test Author', email: 'test@example.com' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test', 'plugin'], + }, + source_url: 'https://github.com/test/plugin/archive/main.zip', + skill_ids: ['skill-1', 'skill-2'], + unmanaged_assets: { + commands: ['commands/cmd1.md'], + agents: [], + hooks: [], + mcp_servers: ['mcp-config.json'], + output_styles: [], + lsp_servers: [], + }, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + }); + }); + + it('handles optional source_url being undefined', () => { + const persisted = fromEs(createPluginDocument({ source_url: undefined })); + const result = toPluginDefinition(persisted); + + expect(result.source_url).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/converters.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/converters.ts new file mode 100644 index 0000000000000..c8bf164a8ff36 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/converters.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ParsedPluginArchive, PluginDefinition } from '@kbn/agent-builder-common'; +import type { PluginProperties } from './storage'; +import type { + PluginDocument, + PersistedPluginDefinition, + PluginCreateRequest, + PluginUpdateRequest, +} from './types'; + +export const fromEs = (document: PluginDocument): PersistedPluginDefinition => { + if (!document._source) { + throw new Error('No source found on plugin document'); + } + const { _source: src } = document; + return { + id: src.id, + name: src.name, + version: src.version, + description: src.description, + manifest: { + author: src.manifest.author, + homepage: src.manifest.homepage, + repository: src.manifest.repository, + license: src.manifest.license, + keywords: src.manifest.keywords, + }, + source_url: src.source_url, + skill_ids: src.skill_ids ?? [], + unmanaged_assets: src.unmanaged_assets, + created_at: src.created_at, + updated_at: src.updated_at, + }; +}; + +export const createRequestToEs = ({ + id, + createRequest, + space, + creationDate = new Date(), +}: { + id: string; + createRequest: PluginCreateRequest; + space: string; + creationDate?: Date; +}): PluginProperties => { + return { + id, + name: createRequest.name, + version: createRequest.version, + space, + description: createRequest.description, + manifest: { + author: createRequest.manifest.author, + homepage: createRequest.manifest.homepage, + repository: createRequest.manifest.repository, + license: createRequest.manifest.license, + keywords: createRequest.manifest.keywords, + }, + source_url: createRequest.source_url, + skill_ids: createRequest.skill_ids ?? [], + unmanaged_assets: createRequest.unmanaged_assets, + created_at: creationDate.toISOString(), + updated_at: creationDate.toISOString(), + }; +}; + +/** + * Converts a parsed plugin archive into a + * {@link PluginCreateRequest} suitable for the persistence client. + */ +export const parsedArchiveToCreateRequest = ({ + parsedArchive, + sourceUrl, + skillIds, + nameOverride, +}: { + parsedArchive: ParsedPluginArchive; + sourceUrl?: string; + skillIds: string[]; + nameOverride?: string; +}): PluginCreateRequest => { + const { manifest, unmanagedAssets } = parsedArchive; + return { + name: nameOverride ?? manifest.name, + version: manifest.version ?? '0.0.0', + description: manifest.description ?? '', + manifest: { + author: manifest.author, + homepage: manifest.homepage, + repository: manifest.repository, + license: manifest.license, + keywords: manifest.keywords, + }, + source_url: sourceUrl, + skill_ids: skillIds, + unmanaged_assets: unmanagedAssets, + }; +}; + +export const toPluginDefinition = (persisted: PersistedPluginDefinition): PluginDefinition => ({ + id: persisted.id, + name: persisted.name, + version: persisted.version, + description: persisted.description, + manifest: persisted.manifest, + source_url: persisted.source_url, + skill_ids: persisted.skill_ids, + unmanaged_assets: persisted.unmanaged_assets, + created_at: persisted.created_at, + updated_at: persisted.updated_at, +}); + +export const updateRequestToEs = ({ + current, + update, + updateDate = new Date(), +}: { + current: PluginProperties; + update: PluginUpdateRequest; + updateDate?: Date; +}): PluginProperties => { + const { manifest, ...rest } = update; + return { + ...current, + ...rest, + manifest: { ...current.manifest, ...manifest }, + updated_at: updateDate.toISOString(), + }; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/index.ts new file mode 100644 index 0000000000000..9ae160ec3619e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { createClient, type PluginClient } from './client'; +export { parsedArchiveToCreateRequest, toPluginDefinition } from './converters'; +export type { + PluginDocument, + PersistedPluginDefinition, + PluginCreateRequest, + PluginUpdateRequest, +} from './types'; +export { pluginIndexName, type PluginStorage } from './storage'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/storage.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/storage.ts new file mode 100644 index 0000000000000..6274f072b7b50 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/storage.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import type { IndexStorageSettings } from '@kbn/storage-adapter'; +import { StorageIndexAdapter, types } from '@kbn/storage-adapter'; +import { chatSystemIndex } from '@kbn/agent-builder-server'; +import type { PluginManifestAuthor, UnmanagedPluginAssets } from '@kbn/agent-builder-common'; + +export const pluginIndexName = chatSystemIndex('plugins'); + +const storageSettings = { + name: pluginIndexName, + schema: { + properties: { + id: types.keyword({}), + name: types.keyword({}), + version: types.keyword({}), + space: types.keyword({}), + description: types.text({}), + manifest: types.object({ + dynamic: false, + properties: {}, + }), + source_url: types.keyword({}), + skill_ids: types.keyword({}), + unmanaged_assets: types.object({ + dynamic: false, + properties: {}, + }), + created_at: types.date({}), + updated_at: types.date({}), + }, + }, +} satisfies IndexStorageSettings; + +export interface PluginManifestProperties { + author?: PluginManifestAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; +} + +export interface PluginProperties { + id: string; + name: string; + version: string; + space: string; + description: string; + manifest: PluginManifestProperties; + source_url?: string; + skill_ids: string[]; + unmanaged_assets: UnmanagedPluginAssets; + created_at: string; + updated_at: string; +} + +export type PluginStorageSettings = typeof storageSettings; + +export type PluginStorage = StorageIndexAdapter; + +export const createStorage = ({ + logger, + esClient, +}: { + logger: Logger; + esClient: ElasticsearchClient; +}): PluginStorage => { + return new StorageIndexAdapter( + esClient, + logger, + storageSettings + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/types.ts new file mode 100644 index 0000000000000..3edea7e19dbb0 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/client/types.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { GetResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { PluginManifestAuthor, UnmanagedPluginAssets } from '@kbn/agent-builder-common'; +import type { PluginProperties } from './storage'; + +export type PluginDocument = Pick, '_source' | '_id'>; + +export interface PersistedPluginManifestMetadata { + author?: PluginManifestAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; +} + +export interface PersistedPluginDefinition { + id: string; + name: string; + version: string; + description: string; + manifest: PersistedPluginManifestMetadata; + source_url?: string; + skill_ids: string[]; + unmanaged_assets: UnmanagedPluginAssets; + created_at: string; + updated_at: string; +} + +export interface PluginCreateRequest { + name: string; + version: string; + description: string; + manifest: PersistedPluginManifestMetadata; + source_url?: string; + skill_ids?: string[]; + unmanaged_assets: UnmanagedPluginAssets; +} + +export interface PluginUpdateRequest { + version?: string; + description?: string; + manifest?: PersistedPluginManifestMetadata; + source_url?: string; + skill_ids?: string[]; + unmanaged_assets?: UnmanagedPluginAssets; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/index.ts new file mode 100644 index 0000000000000..8659732a42a99 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + createPluginsService, + type PluginsService, + type PluginsServiceSetup, + type PluginsServiceStart, +} from './plugin_service'; +export { type PluginClient, type PersistedPluginDefinition, toPluginDefinition } from './client'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/plugin_service.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/plugin_service.test.ts new file mode 100644 index 0000000000000..d8a2b76fa406b --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/plugin_service.test.ts @@ -0,0 +1,425 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; +import type { ParsedPluginArchive } from '@kbn/agent-builder-common'; +import type { KibanaRequest } from '@kbn/core/server'; +import { createPluginsService, type PluginsServiceStart } from './plugin_service'; +import type { PluginClient, PersistedPluginDefinition } from './client'; +import type { SkillClient } from '../skills/persisted/client'; + +const mockParsePluginFromUrl = jest.fn(); +const mockParsePluginFromFile = jest.fn(); +jest.mock('./utils', () => ({ + parsePluginFromUrl: (...args: unknown[]) => mockParsePluginFromUrl(...args), + parsePluginFromFile: (...args: unknown[]) => mockParsePluginFromFile(...args), +})); + +const mockCreateClient = jest.fn(); +jest.mock('./client', () => ({ + ...jest.requireActual('./client'), + createClient: (...args: unknown[]) => mockCreateClient(...args), +})); + +const mockCreateSkillClient = jest.fn(); +jest.mock('../skills/persisted/client', () => ({ + createClient: (...args: unknown[]) => mockCreateSkillClient(...args), +})); + +jest.mock('../../utils/spaces', () => ({ + getCurrentSpaceId: jest.fn(() => 'default'), +})); + +const createMockParsedArchive = ( + overrides?: Partial +): ParsedPluginArchive => ({ + manifest: { + name: 'my-plugin', + version: '1.0.0', + description: 'A test plugin', + author: { name: 'Author' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/repo', + license: 'MIT', + keywords: ['test'], + }, + skills: [], + unmanagedAssets: { + commands: ['commands/cmd.md'], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + ...overrides, +}); + +const createMockPersistedPlugin = ( + overrides?: Partial +): PersistedPluginDefinition => ({ + id: 'generated-id', + name: 'my-plugin', + version: '1.0.0', + description: 'A test plugin', + manifest: { + author: { name: 'Author' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/repo', + license: 'MIT', + keywords: ['test'], + }, + source_url: 'https://github.com/test/repo/tree/main/plugin', + skill_ids: [], + unmanaged_assets: { + commands: ['commands/cmd.md'], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + ...overrides, +}); + +describe('PluginsService', () => { + let start: PluginsServiceStart; + let mockClient: jest.Mocked; + let mockSkillClient: jest.Mocked; + const mockRequest = {} as KibanaRequest; + + beforeEach(() => { + jest.clearAllMocks(); + + mockClient = { + get: jest.fn(), + list: jest.fn(), + has: jest.fn(), + findByName: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + mockSkillClient = { + get: jest.fn(), + list: jest.fn(), + has: jest.fn(), + create: jest.fn(), + bulkCreate: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteByPluginId: jest.fn(), + }; + + mockCreateClient.mockReturnValue(mockClient); + mockCreateSkillClient.mockReturnValue(mockSkillClient); + + const mockElasticsearch = { + client: { + asScoped: jest.fn(() => ({ + asInternalUser: {}, + })), + }, + }; + + const service = createPluginsService(); + service.setup(); + start = service.start({ + logger: loggerMock.create(), + elasticsearch: mockElasticsearch as any, + config: { enabled: true, githubBaseUrl: 'https://github.com' }, + }); + }); + + describe('getScopedClient', () => { + it('creates a client with the correct parameters', () => { + const client = start.getScopedClient({ request: mockRequest }); + + expect(client).toBe(mockClient); + expect(mockCreateClient).toHaveBeenCalledWith( + expect.objectContaining({ + space: 'default', + }) + ); + }); + }); + + describe('installPlugin', () => { + const archiveWithSkills = createMockParsedArchive({ + skills: [ + { + dirName: 'pdf-processor', + meta: { name: 'PDF Processor', description: 'Processes PDFs' }, + content: 'Skill instructions for PDF.', + referencedFiles: [{ relativePath: 'schema.json', content: '{}' }], + }, + { + dirName: 'code-reviewer', + meta: {}, + content: 'Review code.', + referencedFiles: [], + }, + ], + }); + + describe('from URL', () => { + it('parses the URL, creates skills and the plugin record, and returns it', async () => { + const persistedPlugin = createMockPersistedPlugin({ + skill_ids: ['my-plugin-pdf-processor', 'my-plugin-code-reviewer'], + }); + + mockParsePluginFromUrl.mockResolvedValue(archiveWithSkills); + mockClient.findByName.mockResolvedValue(undefined); + mockClient.create.mockResolvedValue(persistedPlugin); + mockSkillClient.bulkCreate.mockResolvedValue([]); + + const result = await start.installPlugin({ + request: mockRequest, + source: { type: 'url', url: 'https://github.com/test/repo/tree/main/plugin' }, + }); + + expect(mockParsePluginFromUrl).toHaveBeenCalledWith( + 'https://github.com/test/repo/tree/main/plugin', + { githubBaseUrl: 'https://github.com' } + ); + + expect(mockClient.findByName).toHaveBeenCalledWith('my-plugin'); + + expect(mockSkillClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(mockSkillClient.bulkCreate).toHaveBeenCalledWith([ + { + id: 'my-plugin-pdf-processor', + name: 'PDF Processor', + description: 'Processes PDFs', + content: 'Skill instructions for PDF.', + referenced_content: [ + { name: 'schema.json', relativePath: 'schema.json', content: '{}' }, + ], + tool_ids: [], + plugin_id: 'my-plugin', + }, + { + id: 'my-plugin-code-reviewer', + name: 'code-reviewer', + description: '', + content: 'Review code.', + referenced_content: [], + tool_ids: [], + plugin_id: 'my-plugin', + }, + ]); + + expect(mockClient.create).toHaveBeenCalledWith({ + name: 'my-plugin', + version: '1.0.0', + description: 'A test plugin', + manifest: { + author: { name: 'Author' }, + homepage: 'https://example.com', + repository: 'https://github.com/test/repo', + license: 'MIT', + keywords: ['test'], + }, + source_url: 'https://github.com/test/repo/tree/main/plugin', + skill_ids: ['my-plugin-pdf-processor', 'my-plugin-code-reviewer'], + unmanaged_assets: archiveWithSkills.unmanagedAssets, + }); + + expect(result).toBe(persistedPlugin); + }); + + it('defaults version to 0.0.0 when manifest has no version', async () => { + const parsedArchive = createMockParsedArchive({ + manifest: { name: 'no-version-plugin' }, + }); + + mockParsePluginFromUrl.mockResolvedValue(parsedArchive); + mockClient.findByName.mockResolvedValue(undefined); + mockClient.create.mockResolvedValue(createMockPersistedPlugin()); + + await start.installPlugin({ + request: mockRequest, + source: { type: 'url', url: 'https://example.com/plugin.zip' }, + }); + + expect(mockClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + version: '0.0.0', + description: '', + }) + ); + }); + + it('throws BadRequestError when a plugin with the same name already exists', async () => { + mockParsePluginFromUrl.mockResolvedValue(createMockParsedArchive()); + mockClient.findByName.mockResolvedValue( + createMockPersistedPlugin({ id: 'existing-id', version: '0.9.0' }) + ); + + try { + await start.installPlugin({ + request: mockRequest, + source: { type: 'url', url: 'https://example.com/plugin.zip' }, + }); + fail('Expected error to be thrown'); + } catch (e) { + expect((e as Error).message).toMatch(/already installed/); + } + + expect(mockClient.create).not.toHaveBeenCalled(); + expect(mockSkillClient.bulkCreate).not.toHaveBeenCalled(); + }); + + it('propagates errors from parsePluginFromUrl', async () => { + mockParsePluginFromUrl.mockRejectedValue(new Error('Download failed')); + + await expect( + start.installPlugin({ + request: mockRequest, + source: { type: 'url', url: 'https://example.com/bad.zip' }, + }) + ).rejects.toThrow('Download failed'); + }); + }); + + describe('from file', () => { + it('parses the local file, creates skills and the plugin record without source_url', async () => { + const persistedPlugin = createMockPersistedPlugin({ + source_url: undefined, + skill_ids: ['my-plugin-pdf-processor', 'my-plugin-code-reviewer'], + }); + + mockParsePluginFromFile.mockResolvedValue(archiveWithSkills); + mockClient.findByName.mockResolvedValue(undefined); + mockClient.create.mockResolvedValue(persistedPlugin); + mockSkillClient.bulkCreate.mockResolvedValue([]); + + const result = await start.installPlugin({ + request: mockRequest, + source: { type: 'file', filePath: '/tmp/plugin.zip' }, + }); + + expect(mockParsePluginFromFile).toHaveBeenCalledWith('/tmp/plugin.zip'); + expect(mockParsePluginFromUrl).not.toHaveBeenCalled(); + + expect(mockSkillClient.bulkCreate).toHaveBeenCalledTimes(1); + + expect(mockClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-plugin', + source_url: undefined, + skill_ids: ['my-plugin-pdf-processor', 'my-plugin-code-reviewer'], + }) + ); + + expect(result).toBe(persistedPlugin); + }); + + it('throws BadRequestError when a plugin with the same name already exists', async () => { + mockParsePluginFromFile.mockResolvedValue(createMockParsedArchive()); + mockClient.findByName.mockResolvedValue( + createMockPersistedPlugin({ id: 'existing-id', version: '0.9.0' }) + ); + + await expect( + start.installPlugin({ + request: mockRequest, + source: { type: 'file', filePath: '/tmp/plugin.zip' }, + }) + ).rejects.toThrow(/already installed/); + + expect(mockClient.create).not.toHaveBeenCalled(); + expect(mockSkillClient.bulkCreate).not.toHaveBeenCalled(); + }); + + it('propagates errors from parsePluginFromFile', async () => { + mockParsePluginFromFile.mockRejectedValue(new Error('Invalid zip')); + + await expect( + start.installPlugin({ + request: mockRequest, + source: { type: 'file', filePath: '/tmp/bad.zip' }, + }) + ).rejects.toThrow('Invalid zip'); + }); + }); + + describe('with pluginName override', () => { + it('uses the provided pluginName instead of the manifest name', async () => { + const persistedPlugin = createMockPersistedPlugin({ + name: 'custom-name', + skill_ids: ['custom-name-pdf-processor', 'custom-name-code-reviewer'], + }); + + mockParsePluginFromUrl.mockResolvedValue(archiveWithSkills); + mockClient.findByName.mockResolvedValue(undefined); + mockClient.create.mockResolvedValue(persistedPlugin); + mockSkillClient.bulkCreate.mockResolvedValue([]); + + const result = await start.installPlugin({ + request: mockRequest, + source: { type: 'url', url: 'https://example.com/plugin.zip' }, + pluginName: 'custom-name', + }); + + expect(mockClient.findByName).toHaveBeenCalledWith('custom-name'); + + expect(mockSkillClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'custom-name-pdf-processor', + plugin_id: 'custom-name', + }), + expect.objectContaining({ + id: 'custom-name-code-reviewer', + plugin_id: 'custom-name', + }), + ]) + ); + + expect(mockClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'custom-name', + skill_ids: ['custom-name-pdf-processor', 'custom-name-code-reviewer'], + }) + ); + + expect(result).toBe(persistedPlugin); + }); + }); + }); + + describe('deletePlugin', () => { + it('deletes associated skills by plugin name, then deletes the plugin', async () => { + mockClient.get.mockResolvedValue( + createMockPersistedPlugin({ id: 'plugin-1', name: 'my-plugin' }) + ); + mockSkillClient.deleteByPluginId.mockResolvedValue(undefined); + mockClient.delete.mockResolvedValue(undefined); + + await start.deletePlugin({ request: mockRequest, pluginId: 'plugin-1' }); + + expect(mockClient.get).toHaveBeenCalledWith('plugin-1'); + expect(mockSkillClient.deleteByPluginId).toHaveBeenCalledWith('my-plugin'); + expect(mockClient.delete).toHaveBeenCalledWith('plugin-1'); + }); + + it('propagates errors from client.get', async () => { + mockClient.get.mockRejectedValue(new Error('Not found')); + + await expect( + start.deletePlugin({ request: mockRequest, pluginId: 'missing-id' }) + ).rejects.toThrow('Not found'); + + expect(mockSkillClient.deleteByPluginId).not.toHaveBeenCalled(); + expect(mockClient.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/plugin_service.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/plugin_service.ts new file mode 100644 index 0000000000000..068a5a1225f4e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/plugin_service.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest, Logger, ElasticsearchServiceStart } from '@kbn/core/server'; +import { createBadRequestError } from '@kbn/agent-builder-common'; +import type { ParsedPluginArchive, ParsedSkillFile } from '@kbn/agent-builder-common'; +import type { PersistedSkillCreateRequest } from '@kbn/agent-builder-common'; +import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; +import type { AgentBuilderConfig } from '../../config'; +import { getCurrentSpaceId } from '../../utils/spaces'; +import type { PluginClient, PersistedPluginDefinition } from './client'; +import { createClient, parsedArchiveToCreateRequest } from './client'; +import { parsePluginFromUrl, parsePluginFromFile } from './utils'; +import { createClient as createSkillClient } from '../skills/persisted/client'; + +type InstallPluginSource = { type: 'url'; url: string } | { type: 'file'; filePath: string }; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginsServiceSetup {} + +export interface PluginsServiceStart { + getScopedClient(options: { request: KibanaRequest }): PluginClient; + installPlugin(options: { + request: KibanaRequest; + source: InstallPluginSource; + pluginName?: string; + }): Promise; + deletePlugin(options: { request: KibanaRequest; pluginId: string }): Promise; +} + +export interface PluginsService { + setup(): PluginsServiceSetup; + start(deps: PluginsServiceStartDeps): PluginsServiceStart; +} + +export interface PluginsServiceStartDeps { + logger: Logger; + elasticsearch: ElasticsearchServiceStart; + spaces?: SpacesPluginStart; + config: AgentBuilderConfig; +} + +export const createPluginsService = (): PluginsService => { + return new PluginsServiceImpl(); +}; + +class PluginsServiceImpl implements PluginsService { + private startDeps?: PluginsServiceStartDeps; + + setup(): PluginsServiceSetup { + return {}; + } + + start(deps: PluginsServiceStartDeps): PluginsServiceStart { + this.startDeps = deps; + + return { + getScopedClient: (options) => this.getScopedClients(options).pluginClient, + installPlugin: (options) => this.installPlugin(options), + deletePlugin: (options) => this.deletePlugin(options), + }; + } + + private getStartDeps(): PluginsServiceStartDeps { + if (!this.startDeps) { + throw new Error('PluginsService#start has not been called'); + } + return this.startDeps; + } + + private getScopedClients({ request }: { request: KibanaRequest }) { + const { elasticsearch, logger, spaces } = this.getStartDeps(); + const esClient = elasticsearch.client.asScoped(request).asInternalUser; + const space = getCurrentSpaceId({ request, spaces }); + + return { + pluginClient: createClient({ esClient, logger, space }), + skillClient: createSkillClient({ esClient, logger, space }), + }; + } + + private async installPlugin({ + request, + source, + pluginName: pluginNameOverride, + }: { + request: KibanaRequest; + source: InstallPluginSource; + pluginName?: string; + }): Promise { + let parsedArchive: ParsedPluginArchive; + let sourceUrl: string | undefined; + + if (source.type === 'url') { + const { config } = this.getStartDeps(); + parsedArchive = await parsePluginFromUrl(source.url, { githubBaseUrl: config.githubBaseUrl }); + sourceUrl = source.url; + } else { + parsedArchive = await parsePluginFromFile(source.filePath); + } + + const pluginName = pluginNameOverride ?? parsedArchive.manifest.name; + const { pluginClient, skillClient } = this.getScopedClients({ request }); + + const existing = await pluginClient.findByName(pluginName); + if (existing) { + throw createBadRequestError( + `Plugin '${pluginName}' is already installed (id: ${existing.id}, version: ${existing.version}).` + ); + } + + const createRequests = parsedArchive.skills.map((skill) => + toSkillCreateRequest({ skill, pluginName }) + ); + await skillClient.bulkCreate(createRequests); + + const skillIds = createRequests.map((req) => req.id); + + const createRequest = parsedArchiveToCreateRequest({ + parsedArchive, + sourceUrl, + skillIds, + nameOverride: pluginNameOverride, + }); + + return pluginClient.create(createRequest); + } + + private async deletePlugin({ + request, + pluginId, + }: { + request: KibanaRequest; + pluginId: string; + }): Promise { + const { pluginClient, skillClient } = this.getScopedClients({ request }); + const plugin = await pluginClient.get(pluginId); + await skillClient.deleteByPluginId(plugin.name); + await pluginClient.delete(pluginId); + } +} + +const toSkillCreateRequest = ({ + skill, + pluginName, +}: { + skill: ParsedSkillFile; + pluginName: string; +}): PersistedSkillCreateRequest => { + return { + id: `${pluginName}-${skill.dirName}`, + name: skill.meta.name ?? skill.dirName, + description: skill.meta.description ?? '', + content: skill.content, + referenced_content: skill.referencedFiles.map((file) => ({ + name: file.relativePath, + relativePath: file.relativePath, + content: file.content, + })), + tool_ids: [], + plugin_id: pluginName, + }; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/create_scoped_archive.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/create_scoped_archive.test.ts new file mode 100644 index 0000000000000..87a89ceece4db --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/create_scoped_archive.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ZipArchive } from './open_zip_archive'; +import { createScopedArchive, detectArchiveRootPrefix } from './create_scoped_archive'; + +const createMockArchive = (files: Record): ZipArchive => { + return { + hasEntry: (path: string) => path in files, + getEntryPaths: () => Object.keys(files), + getEntryContent: async (path: string) => { + if (!(path in files)) { + throw new Error(`Entry ${path} not found in archive`); + } + return Buffer.from(files[path], 'utf-8'); + }, + close: jest.fn(), + }; +}; + +describe('createScopedArchive', () => { + it('rebases entry paths relative to the prefix', () => { + const inner = createMockArchive({ + 'repo-main/': '', + 'repo-main/plugin.json': '{}', + 'repo-main/skills/': '', + 'repo-main/skills/my-skill/SKILL.md': 'content', + }); + + const scoped = createScopedArchive(inner, 'repo-main/'); + + expect(scoped.getEntryPaths().sort()).toEqual([ + 'plugin.json', + 'skills/', + 'skills/my-skill/SKILL.md', + ]); + }); + + it('does not include the prefix directory itself', () => { + const inner = createMockArchive({ + 'prefix/': '', + 'prefix/file.txt': 'content', + }); + + const scoped = createScopedArchive(inner, 'prefix/'); + + expect(scoped.getEntryPaths()).toEqual(['file.txt']); + }); + + it('resolves hasEntry through the prefix', () => { + const inner = createMockArchive({ + 'repo-main/skills/SKILL.md': 'content', + }); + + const scoped = createScopedArchive(inner, 'repo-main/'); + + expect(scoped.hasEntry('skills/SKILL.md')).toBe(true); + expect(scoped.hasEntry('other.txt')).toBe(false); + }); + + it('reads entry content through the prefix', async () => { + const inner = createMockArchive({ + 'repo-main/data.txt': 'hello world', + }); + + const scoped = createScopedArchive(inner, 'repo-main/'); + + const content = await scoped.getEntryContent('data.txt'); + expect(content.toString('utf-8')).toBe('hello world'); + }); + + it('throws when reading a non-existent scoped entry', async () => { + const inner = createMockArchive({ + 'repo-main/exists.txt': 'yes', + }); + + const scoped = createScopedArchive(inner, 'repo-main/'); + + await expect(scoped.getEntryContent('missing.txt')).rejects.toThrow(/not found/); + }); + + it('filters out entries outside the prefix', () => { + const inner = createMockArchive({ + 'repo-main/plugin/file.txt': 'inside', + 'repo-main/other/file.txt': 'outside', + }); + + const scoped = createScopedArchive(inner, 'repo-main/plugin/'); + + expect(scoped.getEntryPaths()).toEqual(['file.txt']); + expect(scoped.hasEntry('file.txt')).toBe(true); + }); + + it('handles prefix without trailing slash', () => { + const inner = createMockArchive({ + 'prefix/file.txt': 'content', + }); + + const scoped = createScopedArchive(inner, 'prefix'); + + expect(scoped.getEntryPaths()).toEqual(['file.txt']); + }); + + it('delegates close to the inner archive', () => { + const inner = createMockArchive({}); + + const scoped = createScopedArchive(inner, 'prefix/'); + scoped.close(); + + expect(inner.close).toHaveBeenCalled(); + }); + + it('returns the original archive unchanged when prefix is empty', () => { + const inner = createMockArchive({ + 'plugin.json': '{}', + 'skills/my-skill/SKILL.md': 'content', + }); + + const scoped = createScopedArchive(inner, ''); + + expect(scoped).toBe(inner); + expect(scoped.getEntryPaths()).toEqual(['plugin.json', 'skills/my-skill/SKILL.md']); + }); +}); + +describe('detectArchiveRootPrefix', () => { + it('detects a single top-level directory', () => { + const archive = createMockArchive({ + 'claude-code-main/': '', + 'claude-code-main/plugin.json': '{}', + 'claude-code-main/skills/SKILL.md': 'content', + }); + + expect(detectArchiveRootPrefix(archive)).toBe('claude-code-main/'); + }); + + it('returns empty string when entries have no common prefix', () => { + const archive = createMockArchive({ + 'file-a.txt': 'a', + 'file-b.txt': 'b', + }); + + expect(detectArchiveRootPrefix(archive)).toBe(''); + }); + + it('returns empty string for an empty archive', () => { + const archive = createMockArchive({}); + + expect(detectArchiveRootPrefix(archive)).toBe(''); + }); + + it('returns empty string when multiple top-level directories exist', () => { + const archive = createMockArchive({ + 'dir-a/file.txt': 'a', + 'dir-b/file.txt': 'b', + }); + + expect(detectArchiveRootPrefix(archive)).toBe(''); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/create_scoped_archive.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/create_scoped_archive.ts new file mode 100644 index 0000000000000..7662fb976690a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/create_scoped_archive.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ZipArchive } from './open_zip_archive'; + +/** + * Creates a path-scoped view of a ZipArchive. + * + * All entry paths are rebased relative to the given prefix. + * For example, if the underlying archive has entries like + * `repo-main/plugins/my-plugin/skills/SKILL.md` and the prefix + * is `repo-main/plugins/my-plugin/`, then `getEntryPaths()` will + * return `skills/SKILL.md`. + * + * This is useful for GitHub archive downloads where the zip + * contains a top-level `{repo}-{ref}/` directory and the plugin + * may be nested inside it. + */ +export const createScopedArchive = (archive: ZipArchive, prefix: string): ZipArchive => { + if (prefix === '') { + return archive; + } + const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`; + return new ScopedZipArchive(archive, normalizedPrefix); +}; + +/** + * Detects the single top-level directory in a zip archive. + * + * GitHub archive zips always contain a single root folder + * like `{repo}-{ref}/`. This function finds it by inspecting + * the entry paths. + * + * Returns the prefix including trailing slash (e.g. `claude-code-main/`). + */ +export const detectArchiveRootPrefix = (archive: ZipArchive): string => { + const entries = archive.getEntryPaths(); + if (entries.length === 0) { + return ''; + } + + const firstSegment = entries[0].split('/')[0]; + const prefix = `${firstSegment}/`; + + const allMatch = entries.every((entry) => entry.startsWith(prefix)); + if (!allMatch) { + return ''; + } + + return prefix; +}; + +class ScopedZipArchive implements ZipArchive { + private readonly inner: ZipArchive; + private readonly prefix: string; + + constructor(inner: ZipArchive, prefix: string) { + this.inner = inner; + this.prefix = prefix; + } + + hasEntry(entryPath: string): boolean { + return this.inner.hasEntry(this.prefix + entryPath); + } + + getEntryPaths(): string[] { + return this.inner + .getEntryPaths() + .filter((p) => p.startsWith(this.prefix) && p.length > this.prefix.length) + .map((p) => p.substring(this.prefix.length)); + } + + getEntryContent(entryPath: string): Promise { + return this.inner.getEntryContent(this.prefix + entryPath); + } + + close(): void { + this.inner.close(); + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/index.ts new file mode 100644 index 0000000000000..04f8de3344786 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { openZipArchive, type ZipArchive } from './open_zip_archive'; +export { createScopedArchive, detectArchiveRootPrefix } from './create_scoped_archive'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/open_zip_archive.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/open_zip_archive.test.ts new file mode 100644 index 0000000000000..d7bb2ca6e23fa --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/open_zip_archive.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import yazl from 'yazl'; +import { openZipArchive, type ZipArchive } from './open_zip_archive'; + +const createTestZip = async (files: Record, destPath: string): Promise => { + return new Promise((resolve, reject) => { + const zipFile = new yazl.ZipFile(); + for (const [filePath, content] of Object.entries(files)) { + zipFile.addBuffer(Buffer.from(content, 'utf-8'), filePath); + } + zipFile.end(); + + const chunks: Buffer[] = []; + zipFile.outputStream.on('data', (chunk: Buffer) => chunks.push(chunk)); + zipFile.outputStream.on('end', async () => { + try { + await fs.writeFile(destPath, Buffer.concat(chunks)); + resolve(); + } catch (err) { + reject(err); + } + }); + zipFile.outputStream.on('error', reject); + }); +}; + +describe('openZipArchive', () => { + let tmpDir: string; + let archive: ZipArchive | undefined; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zip-archive-test-')); + }); + + afterEach(async () => { + archive?.close(); + archive = undefined; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('lists all entry paths', async () => { + const zipPath = path.join(tmpDir, 'test.zip'); + await createTestZip( + { + 'file-a.txt': 'content a', + 'dir/file-b.txt': 'content b', + }, + zipPath + ); + + archive = await openZipArchive(zipPath); + + expect(archive.getEntryPaths().sort()).toEqual(['dir/file-b.txt', 'file-a.txt']); + }); + + it('reports whether an entry exists via hasEntry', async () => { + const zipPath = path.join(tmpDir, 'test.zip'); + await createTestZip({ 'exists.txt': 'yes' }, zipPath); + + archive = await openZipArchive(zipPath); + + expect(archive.hasEntry('exists.txt')).toBe(true); + expect(archive.hasEntry('missing.txt')).toBe(false); + }); + + it('reads entry content as a Buffer', async () => { + const zipPath = path.join(tmpDir, 'test.zip'); + await createTestZip({ 'hello.txt': 'Hello, world!' }, zipPath); + + archive = await openZipArchive(zipPath); + + const content = await archive.getEntryContent('hello.txt'); + expect(Buffer.isBuffer(content)).toBe(true); + expect(content.toString('utf-8')).toBe('Hello, world!'); + }); + + it('preserves content byte-for-byte', async () => { + const zipPath = path.join(tmpDir, 'test.zip'); + const originalContent = 'line 1\nline 2\nline 3'; + await createTestZip({ 'multi-line.txt': originalContent }, zipPath); + + archive = await openZipArchive(zipPath); + + const content = await archive.getEntryContent('multi-line.txt'); + expect(content.toString('utf-8')).toBe(originalContent); + expect(content.length).toBe(Buffer.byteLength(originalContent, 'utf-8')); + }); + + it('throws when reading a non-existent entry', async () => { + const zipPath = path.join(tmpDir, 'test.zip'); + await createTestZip({ 'a.txt': 'a' }, zipPath); + + archive = await openZipArchive(zipPath); + + expect(() => archive!.getEntryContent('does-not-exist.txt')).toThrow(/not found in archive/); + }); + + it('rejects when the zip file does not exist', async () => { + const zipPath = path.join(tmpDir, 'nonexistent.zip'); + + await expect(openZipArchive(zipPath)).rejects.toThrow(); + }); + + it('rejects when the file is not a valid zip', async () => { + const zipPath = path.join(tmpDir, 'not-a-zip.zip'); + await fs.writeFile(zipPath, 'this is not a zip file'); + + await expect(openZipArchive(zipPath)).rejects.toThrow(); + }); + + it('handles an empty zip archive', async () => { + const zipPath = path.join(tmpDir, 'empty.zip'); + await createTestZip({}, zipPath); + + archive = await openZipArchive(zipPath); + + expect(archive.getEntryPaths()).toEqual([]); + expect(archive.hasEntry('anything')).toBe(false); + }); + + it('handles entries with nested directory paths', async () => { + const zipPath = path.join(tmpDir, 'nested.zip'); + await createTestZip( + { + 'a/b/c/deep.txt': 'deep content', + 'a/b/sibling.txt': 'sibling content', + }, + zipPath + ); + + archive = await openZipArchive(zipPath); + + expect(archive.hasEntry('a/b/c/deep.txt')).toBe(true); + expect(archive.hasEntry('a/b/sibling.txt')).toBe(true); + + const deep = await archive.getEntryContent('a/b/c/deep.txt'); + expect(deep.toString('utf-8')).toBe('deep content'); + + const sibling = await archive.getEntryContent('a/b/sibling.txt'); + expect(sibling.toString('utf-8')).toBe('sibling content'); + }); + + it('can read multiple entries from the same archive', async () => { + const zipPath = path.join(tmpDir, 'multi.zip'); + await createTestZip( + { + 'first.txt': 'first', + 'second.txt': 'second', + 'third.txt': 'third', + }, + zipPath + ); + + archive = await openZipArchive(zipPath); + + const first = await archive.getEntryContent('first.txt'); + const second = await archive.getEntryContent('second.txt'); + const third = await archive.getEntryContent('third.txt'); + + expect(first.toString('utf-8')).toBe('first'); + expect(second.toString('utf-8')).toBe('second'); + expect(third.toString('utf-8')).toBe('third'); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/open_zip_archive.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/open_zip_archive.ts new file mode 100644 index 0000000000000..eb789bfbd6b77 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/archive/open_zip_archive.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import yauzl from 'yauzl'; + +export interface ZipArchive { + hasEntry(entryPath: string): boolean; + getEntryPaths(): string[]; + getEntryContent(entryPath: string): Promise; + close(): void; +} + +export const openZipArchive = async (archivePath: string): Promise => { + return new Promise((resolve, reject) => { + const entries: yauzl.Entry[] = []; + yauzl.open(archivePath, { lazyEntries: true, autoClose: false }, (err, zipFile) => { + if (err || !zipFile) { + return reject(err ?? new Error('Failed to open zip file')); + } + + zipFile.on('error', (zipErr) => { + zipFile.close(); + reject(zipErr); + }); + + zipFile.on('entry', (entry) => { + entries.push(entry); + zipFile.readEntry(); + }); + + zipFile.on('end', () => { + zipFile.removeAllListeners('error'); + resolve(new ZipArchiveImpl(entries, zipFile)); + }); + + zipFile.readEntry(); + }); + }); +}; + +class ZipArchiveImpl implements ZipArchive { + private readonly zipFile: yauzl.ZipFile; + private readonly entries: Map; + + constructor(entries: yauzl.Entry[], zipFile: yauzl.ZipFile) { + this.zipFile = zipFile; + this.entries = new Map(entries.map((entry) => [entry.fileName, entry])); + } + + hasEntry(entryPath: string) { + return this.entries.has(entryPath); + } + + getEntryPaths() { + return [...this.entries.keys()]; + } + + getEntryContent(entryPath: string) { + const foundEntry = this.entries.get(entryPath); + if (!foundEntry) { + throw new Error(`Entry ${entryPath} not found in archive`); + } + return getZipEntryContent(this.zipFile, foundEntry); + } + + close() { + this.zipFile.close(); + } +} + +const getZipEntryContent = async (zipFile: yauzl.ZipFile, entry: yauzl.Entry): Promise => { + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (err, readStream) => { + if (err || !readStream) { + return reject(err ?? new Error('Failed to open read stream')); + } + const chunks: Buffer[] = []; + readStream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + readStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + readStream.on('error', (streamErr) => { + reject(streamErr); + }); + }); + }); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/index.ts new file mode 100644 index 0000000000000..908823a192f1f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + openZipArchive, + createScopedArchive, + detectArchiveRootPrefix, + type ZipArchive, +} from './archive'; +export { parsePluginZipFile, PluginArchiveError, parseSkillFile } from './parsing'; +export { + parseGithubUrl, + getGithubArchiveUrl, + isGithubUrl, + type GithubUrlInfo, + resolvePluginUrl, + type ResolvedPluginUrl, + type ZipPluginUrl, + type GithubPluginUrl, + parsePluginFromUrl, + parsePluginFromFile, + saveUploadedFile, +} from './sourcing'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/index.ts new file mode 100644 index 0000000000000..ce0715b0644e4 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { parsePluginZipFile, PluginArchiveError } from './parse_plugin_zip_file'; +export { parseSkillFile } from './parse_skill_file'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_plugin_zip_file.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_plugin_zip_file.test.ts new file mode 100644 index 0000000000000..1953e8b774da5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_plugin_zip_file.test.ts @@ -0,0 +1,333 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parsePluginZipFile, PluginArchiveError } from './parse_plugin_zip_file'; +import type { ZipArchive } from '../archive'; + +const createMockArchive = (files: Record): ZipArchive => { + return { + hasEntry: (path: string) => path in files, + getEntryPaths: () => Object.keys(files), + getEntryContent: async (path: string) => { + if (!(path in files)) { + throw new Error(`Entry ${path} not found in archive`); + } + return Buffer.from(files[path], 'utf-8'); + }, + close: jest.fn(), + }; +}; + +describe('parsePluginZipFile', () => { + it('parses a valid plugin with manifest and skills', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'test-plugin', + version: '1.0.0', + description: 'A test plugin', + }), + 'skills/': '', + 'skills/my-skill/': '', + 'skills/my-skill/SKILL.md': [ + '---', + 'name: my-skill', + 'description: Does things', + 'allowed-tools: Read, Grep', + '---', + '', + 'Skill instructions here.', + ].join('\n'), + 'skills/my-skill/reference.md': 'Reference content here.', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.manifest).toEqual({ + name: 'test-plugin', + version: '1.0.0', + description: 'A test plugin', + }); + expect(result.skills).toHaveLength(1); + expect(result.skills[0].dirName).toBe('my-skill'); + expect(result.skills[0].meta).toEqual({ + name: 'my-skill', + description: 'Does things', + allowedTools: ['Read', 'Grep'], + }); + expect(result.skills[0].content).toBe('Skill instructions here.'); + expect(result.skills[0].referencedFiles).toEqual([ + { relativePath: 'reference.md', content: 'Reference content here.' }, + ]); + }); + + it('throws when manifest is missing', async () => { + const archive = createMockArchive({ + 'skills/my-skill/SKILL.md': 'Some content', + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow(PluginArchiveError); + await expect(parsePluginZipFile(archive)).rejects.toThrow(/manifest not found/); + }); + + it('reads manifest from root plugin.json as fallback', async () => { + const archive = createMockArchive({ + 'plugin.json': JSON.stringify({ + name: 'root-manifest-plugin', + version: '1.0.0', + }), + 'skills/my-skill/SKILL.md': 'Skill content.', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.manifest.name).toBe('root-manifest-plugin'); + expect(result.manifest.version).toBe('1.0.0'); + expect(result.skills).toHaveLength(1); + }); + + it('prefers .claude-plugin/plugin.json over root plugin.json', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ name: 'from-claude-plugin-dir' }), + 'plugin.json': JSON.stringify({ name: 'from-root' }), + }); + + const result = await parsePluginZipFile(archive); + + expect(result.manifest.name).toBe('from-claude-plugin-dir'); + }); + + it('throws when manifest JSON is invalid', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': '{ invalid json }', + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow(PluginArchiveError); + await expect(parsePluginZipFile(archive)).rejects.toThrow(/Invalid JSON/); + }); + + it('throws when manifest is missing the "name" field', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + version: '1.0.0', + description: 'No name', + }), + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow(PluginArchiveError); + await expect(parsePluginZipFile(archive)).rejects.toThrow(/Invalid plugin manifest.*"name"/); + }); + + it('throws when manifest "name" is empty', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ name: ' ' }), + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow( + /Invalid plugin manifest.*"name".*must not be empty/ + ); + }); + + it('detects unmanaged assets', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ name: 'test-plugin' }), + 'commands/': '', + 'commands/deploy.md': 'Deploy command', + 'commands/status.md': 'Status command', + 'agents/': '', + 'agents/reviewer.md': 'Reviewer agent', + 'hooks/': '', + 'hooks/hooks.json': '{}', + '.mcp.json': '{}', + '.lsp.json': '{}', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.skills).toHaveLength(0); + expect(result.unmanagedAssets.commands).toEqual( + expect.arrayContaining(['commands/deploy.md', 'commands/status.md']) + ); + expect(result.unmanagedAssets.agents).toEqual(['agents/reviewer.md']); + expect(result.unmanagedAssets.hooks).toEqual(['hooks/hooks.json']); + expect(result.unmanagedAssets.mcp_servers).toEqual(['.mcp.json']); + expect(result.unmanagedAssets.lsp_servers).toEqual(['.lsp.json']); + }); + + it('reads skills from custom paths defined in manifest', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'custom-paths', + skills: './custom/skills/', + }), + 'custom/skills/': '', + 'custom/skills/custom-skill/': '', + 'custom/skills/custom-skill/SKILL.md': [ + '---', + 'name: custom-skill', + '---', + '', + 'Custom skill content.', + ].join('\n'), + }); + + const result = await parsePluginZipFile(archive); + + expect(result.skills).toHaveLength(1); + expect(result.skills[0].dirName).toBe('custom-skill'); + expect(result.skills[0].meta.name).toBe('custom-skill'); + expect(result.skills[0].content).toBe('Custom skill content.'); + }); + + it('reads skills from both default and custom paths', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'multi-paths', + skills: './extra/skills/', + }), + 'skills/default-skill/SKILL.md': 'Default skill content.', + 'extra/skills/extra-skill/SKILL.md': 'Extra skill content.', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.skills).toHaveLength(2); + const dirNames = result.skills.map((s) => s.dirName); + expect(dirNames).toContain('default-skill'); + expect(dirNames).toContain('extra-skill'); + const skillContents = result.skills.map((s) => s.content); + expect(skillContents).toContain('Default skill content.'); + expect(skillContents).toContain('Extra skill content.'); + }); + + it('handles skills without frontmatter', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ name: 'test' }), + 'skills/simple/SKILL.md': 'Just instructions, no frontmatter.', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.skills).toHaveLength(1); + expect(result.skills[0].dirName).toBe('simple'); + expect(result.skills[0].meta).toEqual({}); + expect(result.skills[0].content).toBe('Just instructions, no frontmatter.'); + }); + + it('validates manifest field types', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'test', + version: 123, + }), + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow( + /Invalid plugin manifest.*"version".*Expected string/ + ); + }); + + it('validates manifest keywords as array of strings', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'test', + keywords: ['valid', 42], + }), + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow( + /Invalid plugin manifest.*"keywords\.1".*Expected string/ + ); + }); + + it('validates manifest path fields', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'test', + skills: 42, + }), + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow(/Invalid plugin manifest.*"skills"/); + }); + + it('rejects unknown manifest fields', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'test', + unknownField: 'value', + }), + }); + + await expect(parsePluginZipFile(archive)).rejects.toThrow( + /Invalid plugin manifest.*Unrecognized key/ + ); + }); + + it('parses a complete manifest with all metadata fields', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'full-plugin', + version: '2.0.0', + description: 'Full plugin', + author: { name: 'Test Author', email: 'test@example.com', url: 'https://example.com' }, + homepage: 'https://example.com/plugin', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test', 'plugin'], + }), + }); + + const result = await parsePluginZipFile(archive); + + expect(result.manifest).toEqual({ + name: 'full-plugin', + version: '2.0.0', + description: 'Full plugin', + author: { name: 'Test Author', email: 'test@example.com', url: 'https://example.com' }, + homepage: 'https://example.com/plugin', + repository: 'https://github.com/test/plugin', + license: 'MIT', + keywords: ['test', 'plugin'], + }); + }); + + it('detects unmanaged assets from custom manifest paths', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ + name: 'test', + commands: ['./custom/cmd.md'], + mcpServers: './mcp-config.json', + }), + 'custom/cmd.md': 'custom command', + 'mcp-config.json': '{}', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.unmanagedAssets.commands).toContain('custom/cmd.md'); + expect(result.unmanagedAssets.mcp_servers).toContain('mcp-config.json'); + }); + + it('collects multiple referenced files for a skill', async () => { + const archive = createMockArchive({ + '.claude-plugin/plugin.json': JSON.stringify({ name: 'test' }), + 'skills/rich-skill/SKILL.md': 'Skill content.', + 'skills/rich-skill/reference.md': 'Reference docs.', + 'skills/rich-skill/examples/sample.md': 'Example output.', + 'skills/rich-skill/scripts/validate.sh': '#!/bin/bash\necho ok', + }); + + const result = await parsePluginZipFile(archive); + + expect(result.skills).toHaveLength(1); + expect(result.skills[0].referencedFiles).toHaveLength(3); + const paths = result.skills[0].referencedFiles.map((f) => f.relativePath); + expect(paths).toContain('reference.md'); + expect(paths).toContain('examples/sample.md'); + expect(paths).toContain('scripts/validate.sh'); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_plugin_zip_file.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_plugin_zip_file.ts new file mode 100644 index 0000000000000..45f0403a2fcca --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_plugin_zip_file.ts @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import type { + PluginManifest, + ParsedPluginArchive, + ParsedSkillFile, + UnmanagedPluginAssets, +} from '@kbn/agent-builder-common'; +import type { ZipArchive } from '../archive'; +import { parseSkillFile } from './parse_skill_file'; + +const manifestPaths = ['.claude-plugin/plugin.json', 'plugin.json']; + +const defaultSkillsDir = 'skills/'; +const defaultCommandsDir = 'commands/'; +const defaultAgentsDir = 'agents/'; +const defaultHooksConfig = 'hooks/hooks.json'; +const defaultHooksDir = 'hooks/'; +const defaultMcpConfig = '.mcp.json'; +const defaultOutputStylesDir = 'outputStyles/'; +const defaultLspConfig = '.lsp.json'; + +/** + * Parses and validates a Claude plugin zip archive. + * + * Extracts the manifest, parses skill files, and detects + * unmanaged assets (commands, agents, hooks, etc.) that are + * present in the archive but not yet supported for installation. + */ +export const parsePluginZipFile = async (archive: ZipArchive): Promise => { + const manifest = await readAndValidateManifest(archive); + const skills = await readSkills(archive, manifest); + const unmanagedAssets = detectUnmanagedAssets(archive, manifest); + + return { manifest, skills, unmanagedAssets }; +}; + +const readAndValidateManifest = async (archive: ZipArchive): Promise => { + const resolvedPath = manifestPaths.find((p) => archive.hasEntry(p)); + if (!resolvedPath) { + throw new PluginArchiveError( + `Plugin manifest not found. Looked for ${manifestPaths.join( + ' and ' + )}. A plugin.json manifest is required.` + ); + } + + const content = await archive.getEntryContent(resolvedPath); + let parsed: unknown; + try { + parsed = JSON.parse(content.toString('utf-8')); + } catch (e) { + throw new PluginArchiveError( + `Invalid JSON in plugin manifest: ${e instanceof Error ? e.message : String(e)}` + ); + } + + return validateManifest(parsed); +}; + +const pathFieldSchema = z.union([z.string(), z.array(z.string())]); + +const pluginManifestSchema = z + .object({ + name: z + .string() + .refine((val) => val.trim().length > 0, { message: 'must not be empty' }) + .transform((val) => val.trim()), + version: z.string().optional(), + description: z.string().optional(), + author: z + .object({ + name: z.string(), + email: z.string().optional(), + url: z.string().optional(), + }) + .optional(), + homepage: z.string().optional(), + repository: z.string().optional(), + license: z.string().optional(), + keywords: z.array(z.string()).optional(), + commands: pathFieldSchema.optional(), + agents: pathFieldSchema.optional(), + skills: pathFieldSchema.optional(), + hooks: pathFieldSchema.optional(), + mcpServers: pathFieldSchema.optional(), + outputStyles: pathFieldSchema.optional(), + lspServers: pathFieldSchema.optional(), + }) + .strict(); + +const validateManifest = (raw: unknown): PluginManifest => { + const result = pluginManifestSchema.safeParse(raw); + if (!result.success) { + const messages = result.error.issues + .map((issue) => { + const path = issue.path.length > 0 ? `"${issue.path.join('.')}"` : 'root'; + return `${path}: ${issue.message}`; + }) + .join('; '); + throw new PluginArchiveError(`Invalid plugin manifest: ${messages}`); + } + return result.data; +}; + +const readSkills = async ( + archive: ZipArchive, + manifest: PluginManifest +): Promise => { + const skillDirs = resolveSkillDirs(archive, manifest); + const skills: ParsedSkillFile[] = []; + + for (const skillDir of skillDirs) { + const skillMdPath = `${skillDir}SKILL.md`; + if (!archive.hasEntry(skillMdPath)) { + continue; + } + + const content = await archive.getEntryContent(skillMdPath); + const { meta, content: body } = parseSkillFile(content.toString('utf-8')); + + const referencedFiles = await readReferencedFiles(archive, skillDir, skillMdPath); + const dirName = skillDir.replace(/\/$/, '').split('/').pop()!; + + skills.push({ + dirName, + meta, + content: body, + referencedFiles, + }); + } + + return skills; +}; + +/** + * Resolves all skill directories from the archive. + * A skill directory is any directory containing a SKILL.md file, + * found under the default `skills/` or custom paths from the manifest. + */ +const resolveSkillDirs = (archive: ZipArchive, manifest: PluginManifest): string[] => { + const searchRoots = new Set(); + searchRoots.add(defaultSkillsDir); + + if (manifest.skills !== undefined) { + const customPaths = Array.isArray(manifest.skills) ? manifest.skills : [manifest.skills]; + for (const p of customPaths) { + searchRoots.add(normalizeDirPath(p)); + } + } + + const skillDirs = new Set(); + const entries = archive.getEntryPaths(); + + for (const entryPath of entries) { + for (const root of searchRoots) { + if (entryPath.startsWith(root) && entryPath.endsWith('/SKILL.md')) { + const dir = entryPath.substring(0, entryPath.length - 'SKILL.md'.length); + skillDirs.add(dir); + } + } + } + + return [...skillDirs]; +}; + +const readReferencedFiles = async ( + archive: ZipArchive, + skillDir: string, + skillMdPath: string +): Promise> => { + const entries = archive.getEntryPaths(); + const referencedFiles: Array<{ relativePath: string; content: string }> = []; + + for (const entryPath of entries) { + if (entryPath.startsWith(skillDir) && entryPath !== skillMdPath && !entryPath.endsWith('/')) { + const fileContent = await archive.getEntryContent(entryPath); + referencedFiles.push({ + relativePath: entryPath.substring(skillDir.length), + content: fileContent.toString('utf-8'), + }); + } + } + + return referencedFiles; +}; + +const detectUnmanagedAssets = ( + archive: ZipArchive, + manifest: PluginManifest +): UnmanagedPluginAssets => { + const entries = archive.getEntryPaths(); + + return { + commands: findAssetEntries(entries, defaultCommandsDir, manifest.commands), + agents: findAssetEntries(entries, defaultAgentsDir, manifest.agents), + hooks: findAssetEntries(entries, defaultHooksDir, manifest.hooks, defaultHooksConfig), + mcp_servers: findAssetEntries(entries, undefined, manifest.mcpServers, defaultMcpConfig), + output_styles: findAssetEntries(entries, defaultOutputStylesDir, manifest.outputStyles), + lsp_servers: findAssetEntries(entries, undefined, manifest.lspServers, defaultLspConfig), + }; +}; + +/** + * Finds archive entries matching a given asset type. + * + * Searches: + * - entries under `defaultDir` (if provided) + * - entries matching manifest custom paths + * - a single known config file (if provided) + * + * Only returns non-directory entries. + */ +const findAssetEntries = ( + entries: string[], + defaultDir: string | undefined, + customPaths: string | string[] | undefined, + defaultConfigFile?: string +): string[] => { + const matched = new Set(); + + for (const entry of entries) { + if (entry.endsWith('/')) { + continue; + } + if (defaultDir && entry.startsWith(defaultDir)) { + matched.add(entry); + } + if (defaultConfigFile && entry === defaultConfigFile) { + matched.add(entry); + } + } + + if (customPaths !== undefined) { + const paths = Array.isArray(customPaths) ? customPaths : [customPaths]; + for (const customPath of paths) { + const cleaned = customPath.startsWith('./') ? customPath.substring(2) : customPath; + const asDir = cleaned.endsWith('/') ? cleaned : `${cleaned}/`; + for (const entry of entries) { + if (entry.endsWith('/')) { + continue; + } + if (entry === cleaned || entry.startsWith(asDir)) { + matched.add(entry); + } + } + } + } + + return [...matched]; +}; + +const normalizeDirPath = (p: string): string => { + const cleaned = p.startsWith('./') ? p.substring(2) : p; + return cleaned.endsWith('/') ? cleaned : `${cleaned}/`; +}; + +export class PluginArchiveError extends Error { + constructor(message: string) { + super(message); + this.name = 'PluginArchiveError'; + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_skill_file.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_skill_file.test.ts new file mode 100644 index 0000000000000..4d434022c433c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_skill_file.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parseSkillFile } from './parse_skill_file'; + +describe('parseSkillFile', () => { + it('parses a skill with all supported frontmatter fields', () => { + const raw = [ + '---', + 'name: my-skill', + 'description: What this skill does', + 'disable-model-invocation: true', + 'allowed-tools: Read, Grep, Glob', + '---', + '', + 'Your skill instructions here...', + ].join('\n'); + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({ + name: 'my-skill', + description: 'What this skill does', + disableModelInvocation: true, + allowedTools: ['Read', 'Grep', 'Glob'], + }); + expect(result.content).toBe('Your skill instructions here...'); + }); + + it('parses a skill with no frontmatter', () => { + const raw = 'Just some markdown content\n\nWith multiple paragraphs.'; + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({}); + expect(result.content).toBe('Just some markdown content\n\nWith multiple paragraphs.'); + }); + + it('parses a skill with partial frontmatter', () => { + const raw = ['---', 'name: partial-skill', '---', '', 'Content here.'].join('\n'); + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({ name: 'partial-skill' }); + expect(result.content).toBe('Content here.'); + }); + + it('trims allowed-tools values and filters empty strings', () => { + const raw = ['---', 'allowed-tools: Read , Grep , , Glob ', '---', '', 'Content.'].join( + '\n' + ); + + const result = parseSkillFile(raw); + + expect(result.meta.allowedTools).toEqual(['Read', 'Grep', 'Glob']); + }); + + it('handles disable-model-invocation set to false', () => { + const raw = ['---', 'disable-model-invocation: false', '---', '', 'Content.'].join('\n'); + + const result = parseSkillFile(raw); + + expect(result.meta.disableModelInvocation).toBe(false); + }); + + it('ignores unknown frontmatter fields', () => { + const raw = [ + '---', + 'name: my-skill', + 'context: fork', + 'agent: Explore', + 'model: fast', + '---', + '', + 'Content.', + ].join('\n'); + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({ name: 'my-skill' }); + expect(result.content).toBe('Content.'); + }); + + it('handles invalid YAML frontmatter gracefully', () => { + const raw = ['---', ' invalid: [yaml: broken', '---', '', 'Content.'].join('\n'); + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({}); + expect(result.content).toBe('Content.'); + }); + + it('handles empty frontmatter', () => { + const raw = ['---', '---', '', 'Content.'].join('\n'); + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({}); + expect(result.content).toBe('Content.'); + }); + + it('trims content whitespace', () => { + const raw = ['---', 'name: trimmer', '---', '', ' Content with leading space. ', ''].join( + '\n' + ); + + const result = parseSkillFile(raw); + + expect(result.content).toBe('Content with leading space.'); + }); + + it('parses frontmatter with CRLF line endings', () => { + const raw = [ + '---', + 'name: crlf-skill', + 'description: A CRLF skill', + '---', + '', + 'Content.', + ].join('\r\n'); + + const result = parseSkillFile(raw); + + expect(result.meta).toEqual({ name: 'crlf-skill', description: 'A CRLF skill' }); + expect(result.content).toBe('Content.'); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_skill_file.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_skill_file.ts new file mode 100644 index 0000000000000..16a14a1c7d452 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/parsing/parse_skill_file.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import yaml from 'js-yaml'; +import type { ParsedSkillMeta } from '@kbn/agent-builder-common'; + +export interface ParsedSkillFileResult { + meta: ParsedSkillMeta; + content: string; +} + +const frontmatterRegex = /^---\s*\r?\n([\s\S]*?)---\s*\r?\n?([\s\S]*)$/; + +/** + * Parses a SKILL.md file content, extracting YAML frontmatter metadata + * and the markdown body. + */ +export const parseSkillFile = (rawContent: string): ParsedSkillFileResult => { + const match = rawContent.match(frontmatterRegex); + if (!match) { + return { + meta: {}, + content: rawContent.trim(), + }; + } + + const [, frontmatterRaw, body] = match; + const meta = parseFrontmatter(frontmatterRaw); + + return { + meta, + content: body.trim(), + }; +}; + +const parseFrontmatter = (raw: string): ParsedSkillMeta => { + let parsed: Record; + try { + parsed = (yaml.load(raw) as Record) ?? {}; + } catch { + return {}; + } + + if (typeof parsed !== 'object' || parsed === null) { + return {}; + } + + const meta: ParsedSkillMeta = {}; + + if (typeof parsed.name === 'string') { + meta.name = parsed.name; + } + if (typeof parsed.description === 'string') { + meta.description = parsed.description; + } + if (typeof parsed['disable-model-invocation'] === 'boolean') { + meta.disableModelInvocation = parsed['disable-model-invocation']; + } + if (typeof parsed['allowed-tools'] === 'string') { + meta.allowedTools = parsed['allowed-tools'] + .split(',') + .map((tool) => tool.trim()) + .filter((tool) => tool.length > 0); + } + + return meta; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/download_plugin.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/download_plugin.test.ts new file mode 100644 index 0000000000000..5de268bb04c42 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/download_plugin.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Readable, PassThrough } from 'stream'; +import { pipeline } from 'stream/promises'; +import type { ParsedPluginArchive } from '@kbn/agent-builder-common'; +import type { ZipArchive } from '../archive'; + +const mockOpenZipArchive = jest.fn(); +jest.mock('../archive', () => ({ + openZipArchive: (...args: unknown[]) => mockOpenZipArchive(...args), +})); + +const mockParsePluginZipFile = jest.fn(); +jest.mock('../parsing', () => ({ + parsePluginZipFile: (...args: unknown[]) => mockParsePluginZipFile(...args), + PluginArchiveError: class PluginArchiveError extends Error { + constructor(message: string) { + super(message); + this.name = 'PluginArchiveError'; + } + }, +})); + +import { parsePluginFromFile, createSizeLimitTransform } from './download_plugin'; + +describe('parsePluginFromFile', () => { + let mockArchive: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockArchive = { + hasEntry: jest.fn().mockReturnValue(false), + getEntryPaths: jest.fn().mockReturnValue([]), + getEntryContent: jest.fn(), + close: jest.fn(), + }; + mockOpenZipArchive.mockResolvedValue(mockArchive); + }); + + it('opens the zip file and parses it', async () => { + const expectedResult: ParsedPluginArchive = { + manifest: { name: 'test-plugin' }, + skills: [], + unmanagedAssets: { + commands: [], + agents: [], + hooks: [], + mcp_servers: [], + output_styles: [], + lsp_servers: [], + }, + }; + + mockParsePluginZipFile.mockResolvedValue(expectedResult); + + const result = await parsePluginFromFile('/path/to/plugin.zip'); + + expect(mockOpenZipArchive).toHaveBeenCalledWith('/path/to/plugin.zip'); + expect(mockParsePluginZipFile).toHaveBeenCalledWith(mockArchive); + expect(mockArchive.close).toHaveBeenCalled(); + expect(result).toBe(expectedResult); + }); + + it('closes the archive even when parsing fails', async () => { + mockParsePluginZipFile.mockRejectedValue(new Error('parse error')); + + await expect(parsePluginFromFile('/path/to/bad.zip')).rejects.toThrow('parse error'); + + expect(mockArchive.close).toHaveBeenCalled(); + }); + + it('closes the archive even when opening succeeds but parsing throws synchronously', async () => { + mockParsePluginZipFile.mockImplementation(() => { + throw new Error('sync error'); + }); + + await expect(parsePluginFromFile('/path/to/file.zip')).rejects.toThrow('sync error'); + + expect(mockArchive.close).toHaveBeenCalled(); + }); +}); + +describe('createSizeLimitTransform', () => { + it('passes through data under the limit', async () => { + const transform = createSizeLimitTransform(100, 'http://example.com'); + const input = Readable.from([Buffer.alloc(50), Buffer.alloc(30)]); + const output = new PassThrough(); + + const chunks: Buffer[] = []; + output.on('data', (chunk: Buffer) => chunks.push(chunk)); + + await pipeline(input, transform, output); + + expect(Buffer.concat(chunks).length).toBe(80); + }); + + it('rejects when data exceeds the limit', async () => { + const transform = createSizeLimitTransform(100, 'http://example.com/big.zip'); + const input = Readable.from([Buffer.alloc(60), Buffer.alloc(60)]); + const output = new PassThrough(); + + await expect(pipeline(input, transform, output)).rejects.toThrow( + /exceeds the maximum allowed size/ + ); + }); + + it('rejects when a single chunk exceeds the limit', async () => { + const transform = createSizeLimitTransform(50, 'http://example.com/huge.zip'); + const input = Readable.from([Buffer.alloc(100)]); + const output = new PassThrough(); + + await expect(pipeline(input, transform, output)).rejects.toThrow( + /exceeds the maximum allowed size of 50 bytes/ + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/download_plugin.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/download_plugin.ts new file mode 100644 index 0000000000000..0a58f5f865230 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/download_plugin.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { randomUUID } from 'crypto'; +import { Readable, Transform } from 'stream'; +import type { ReadableStream as WebReadableStream } from 'stream/web'; +import { pipeline } from 'stream/promises'; +import { createWriteStream, deleteFile, getSafePath } from '@kbn/fs'; +import type { ParsedPluginArchive } from '@kbn/agent-builder-common'; +import { + openZipArchive, + createScopedArchive, + detectArchiveRootPrefix, + type ZipArchive, +} from '../archive'; +import { parsePluginZipFile, PluginArchiveError } from '../parsing'; +import { resolvePluginUrl, type ResolvePluginUrlOptions } from './resolve_plugin_url'; + +const VOLUME = 'agent_builder'; + +export type ParsePluginFromUrlOptions = ResolvePluginUrlOptions; + +/** + * Downloads a plugin from a URL, parses its contents, and returns + * the parsed plugin archive. + * + * Supported URL formats: + * - `https://github.com/{owner}/{repo}/tree/{ref}/{path}` -- GitHub folder + * - `https://github.com/{owner}/{repo}` -- GitHub repo root + * - `https://github.com/{owner}/{repo}/blob/{ref}/{path}/plugin.json` -- GitHub blob to manifest + * - `https://example.com/plugin.zip` -- direct zip download + */ +export const parsePluginFromUrl = async ( + url: string, + options: ParsePluginFromUrlOptions = {} +): Promise => { + const resolved = resolvePluginUrl(url, options); + + const { archive, cleanup } = await downloadAndOpenArchive(resolved.downloadUrl); + try { + if (resolved.type === 'github') { + const scopedArchive = scopeToPlugin(archive, resolved.pluginPath); + return await parsePluginZipFile(scopedArchive); + } + return await parsePluginZipFile(archive); + } finally { + archive.close(); + await cleanup(); + } +}; + +const scopeToPlugin = (archive: ZipArchive, pluginPath?: string): ZipArchive => { + const rootPrefix = detectArchiveRootPrefix(archive); + const scopePrefix = pluginPath ? `${rootPrefix}${pluginPath}/` : rootPrefix; + + const scopedArchive = createScopedArchive(archive, scopePrefix); + + if (scopedArchive.getEntryPaths().length === 0) { + throw new PluginArchiveError( + `No files found at path "${pluginPath ?? '/'}" in the downloaded archive.` + ); + } + + return scopedArchive; +}; + +const downloadAndOpenArchive = async ( + downloadUrl: string +): Promise<{ archive: ZipArchive; cleanup: () => Promise }> => { + const fileName = `tmp/${randomUUID()}.zip`; + + let fullPath: string; + try { + fullPath = await downloadToFile(downloadUrl, fileName); + } catch (e) { + await deleteFile(fileName, { volume: VOLUME }).catch(() => {}); + throw e; + } + + let archive: ZipArchive; + try { + archive = await openZipArchive(fullPath); + } catch (e) { + await deleteFile(fileName, { volume: VOLUME }).catch(() => {}); + throw e; + } + + const cleanup = async () => { + await deleteFile(fileName, { volume: VOLUME }).catch(() => {}); + }; + + return { archive, cleanup }; +}; + +/** + * Parses a plugin from a local zip file already on disk. + */ +export const parsePluginFromFile = async (filePath: string): Promise => { + const archive = await openZipArchive(filePath); + try { + return await parsePluginZipFile(archive); + } finally { + archive.close(); + } +}; + +const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB, same as upload route + +const downloadToFile = async (url: string, fileName: string): Promise => { + const res = await fetch(url, { redirect: 'follow' }); + + if (!res.ok) { + throw new PluginArchiveError( + `Failed to download plugin archive from ${url}: ${res.status} ${res.statusText}` + ); + } + + if (!res.body) { + throw new PluginArchiveError(`Empty response body when downloading from ${url}`); + } + + const contentLength = res.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) { + throw new PluginArchiveError( + `Plugin archive from ${url} exceeds the maximum allowed size of ${MAX_DOWNLOAD_BYTES} bytes.` + ); + } + + const { fullPath } = getSafePath(fileName, VOLUME); + const readStream = Readable.fromWeb(res.body as WebReadableStream); + const sizeGuard = createSizeLimitTransform(MAX_DOWNLOAD_BYTES, url); + const writeStream = createWriteStream(fileName, VOLUME); + await pipeline(readStream, sizeGuard, writeStream); + + return fullPath; +}; + +export const createSizeLimitTransform = (maxBytes: number, url: string): Transform => { + let bytesReceived = 0; + return new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytesReceived += chunk.length; + if (bytesReceived > maxBytes) { + callback( + new PluginArchiveError( + `Plugin archive from ${url} exceeds the maximum allowed size of ${maxBytes} bytes.` + ) + ); + } else { + callback(null, chunk); + } + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/index.ts new file mode 100644 index 0000000000000..8163bdb3956fc --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + parseGithubUrl, + getGithubArchiveUrl, + isGithubUrl, + type GithubUrlInfo, +} from './parse_github_url'; +export { + resolvePluginUrl, + type ResolvedPluginUrl, + type ZipPluginUrl, + type GithubPluginUrl, + type ResolvePluginUrlOptions, +} from './resolve_plugin_url'; +export { parsePluginFromUrl, parsePluginFromFile } from './download_plugin'; +export { saveUploadedFile } from './save_uploaded_file'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/parse_github_url.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/parse_github_url.test.ts new file mode 100644 index 0000000000000..bddc02e3e4b25 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/parse_github_url.test.ts @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parseGithubUrl, getGithubArchiveUrl, isGithubUrl } from './parse_github_url'; + +describe('parseGithubUrl', () => { + it('parses a full tree URL with ref and path', () => { + const result = parseGithubUrl( + 'https://github.com/anthropics/claude-code/tree/main/plugins/explanatory-output-style' + ); + + expect(result).toEqual({ + owner: 'anthropics', + repo: 'claude-code', + ref: 'main', + path: 'plugins/explanatory-output-style', + }); + }); + + it('parses a URL with ref but no path', () => { + const result = parseGithubUrl('https://github.com/owner/repo/tree/develop'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'develop', + }); + }); + + it('parses a URL with just owner and repo', () => { + const result = parseGithubUrl('https://github.com/owner/repo'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'main', + }); + }); + + it('defaults ref to main when not specified', () => { + const result = parseGithubUrl('https://github.com/owner/repo'); + + expect(result.ref).toBe('main'); + }); + + it('handles .git suffix', () => { + const result = parseGithubUrl('https://github.com/owner/repo.git'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'main', + }); + }); + + it('strips trailing slashes from path', () => { + const result = parseGithubUrl('https://github.com/owner/repo/tree/main/some/path/'); + + expect(result.path).toBe('some/path'); + }); + + it('handles deeply nested paths', () => { + const result = parseGithubUrl('https://github.com/owner/repo/tree/v2.0.0/a/b/c/d'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'v2.0.0', + path: 'a/b/c/d', + }); + }); + + it('throws on invalid URLs', () => { + expect(() => parseGithubUrl('https://gitlab.com/owner/repo')).toThrow(/Invalid GitHub URL/); + expect(() => parseGithubUrl('not-a-url')).toThrow(/Invalid GitHub URL/); + expect(() => parseGithubUrl('https://github.com/')).toThrow(/Invalid GitHub URL/); + expect(() => parseGithubUrl('https://github.com/owner')).toThrow(/Invalid GitHub URL/); + }); + + it('handles http scheme', () => { + const result = parseGithubUrl('http://github.com/owner/repo/tree/main/path'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'main', + path: 'path', + }); + }); + + describe('blob URLs', () => { + it('parses a blob URL to a file', () => { + const result = parseGithubUrl( + 'https://github.com/owner/repo/blob/main/plugins/foo/plugin.json' + ); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'main', + path: 'plugins/foo/plugin.json', + }); + }); + + it('parses a blob URL to .claude-plugin/plugin.json', () => { + const result = parseGithubUrl( + 'https://github.com/anthropics/claude-code/blob/main/plugins/foo/.claude-plugin/plugin.json' + ); + + expect(result).toEqual({ + owner: 'anthropics', + repo: 'claude-code', + ref: 'main', + path: 'plugins/foo/.claude-plugin/plugin.json', + }); + }); + + it('parses a blob URL with a tag ref', () => { + const result = parseGithubUrl('https://github.com/owner/repo/blob/v1.0.0/path/to/file.md'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'v1.0.0', + path: 'path/to/file.md', + }); + }); + }); +}); + +describe('getGithubArchiveUrl', () => { + it('builds the archive download URL', () => { + const url = getGithubArchiveUrl({ + owner: 'anthropics', + repo: 'claude-code', + ref: 'main', + path: 'plugins/explanatory-output-style', + }); + + expect(url).toBe('https://github.com/anthropics/claude-code/archive/main.zip'); + }); + + it('works with tag refs', () => { + const url = getGithubArchiveUrl({ + owner: 'owner', + repo: 'repo', + ref: 'v1.0.0', + }); + + expect(url).toBe('https://github.com/owner/repo/archive/v1.0.0.zip'); + }); +}); + +describe('isGithubUrl', () => { + it('returns true for GitHub tree URLs', () => { + expect(isGithubUrl('https://github.com/owner/repo/tree/main/path')).toBe(true); + }); + + it('returns true for GitHub blob URLs', () => { + expect(isGithubUrl('https://github.com/owner/repo/blob/main/file.json')).toBe(true); + }); + + it('returns true for bare GitHub repo URLs', () => { + expect(isGithubUrl('https://github.com/owner/repo')).toBe(true); + }); + + it('returns false for non-GitHub URLs', () => { + expect(isGithubUrl('https://example.com/plugin.zip')).toBe(false); + expect(isGithubUrl('not-a-url')).toBe(false); + }); +}); + +describe('custom baseUrl', () => { + const baseUrl = 'http://localhost:9321'; + + describe('parseGithubUrl with custom baseUrl', () => { + it('parses a tree URL with a custom base', () => { + const result = parseGithubUrl( + 'http://localhost:9321/owner/repo/tree/main/plugins/foo', + baseUrl + ); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'main', + path: 'plugins/foo', + }); + }); + + it('parses a bare repo URL with a custom base', () => { + const result = parseGithubUrl('http://localhost:9321/owner/repo', baseUrl); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + ref: 'main', + }); + }); + + it('rejects a github.com URL when a custom base is used', () => { + expect(() => parseGithubUrl('https://github.com/owner/repo/tree/main/path', baseUrl)).toThrow( + /Invalid GitHub URL/ + ); + }); + }); + + describe('getGithubArchiveUrl with custom baseUrl', () => { + it('builds the archive URL using the custom base', () => { + const url = getGithubArchiveUrl({ owner: 'owner', repo: 'repo', ref: 'main' }, baseUrl); + + expect(url).toBe('http://localhost:9321/owner/repo/archive/main.zip'); + }); + }); + + describe('isGithubUrl with custom baseUrl', () => { + it('matches URLs against the custom base', () => { + expect(isGithubUrl('http://localhost:9321/owner/repo/tree/main/path', baseUrl)).toBe(true); + expect(isGithubUrl('http://localhost:9321/owner/repo', baseUrl)).toBe(true); + }); + + it('rejects github.com URLs when a custom base is provided', () => { + expect(isGithubUrl('https://github.com/owner/repo', baseUrl)).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/parse_github_url.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/parse_github_url.ts new file mode 100644 index 0000000000000..a483e35eaae10 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/parse_github_url.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface GithubUrlInfo { + owner: string; + repo: string; + /** Branch, tag or commit. Defaults to 'main' when not present in the URL. */ + ref: string; + /** Path within the repository (without leading/trailing slashes), or undefined for root. */ + path?: string; +} + +const DEFAULT_GITHUB_BASE_URL = 'https://github.com'; + +const escapeForRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const buildGithubUrlRegex = (baseUrl: string): RegExp => { + const { host } = new URL(baseUrl); + return new RegExp( + `^https?:\\/\\/${escapeForRegex( + host + )}\\/(?[^/]+)\\/(?[^/]+?)(?:\\.git)?(?:\\/(?:tree|blob)\\/(?[^/]+)(?:\\/(?.+))?)?$` + ); +}; + +const defaultGithubUrlRegex = buildGithubUrlRegex(DEFAULT_GITHUB_BASE_URL); + +/** + * Parses a GitHub repository URL into its components. + * + * Supported formats: + * - `{baseUrl}/{owner}/{repo}` + * - `{baseUrl}/{owner}/{repo}.git` + * - `{baseUrl}/{owner}/{repo}/tree/{ref}` + * - `{baseUrl}/{owner}/{repo}/tree/{ref}/{path}` + * - `{baseUrl}/{owner}/{repo}/blob/{ref}/{path}` + */ +export const parseGithubUrl = (url: string, baseUrl?: string): GithubUrlInfo => { + const regex = baseUrl ? buildGithubUrlRegex(baseUrl) : defaultGithubUrlRegex; + const match = url.match(regex); + if (!match?.groups) { + throw new Error( + `Invalid GitHub URL: "${url}". Expected format: https://github.com/{owner}/{repo}/tree/{ref}/{path}` + ); + } + + const { owner, repo, ref, path: rawPath } = match.groups; + const cleanedPath = rawPath?.replace(/\/+$/, ''); + + return { + owner, + repo, + ref: ref ?? 'main', + ...(cleanedPath && { path: cleanedPath }), + }; +}; + +/** + * Returns the URL to download the repository archive as a zip file. + */ +export const getGithubArchiveUrl = ( + { owner, repo, ref }: GithubUrlInfo, + baseUrl: string = DEFAULT_GITHUB_BASE_URL +): string => { + return `${baseUrl}/${owner}/${repo}/archive/${ref}.zip`; +}; + +/** + * Checks whether a URL is a GitHub URL (tree, blob, or bare repo). + */ +export const isGithubUrl = (url: string, baseUrl?: string): boolean => { + const regex = baseUrl ? buildGithubUrlRegex(baseUrl) : defaultGithubUrlRegex; + return regex.test(url); +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/resolve_plugin_url.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/resolve_plugin_url.test.ts new file mode 100644 index 0000000000000..a7cd53d917e14 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/resolve_plugin_url.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { resolvePluginUrl } from './resolve_plugin_url'; + +describe('resolvePluginUrl', () => { + describe('GitHub tree URLs', () => { + it('resolves a tree URL with a path', () => { + const result = resolvePluginUrl( + 'https://github.com/anthropics/claude-code/tree/main/plugins/foo' + ); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/anthropics/claude-code/archive/main.zip', + pluginPath: 'plugins/foo', + }); + }); + + it('resolves a bare repo URL (plugin at root)', () => { + const result = resolvePluginUrl('https://github.com/owner/repo'); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/owner/repo/archive/main.zip', + }); + }); + + it('resolves a tree URL at repo root', () => { + const result = resolvePluginUrl('https://github.com/owner/repo/tree/develop'); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/owner/repo/archive/develop.zip', + }); + }); + }); + + describe('GitHub blob URLs to plugin.json', () => { + it('resolves a blob URL to .claude-plugin/plugin.json', () => { + const result = resolvePluginUrl( + 'https://github.com/anthropics/claude-code/blob/main/plugins/foo/.claude-plugin/plugin.json' + ); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/anthropics/claude-code/archive/main.zip', + pluginPath: 'plugins/foo', + }); + }); + + it('resolves a blob URL to plugin.json in a subfolder', () => { + const result = resolvePluginUrl( + 'https://github.com/owner/repo/blob/main/plugins/foo/plugin.json' + ); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/owner/repo/archive/main.zip', + pluginPath: 'plugins/foo', + }); + }); + + it('resolves a blob URL to plugin.json at repo root', () => { + const result = resolvePluginUrl('https://github.com/owner/repo/blob/main/plugin.json'); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/owner/repo/archive/main.zip', + }); + }); + + it('resolves a blob URL to .claude-plugin/plugin.json at repo root', () => { + const result = resolvePluginUrl( + 'https://github.com/owner/repo/blob/main/.claude-plugin/plugin.json' + ); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'https://github.com/owner/repo/archive/main.zip', + }); + }); + }); + + describe('direct zip URLs', () => { + it('resolves a direct .zip URL', () => { + const result = resolvePluginUrl('https://example.com/plugins/my-plugin.zip'); + + expect(result).toEqual({ + type: 'zip', + downloadUrl: 'https://example.com/plugins/my-plugin.zip', + }); + }); + + it('resolves a .zip URL with query parameters', () => { + const result = resolvePluginUrl('https://example.com/plugin.zip?token=abc'); + + expect(result).toEqual({ + type: 'zip', + downloadUrl: 'https://example.com/plugin.zip?token=abc', + }); + }); + }); + + describe('unsupported URLs', () => { + it('throws on an unsupported URL', () => { + expect(() => resolvePluginUrl('https://example.com/plugin')).toThrow( + /Unsupported plugin URL/ + ); + }); + + it('throws on a non-URL string', () => { + expect(() => resolvePluginUrl('not-a-url')).toThrow(/Unsupported plugin URL/); + }); + }); + + describe('custom githubBaseUrl', () => { + const githubBaseUrl = 'http://localhost:9321'; + + it('resolves a tree URL against the custom base', () => { + const result = resolvePluginUrl('http://localhost:9321/owner/repo/tree/main/plugins/foo', { + githubBaseUrl, + }); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'http://localhost:9321/owner/repo/archive/main.zip', + pluginPath: 'plugins/foo', + }); + }); + + it('resolves a bare repo URL against the custom base', () => { + const result = resolvePluginUrl('http://localhost:9321/owner/repo', { githubBaseUrl }); + + expect(result).toEqual({ + type: 'github', + downloadUrl: 'http://localhost:9321/owner/repo/archive/main.zip', + }); + }); + + it('treats a github.com URL as unsupported when a custom base is provided', () => { + expect(() => resolvePluginUrl('https://github.com/owner/repo', { githubBaseUrl })).toThrow( + /Unsupported plugin URL/ + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/resolve_plugin_url.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/resolve_plugin_url.ts new file mode 100644 index 0000000000000..5bae9bc769d2c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/resolve_plugin_url.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createBadRequestError } from '@kbn/agent-builder-common'; +import { isGithubUrl, parseGithubUrl, getGithubArchiveUrl } from './parse_github_url'; + +/** + * A direct zip file URL. The zip itself contains the plugin at its root. + */ +export interface ZipPluginUrl { + type: 'zip'; + downloadUrl: string; +} + +/** + * A GitHub-hosted plugin. The entire repo archive is downloaded as a zip, + * then scoped to `pluginPath` within it. + */ +export interface GithubPluginUrl { + type: 'github'; + downloadUrl: string; + pluginPath?: string; +} + +export type ResolvedPluginUrl = ZipPluginUrl | GithubPluginUrl; + +export interface ResolvePluginUrlOptions { + githubBaseUrl?: string; +} + +/** + * Classifies a URL and resolves it into a normalized descriptor + * that `parsePluginFromUrl` can consume. + * + * Supported inputs: + * - GitHub folder URL (`/tree/`) -> download repo archive, scope to folder + * - GitHub `plugin.json` blob URL (`/blob/`) -> derive the plugin folder, then same as above + * - Direct `.zip` URL -> download the zip as-is + */ +export const resolvePluginUrl = ( + url: string, + options: ResolvePluginUrlOptions = {} +): ResolvedPluginUrl => { + const { githubBaseUrl } = options; + + if (looksLikeZipUrl(url)) { + return { type: 'zip', downloadUrl: url }; + } + + if (isGithubUrl(url, githubBaseUrl)) { + return resolveGithubUrl(url, githubBaseUrl); + } + + throw createBadRequestError( + `Unsupported plugin URL: "${url}". Provide a GitHub repository URL or a direct link to a .zip file.` + ); +}; + +const resolveGithubUrl = (url: string, githubBaseUrl?: string): GithubPluginUrl => { + const info = parseGithubUrl(url, githubBaseUrl); + const downloadUrl = getGithubArchiveUrl(info, githubBaseUrl); + + const pluginPath = derivePluginPath(info.path); + + return { + type: 'github', + downloadUrl, + ...(pluginPath !== undefined && { pluginPath }), + }; +}; + +/** + * Given the raw path from a GitHub URL, derives the plugin root folder. + * + * - `/tree/main/plugins/foo` -> `plugins/foo` (path is the plugin folder) + * - `/blob/main/plugins/foo/plugin.json` -> `plugins/foo` + * - `/blob/main/plugins/foo/.claude-plugin/plugin.json` -> `plugins/foo` + * - no path -> undefined (plugin is at repo root) + */ +const derivePluginPath = (rawPath?: string): string | undefined => { + if (!rawPath) { + return undefined; + } + + // .claude-plugin/plugin.json at repo root + if (rawPath === '.claude-plugin/plugin.json') { + return undefined; + } + // plugin.json at repo root + if (rawPath === 'plugin.json') { + return undefined; + } + + // .claude-plugin/plugin.json inside a subfolder + if (rawPath.endsWith('/.claude-plugin/plugin.json')) { + return rawPath.slice(0, -'/.claude-plugin/plugin.json'.length); + } + // plugin.json inside a subfolder + if (rawPath.endsWith('/plugin.json')) { + return rawPath.slice(0, -'/plugin.json'.length); + } + + return rawPath; +}; + +const looksLikeZipUrl = (url: string): boolean => { + try { + const { pathname } = new URL(url); + return pathname.endsWith('.zip'); + } catch { + return false; + } +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts new file mode 100644 index 0000000000000..972c076a899ea --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Readable, PassThrough } from 'stream'; + +const mockGetSafePath = jest.fn(); +const mockCreateWriteStream = jest.fn(); +const mockDeleteFile = jest.fn(); + +jest.mock('@kbn/fs', () => ({ + getSafePath: (...args: unknown[]) => mockGetSafePath(...args), + createWriteStream: (...args: unknown[]) => mockCreateWriteStream(...args), + deleteFile: (...args: unknown[]) => mockDeleteFile(...args), +})); + +jest.mock('crypto', () => ({ + randomUUID: () => 'test-uuid-1234', +})); + +import { saveUploadedFile } from './save_uploaded_file'; + +describe('saveUploadedFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetSafePath.mockReturnValue({ fullPath: '/data/agent_builder/tmp/test-uuid-1234.zip' }); + mockDeleteFile.mockResolvedValue(undefined); + }); + + it('saves the stream to a temporary file and returns the path', async () => { + const writeStream = new PassThrough(); + mockCreateWriteStream.mockReturnValue(writeStream); + + const input = Readable.from(Buffer.from('zip content')); + + const result = await saveUploadedFile(input); + + expect(mockGetSafePath).toHaveBeenCalledWith('tmp/test-uuid-1234.zip', 'agent_builder'); + expect(mockCreateWriteStream).toHaveBeenCalledWith('tmp/test-uuid-1234.zip', 'agent_builder'); + expect(result.filePath).toBe('/data/agent_builder/tmp/test-uuid-1234.zip'); + expect(typeof result.cleanup).toBe('function'); + }); + + it('cleanup deletes the temporary file', async () => { + const writeStream = new PassThrough(); + mockCreateWriteStream.mockReturnValue(writeStream); + + const input = Readable.from(Buffer.from('zip content')); + + const { cleanup } = await saveUploadedFile(input); + await cleanup(); + + expect(mockDeleteFile).toHaveBeenCalledWith('tmp/test-uuid-1234.zip', { + volume: 'agent_builder', + }); + }); + + it('cleanup swallows errors from deleteFile', async () => { + const writeStream = new PassThrough(); + mockCreateWriteStream.mockReturnValue(writeStream); + mockDeleteFile.mockRejectedValue(new Error('delete failed')); + + const input = Readable.from(Buffer.from('zip content')); + + const { cleanup } = await saveUploadedFile(input); + + await expect(cleanup()).resolves.toBeUndefined(); + }); + + it('deletes the temp file when the pipeline fails', async () => { + const writeStream = new PassThrough(); + writeStream.destroy(new Error('write error')); + mockCreateWriteStream.mockReturnValue(writeStream); + + const input = Readable.from(Buffer.from('zip content')); + + await expect(saveUploadedFile(input)).rejects.toThrow(); + + expect(mockDeleteFile).toHaveBeenCalledWith('tmp/test-uuid-1234.zip', { + volume: 'agent_builder', + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.ts new file mode 100644 index 0000000000000..17a914003e572 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import { randomUUID } from 'crypto'; +import { createWriteStream, deleteFile, getSafePath } from '@kbn/fs'; + +const VOLUME = 'agent_builder'; + +/** + * Saves an uploaded file stream to a temporary location in the Kibana data folder. + * + * Returns the full path to the saved file and a cleanup function + * that removes the temporary file when called. + */ +export const saveUploadedFile = async ( + stream: Readable +): Promise<{ filePath: string; cleanup: () => Promise }> => { + const fileName = `tmp/${randomUUID()}.zip`; + const { fullPath } = getSafePath(fileName, VOLUME); + const writeStream = createWriteStream(fileName, VOLUME); + try { + await pipeline(stream, writeStream); + } catch (err) { + await deleteFile(fileName, { volume: VOLUME }).catch(() => {}); + throw err; + } + return { + filePath: fullPath, + cleanup: () => deleteFile(fileName, { volume: VOLUME }).catch(() => {}), + }; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.test.ts index 51f4a78803998..b881c4d056d74 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.test.ts @@ -6,7 +6,11 @@ */ import { loggerMock } from '@kbn/logging-mocks'; -import { isSkillNotFoundError, isBadRequestError } from '@kbn/agent-builder-common'; +import { + isSkillNotFoundError, + isBadRequestError, + type PersistedSkillCreateRequest, +} from '@kbn/agent-builder-common'; import { createClient, type SkillClient } from './client'; const testSpace = 'default'; @@ -39,12 +43,14 @@ interface MockEsClient { search: jest.Mock; index: jest.Mock; delete: jest.Mock; + bulk: jest.Mock; } const mockEsClient: MockEsClient = { search: jest.fn(), index: jest.fn(), delete: jest.fn(), + bulk: jest.fn(), }; jest.mock('./storage', () => ({ @@ -235,6 +241,73 @@ describe('SkillClient', () => { }); }); + describe('bulkCreate', () => { + const createRequest = (id: string): PersistedSkillCreateRequest => ({ + id, + name: `Skill ${id}`, + description: `Description for ${id}`, + content: `Content for ${id}`, + referenced_content: [], + tool_ids: [], + plugin_id: 'my-plugin', + }); + + it('indexes all skills in a single bulk request', async () => { + mockEsClient.bulk.mockResolvedValue({ errors: false, items: [], took: 1 }); + + const requests = [createRequest('skill-a'), createRequest('skill-b')]; + const results = await client.bulkCreate(requests); + + expect(mockEsClient.bulk).toHaveBeenCalledTimes(1); + const bulkCall = mockEsClient.bulk.mock.calls[0][0]; + expect(bulkCall.throwOnFail).toBe(true); + expect(bulkCall.operations).toHaveLength(2); + + expect(bulkCall.operations[0]).toEqual({ + index: { + document: expect.objectContaining({ id: 'skill-a', space: testSpace }), + }, + }); + expect(bulkCall.operations[1]).toEqual({ + index: { + document: expect.objectContaining({ id: 'skill-b', space: testSpace }), + }, + }); + + expect(results).toHaveLength(2); + expect(results[0].id).toBe('skill-a'); + expect(results[0].name).toBe('Skill skill-a'); + expect(results[0].plugin_id).toBe('my-plugin'); + expect(results[1].id).toBe('skill-b'); + }); + + it('returns empty array for empty input', async () => { + const results = await client.bulkCreate([]); + + expect(results).toEqual([]); + expect(mockEsClient.bulk).not.toHaveBeenCalled(); + }); + + it('uses the same creation timestamp for all skills', async () => { + mockEsClient.bulk.mockResolvedValue({ errors: false, items: [], took: 1 }); + + const requests = [createRequest('skill-a'), createRequest('skill-b')]; + const results = await client.bulkCreate(requests); + + expect(results[0].created_at).toBe(results[1].created_at); + expect(results[0].updated_at).toBe(results[1].updated_at); + expect(results[0].created_at).toBe(results[0].updated_at); + }); + + it('propagates bulk operation errors', async () => { + mockEsClient.bulk.mockRejectedValue(new Error('Bulk operation failed')); + + await expect(client.bulkCreate([createRequest('skill-a')])).rejects.toThrow( + 'Bulk operation failed' + ); + }); + }); + describe('update', () => { it('merges the update and returns the updated skill', async () => { mockEsClient.search.mockResolvedValue({ diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.ts index 0221e72a658db..555cdc444b733 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/client.ts @@ -15,7 +15,7 @@ import { } from '@kbn/agent-builder-common'; import { createSpaceDslFilter } from '../../../../utils/spaces'; import type { SkillStorage } from './storage'; -import { createStorage } from './storage'; +import { createStorage, skillIndexName } from './storage'; import { fromEs, createAttributes, updateDocument } from './converters'; import type { SkillDocument, SkillPersistedDefinition } from './types'; @@ -30,9 +30,19 @@ export interface SkillClient { create(request: PersistedSkillCreateRequest): Promise; update(skillId: string, updates: PersistedSkillUpdateRequest): Promise; /** - * Deletes a skill. Throws if the skill does not exist. + * Deletes a skill. Throws if the skill does not exist or is plugin-managed. */ delete(skillId: string): Promise; + /** + * Creates multiple skills in a single bulk request. + * Optimized for plugin installation where IDs are deterministic. + * Does not perform per-skill uniqueness checks. + */ + bulkCreate(requests: PersistedSkillCreateRequest[]): Promise; + /** + * Deletes all skills associated with the given plugin. + */ + deleteByPluginId(pluginId: string): Promise; has(skillId: string): Promise; } @@ -46,26 +56,30 @@ export const createClient = ({ esClient: ElasticsearchClient; }): SkillClient => { const storage = createStorage({ logger, esClient }); - return new SkillClientImpl({ space, storage, logger }); + return new SkillClientImpl({ space, storage, logger, esClient }); }; class SkillClientImpl implements SkillClient { private readonly space: string; private readonly storage: SkillStorage; private readonly logger: Logger; + private readonly esClient: ElasticsearchClient; constructor({ space, storage, logger, + esClient, }: { space: string; storage: SkillStorage; logger: Logger; + esClient: ElasticsearchClient; }) { this.space = space; this.storage = storage; this.logger = logger; + this.esClient = esClient; } async get(id: string): Promise { @@ -121,12 +135,49 @@ class SkillClientImpl implements SkillClient { return this.get(id); } + async bulkCreate(requests: PersistedSkillCreateRequest[]): Promise { + if (requests.length === 0) { + return []; + } + + const creationDate = new Date(); + const allAttributes = requests.map((createRequest) => + createAttributes({ createRequest, space: this.space, creationDate }) + ); + + await this.storage.getClient().bulk({ + operations: allAttributes.map((attributes) => ({ + index: { document: attributes }, + })), + throwOnFail: true, + }); + + return allAttributes.map((attributes) => ({ + id: attributes.id, + name: attributes.name, + description: attributes.description, + content: attributes.content, + referenced_content: attributes.referenced_content, + tool_ids: attributes.tool_ids, + plugin_id: attributes.plugin_id, + created_at: attributes.created_at, + updated_at: attributes.updated_at, + })); + } + async update(id: string, update: PersistedSkillUpdateRequest): Promise { const document = await this._get(id); if (!document) { throw createSkillNotFoundError({ skillId: id }); } + const skill = fromEs(document); + if (skill.plugin_id) { + throw createBadRequestError( + `Skill '${id}' is managed by plugin '${skill.plugin_id}' and cannot be modified directly.` + ); + } + const updatedAttributes = updateDocument({ current: document._source!, update, @@ -148,12 +199,31 @@ class SkillClientImpl implements SkillClient { if (!document) { throw createSkillNotFoundError({ skillId: id }); } + + const skill = fromEs(document); + if (skill.plugin_id) { + throw createBadRequestError( + `Skill '${id}' is managed by plugin '${skill.plugin_id}' and cannot be deleted directly.` + ); + } + const result = await this.storage.getClient().delete({ id: document._id }); if (result.result === 'not_found') { throw createSkillNotFoundError({ skillId: id }); } } + async deleteByPluginId(pluginId: string): Promise { + await this.esClient.deleteByQuery({ + index: `${skillIndexName}*`, + query: { + bool: { + filter: [createSpaceDslFilter(this.space), { term: { plugin_id: pluginId } }], + }, + }, + }); + } + async has(id: string): Promise { const document = await this._get(id); return document !== undefined; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/converters.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/converters.ts index 3d79377a1b67c..c0fcb2aef4297 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/converters.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/converters.ts @@ -23,6 +23,7 @@ export const fromEs = (document: SkillDocument): SkillPersistedDefinition => { content: document._source.content, referenced_content: document._source.referenced_content, tool_ids: document._source.tool_ids ?? [], + plugin_id: document._source.plugin_id, created_at: document._source.created_at, updated_at: document._source.updated_at, }; @@ -45,6 +46,7 @@ export const createAttributes = ({ content: createRequest.content, referenced_content: createRequest.referenced_content, tool_ids: createRequest.tool_ids ?? [], + plugin_id: createRequest.plugin_id, created_at: creationDate.toISOString(), updated_at: creationDate.toISOString(), }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/storage.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/storage.ts index 733721e810359..1e13b8ae17554 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/storage.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/storage.ts @@ -27,6 +27,7 @@ const storageSettings = { properties: {}, }), tool_ids: types.keyword({}), + plugin_id: types.keyword({}), created_at: types.date({}), updated_at: types.date({}), }, @@ -41,6 +42,7 @@ export interface SkillProperties { content: string; referenced_content?: SkillReferencedContent[]; tool_ids: string[]; + plugin_id?: string; created_at: string; updated_at: string; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/types.ts index 5f35a6d7fca82..4b69ab51842d6 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/client/types.ts @@ -18,6 +18,7 @@ export interface SkillPersistedDefinition { content: string; referenced_content?: SkillReferencedContent[]; tool_ids: string[]; + plugin_id?: string; created_at: string; updated_at: string; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.test.ts index 127902b025009..ce387251d4b5f 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.test.ts @@ -32,11 +32,20 @@ describe('convertPersistedSkill', () => { expect(result.content).toBe('Instructions content'); }); - it('sets readonly to false', () => { + it('sets readonly to false for user-created skills', () => { const skill = createMockPersistedSkill(); const result = convertPersistedSkill(skill); expect(result.readonly).toBe(false); + expect(result.plugin_id).toBeUndefined(); + }); + + it('sets readonly to true for plugin-managed skills', () => { + const skill = createMockPersistedSkill({ plugin_id: 'my-plugin' }); + const result = convertPersistedSkill(skill); + + expect(result.readonly).toBe(true); + expect(result.plugin_id).toBe('my-plugin'); }); it('set a default basepath', () => { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.ts index f5fb9b9b2cb1c..abe94f78758d7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/converter.ts @@ -16,11 +16,12 @@ export const convertPersistedSkill = ( basePath: '/skills', description: skill.description, content: skill.content, - readonly: false, + readonly: !!skill.plugin_id, referencedContent: skill.referenced_content?.map((rc) => ({ name: rc.name, relativePath: rc.relativePath, content: rc.content, })), getRegistryTools: () => skill.tool_ids ?? [], + plugin_id: skill.plugin_id, }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/provider.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/provider.test.ts index ea8ae14ba0fbe..0be4e3504cc01 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/provider.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/persisted/provider.test.ts @@ -31,8 +31,10 @@ const createMockClient = (): jest.Mocked => ({ get: jest.fn(), list: jest.fn(), create: jest.fn(), + bulkCreate: jest.fn(), update: jest.fn(), delete: jest.fn(), + deleteByPluginId: jest.fn(), }); describe('createPersistedSkillProvider', () => { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.test.ts index fc9467fc2a28e..e3dd9d4a44364 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.test.ts @@ -21,8 +21,17 @@ jest.mock('../runner/store/volumes/skills/utils', () => ({ getSkillEntryPath: jest.fn(({ skill }) => `${skill.basePath}/${skill.name}/SKILL.md`), })); -jest.mock('./persisted/client/client', () => ({ - createClient: jest.fn(), +jest.mock('./persisted/client', () => ({ + createClient: jest.fn(() => ({ + has: jest.fn().mockResolvedValue(false), + get: jest.fn().mockRejectedValue(new Error('not found')), + list: jest.fn().mockResolvedValue([]), + create: jest.fn(), + bulkCreate: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteByPluginId: jest.fn(), + })), })); jest.mock('../../utils/spaces', () => ({ diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.test.ts index 5b109ed567826..a75f17fa6e1a7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.test.ts @@ -86,6 +86,20 @@ describe('internalToPublicDefinition', () => { expect(result.tool_ids).toEqual([]); }); + it('includes plugin_id when present', async () => { + const skill = createMockInternalSkill({ plugin_id: 'my-plugin' }); + const result = await internalToPublicDefinition(skill); + + expect(result.plugin_id).toBe('my-plugin'); + }); + + it('does not include plugin_id when absent', async () => { + const skill = createMockInternalSkill(); + const result = await internalToPublicDefinition(skill); + + expect(result.plugin_id).toBeUndefined(); + }); + it('does not include basePath or getInlineTools in the public definition', async () => { const skill = createMockInternalSkill({ basePath: 'skills/platform', diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.ts index 9e4c3500aa914..de85181d776e5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/utils.ts @@ -26,4 +26,5 @@ export const internalToPublicDefinition = async ( })), tool_ids: await skill.getRegistryTools(), readonly: skill.readonly, + plugin_id: skill.plugin_id, }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts index ec6148970b3b9..615bb23b51b36 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts @@ -31,6 +31,7 @@ import type { AnalyticsService } from '../telemetry'; import type { AuditLogService } from '../audit'; import type { AgentExecutionService, TaskHandler } from './execution'; import type { MeteringService } from './metering'; +import type { PluginsServiceSetup, PluginsServiceStart } from './plugins'; export interface InternalSetupServices { tools: ToolsServiceSetup; @@ -38,6 +39,7 @@ export interface InternalSetupServices { attachments: AttachmentServiceSetup; hooks: HooksServiceSetup; skills: SkillServiceSetup; + plugins: PluginsServiceSetup; metering: MeteringService; } @@ -56,6 +58,7 @@ export interface InternalStartServices { savedObjects: SavedObjectsServiceStart; execution: AgentExecutionService; taskHandler: TaskHandler; + plugins: PluginsServiceStart; } export interface ServiceSetupDeps { diff --git a/x-pack/platform/plugins/shared/agent_builder/tsconfig.json b/x-pack/platform/plugins/shared/agent_builder/tsconfig.json index 41a917b9ea155..8a3d6fa17058c 100644 --- a/x-pack/platform/plugins/shared/agent_builder/tsconfig.json +++ b/x-pack/platform/plugins/shared/agent_builder/tsconfig.json @@ -110,5 +110,6 @@ "@kbn/evals-plugin", "@kbn/usage-api-plugin", "@kbn/es-query", + "@kbn/fs", ] } diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/index.ts b/x-pack/platform/test/agent_builder_api_integration/apis/index.ts index 03282270f9a63..d38be9af2a133 100644 --- a/x-pack/platform/test/agent_builder_api_integration/apis/index.ts +++ b/x-pack/platform/test/agent_builder_api_integration/apis/index.ts @@ -24,5 +24,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./spaces.ts')); loadTestFile(require.resolve('./skills/skills_crud.ts')); loadTestFile(require.resolve('./skills/skills_validation.ts')); + loadTestFile(require.resolve('./plugins')); }); } diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/plugin.json b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/plugin.json new file mode 100644 index 0000000000000..de4d959e6bc2a --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "test-plugin", + "version": "1.0.0", + "description": "A test plugin for integration testing" +} diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/skills/test-skill/SKILL.md b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/skills/test-skill/SKILL.md new file mode 100644 index 0000000000000..0d5873ddc13f7 --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/skills/test-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: Test Skill +description: A test skill for integration testing +--- +This is the content of the test skill. + +It provides instructions for testing plugin installation. diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/skills/test-skill/reference.txt b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/skills/test-skill/reference.txt new file mode 100644 index 0000000000000..67362b108b201 --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/assets/test-plugin/skills/test-skill/reference.txt @@ -0,0 +1,2 @@ +This is a referenced file for the test skill. +It should be installed alongside the skill content. diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/plugins/index.ts b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/index.ts new file mode 100644 index 0000000000000..32bcab8500e29 --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Plugins API', function () { + loadTestFile(require.resolve('./installation')); + }); +} diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/plugins/installation.ts b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/installation.ts new file mode 100644 index 0000000000000..90fb4ce7c1a9e --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/apis/plugins/installation.ts @@ -0,0 +1,359 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import path from 'path'; +import archiver from 'archiver'; +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { PluginsTestServer } from '../../utils/plugins_server'; + +const ASSETS_DIR = path.resolve(__dirname, './assets'); + +const PLUGIN_NAME = 'test-plugin'; +const PLUGIN_DESCRIPTION = 'A test plugin for integration testing'; +const PLUGIN_VERSION = '1.0.0'; +const SKILL_DIR_NAME = 'test-skill'; +const EXPECTED_SKILL_ID = `${PLUGIN_NAME}-${SKILL_DIR_NAME}`; +const EXPECTED_SKILL_NAME = 'Test Skill'; + +const createZipBuffer = async (): Promise => { + return new Promise((resolve, reject) => { + const archive = archiver('zip', { zlib: { level: 0 } }); + const chunks: Buffer[] = []; + + archive.on('data', (chunk: Buffer) => chunks.push(chunk)); + archive.on('end', () => resolve(Buffer.concat(chunks))); + archive.on('error', reject); + + archive.directory(path.join(ASSETS_DIR, PLUGIN_NAME), false); + void archive.finalize(); + }); +}; + +const getPluginsServerPort = (serverArgs: string[]): number => { + const githubBaseUrlArg = serverArgs.find((arg) => + arg.startsWith('--xpack.agentBuilder.githubBaseUrl=') + ); + if (!githubBaseUrlArg) { + throw new Error( + 'Missing --xpack.agentBuilder.githubBaseUrl in kbnTestServer.serverArgs. ' + + 'The plugins test server port must be configured in the FTR config.' + ); + } + const url = new URL(githubBaseUrlArg.split('=')[1]); + return parseInt(url.port, 10); +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const log = getService('log'); + const config = getService('config'); + + describe('Plugin Installation API', function () { + this.tags(['skipServerless']); + + let pluginsServer: PluginsTestServer; + let serverUrl: string; + let zipBuffer: Buffer; + const createdPluginIds: string[] = []; + + before(async () => { + const serverArgs: string[] = config.get('kbnTestServer.serverArgs'); + const port = getPluginsServerPort(serverArgs); + pluginsServer = new PluginsTestServer({ port, assetsDir: ASSETS_DIR, log }); + await pluginsServer.start(); + serverUrl = pluginsServer.getUrl(); + zipBuffer = await createZipBuffer(); + }); + + after(async () => { + for (const pluginId of createdPluginIds) { + try { + await supertest + .delete(`/api/agent_builder/plugins/${pluginId}`) + .set('kbn-xsrf', 'kibana') + .set('elastic-api-version', '2023-10-31') + .expect(200); + } catch (error) { + log.warning(`Failed to cleanup plugin ${pluginId}: ${error.message}`); + } + } + pluginsServer.stop(); + }); + + const installFromUrl = (url: string, pluginName?: string) => { + const body: Record = { url }; + if (pluginName) { + body.plugin_name = pluginName; + } + return supertest + .post('/api/agent_builder/plugins/install') + .set('kbn-xsrf', 'kibana') + .set('elastic-api-version', '2023-10-31') + .send(body); + }; + + const installFromUpload = (pluginName?: string) => { + let req = supertest + .post('/internal/agent_builder/plugins/upload') + .set('kbn-xsrf', 'kibana') + .attach('file', zipBuffer, 'plugin.zip'); + if (pluginName) { + req = req.field('plugin_name', pluginName); + } + return req; + }; + + const deletePlugin = (pluginId: string) => { + return supertest + .delete(`/api/agent_builder/plugins/${pluginId}`) + .set('kbn-xsrf', 'kibana') + .set('elastic-api-version', '2023-10-31'); + }; + + const listSkills = async () => { + const response = await supertest.get('/api/agent_builder/skills').expect(200); + return response.body.results as Array<{ + id: string; + name: string; + readonly: boolean; + plugin_id?: string; + }>; + }; + + const findPluginSkill = async (skillId: string) => { + const skills = await listSkills(); + return skills.find((s) => s.id === skillId); + }; + + describe('install from remote zip URL', () => { + let pluginId: string; + + after(async () => { + if (pluginId) { + const idx = createdPluginIds.indexOf(pluginId); + if (idx !== -1) { + createdPluginIds.splice(idx, 1); + } + } + }); + + it('installs the plugin and returns correct metadata', async () => { + const response = await installFromUrl(`${serverUrl}/plugins/${PLUGIN_NAME}.zip`).expect( + 200 + ); + + pluginId = response.body.id; + createdPluginIds.push(pluginId); + + expect(response.body.name).to.be(PLUGIN_NAME); + expect(response.body.version).to.be(PLUGIN_VERSION); + expect(response.body.description).to.be(PLUGIN_DESCRIPTION); + expect(response.body.skill_ids).to.contain(EXPECTED_SKILL_ID); + }); + + it('creates the associated skills', async () => { + const skill = await findPluginSkill(EXPECTED_SKILL_ID); + expect(skill).to.be.ok(); + expect(skill!.name).to.be(EXPECTED_SKILL_NAME); + expect(skill!.readonly).to.be(true); + expect(skill!.plugin_id).to.be(PLUGIN_NAME); + }); + + it('uninstalls the plugin', async () => { + await deletePlugin(pluginId).expect(200); + const idx = createdPluginIds.indexOf(pluginId); + if (idx !== -1) { + createdPluginIds.splice(idx, 1); + } + }); + + it('removes the associated skills on uninstall', async () => { + const skill = await findPluginSkill(EXPECTED_SKILL_ID); + expect(skill).to.be(undefined); + }); + }); + + describe('install from GitHub-style URL', () => { + let pluginId: string; + + after(async () => { + if (pluginId) { + const idx = createdPluginIds.indexOf(pluginId); + if (idx !== -1) { + createdPluginIds.splice(idx, 1); + } + } + }); + + it('installs the plugin and returns correct metadata', async () => { + const githubUrl = `${serverUrl}/test-owner/${PLUGIN_NAME}/tree/main`; + const response = await installFromUrl(githubUrl).expect(200); + + pluginId = response.body.id; + createdPluginIds.push(pluginId); + + expect(response.body.name).to.be(PLUGIN_NAME); + expect(response.body.version).to.be(PLUGIN_VERSION); + expect(response.body.description).to.be(PLUGIN_DESCRIPTION); + expect(response.body.skill_ids).to.contain(EXPECTED_SKILL_ID); + }); + + it('creates the associated skills', async () => { + const skill = await findPluginSkill(EXPECTED_SKILL_ID); + expect(skill).to.be.ok(); + expect(skill!.name).to.be(EXPECTED_SKILL_NAME); + expect(skill!.readonly).to.be(true); + expect(skill!.plugin_id).to.be(PLUGIN_NAME); + }); + + it('uninstalls the plugin', async () => { + await deletePlugin(pluginId).expect(200); + const idx = createdPluginIds.indexOf(pluginId); + if (idx !== -1) { + createdPluginIds.splice(idx, 1); + } + }); + + it('removes the associated skills on uninstall', async () => { + const skill = await findPluginSkill(EXPECTED_SKILL_ID); + expect(skill).to.be(undefined); + }); + }); + + describe('install from zip file upload', () => { + let pluginId: string; + + after(async () => { + if (pluginId) { + const idx = createdPluginIds.indexOf(pluginId); + if (idx !== -1) { + createdPluginIds.splice(idx, 1); + } + } + }); + + it('installs the plugin and returns correct metadata', async () => { + const response = await installFromUpload().expect(200); + + pluginId = response.body.id; + createdPluginIds.push(pluginId); + + expect(response.body.name).to.be(PLUGIN_NAME); + expect(response.body.version).to.be(PLUGIN_VERSION); + expect(response.body.description).to.be(PLUGIN_DESCRIPTION); + expect(response.body.skill_ids).to.contain(EXPECTED_SKILL_ID); + }); + + it('creates the associated skills', async () => { + const skill = await findPluginSkill(EXPECTED_SKILL_ID); + expect(skill).to.be.ok(); + expect(skill!.name).to.be(EXPECTED_SKILL_NAME); + expect(skill!.readonly).to.be(true); + expect(skill!.plugin_id).to.be(PLUGIN_NAME); + }); + + it('uninstalls the plugin', async () => { + await deletePlugin(pluginId).expect(200); + const idx = createdPluginIds.indexOf(pluginId); + if (idx !== -1) { + createdPluginIds.splice(idx, 1); + } + }); + + it('removes the associated skills on uninstall', async () => { + const skill = await findPluginSkill(EXPECTED_SKILL_ID); + expect(skill).to.be(undefined); + }); + }); + + describe('duplicate installation', () => { + it('rejects installing the same plugin twice', async () => { + const response = await installFromUrl(`${serverUrl}/plugins/${PLUGIN_NAME}.zip`).expect( + 200 + ); + + createdPluginIds.push(response.body.id); + + const duplicateResponse = await installFromUrl( + `${serverUrl}/plugins/${PLUGIN_NAME}.zip` + ).expect(400); + + expect(duplicateResponse.body.message).to.contain('already installed'); + }); + }); + + describe('plugin-managed skills are read-only', () => { + before(async () => { + const existing = await supertest + .get('/api/agent_builder/plugins') + .set('elastic-api-version', '2023-10-31') + .expect(200); + + const alreadyInstalled = existing.body.results.find( + (p: { name: string }) => p.name === 'readonly-test-plugin' + ); + if (alreadyInstalled) { + return; + } + + const response = await installFromUrl( + `${serverUrl}/plugins/${PLUGIN_NAME}.zip`, + 'readonly-test-plugin' + ).expect(200); + + createdPluginIds.push(response.body.id); + }); + + const readonlySkillId = `readonly-test-plugin-${SKILL_DIR_NAME}`; + + it('rejects updating a plugin-managed skill', async () => { + await supertest + .put(`/api/agent_builder/skills/${readonlySkillId}`) + .set('kbn-xsrf', 'kibana') + .send({ name: 'new-name', description: 'new', content: 'new', tool_ids: [] }) + .expect(400); + }); + + it('rejects deleting a plugin-managed skill', async () => { + await supertest + .delete(`/api/agent_builder/skills/${readonlySkillId}`) + .set('kbn-xsrf', 'kibana') + .expect(400); + }); + }); + + describe('plugin name override', () => { + const overrideName = 'custom-plugin-name'; + const overrideSkillId = `${overrideName}-${SKILL_DIR_NAME}`; + + it('installs with the overridden name', async () => { + const response = await installFromUrl( + `${serverUrl}/plugins/${PLUGIN_NAME}.zip`, + overrideName + ).expect(200); + + createdPluginIds.push(response.body.id); + + expect(response.body.name).to.be(overrideName); + expect(response.body.skill_ids).to.contain(overrideSkillId); + }); + + it('creates skills with the overridden plugin name as plugin_id', async () => { + const skill = await findPluginSkill(overrideSkillId); + expect(skill).to.be.ok(); + expect(skill!.plugin_id).to.be(overrideName); + }); + }); + + describe('invalid URL', () => { + it('rejects a URL that is neither a zip nor a GitHub URL', async () => { + await installFromUrl('https://example.com/not-a-plugin').expect(400); + }); + }); + }); +} diff --git a/x-pack/platform/test/agent_builder_api_integration/configs/config.stateful.ts b/x-pack/platform/test/agent_builder_api_integration/configs/config.stateful.ts index fdb49032989e7..2ec55ab37ee00 100644 --- a/x-pack/platform/test/agent_builder_api_integration/configs/config.stateful.ts +++ b/x-pack/platform/test/agent_builder_api_integration/configs/config.stateful.ts @@ -5,26 +5,35 @@ * 2.0. */ +import type { FtrConfigProviderContext } from '@kbn/test'; +import getPort from 'get-port'; import { createStatefulTestConfig } from '../../api_integration_deployment_agnostic/default_configs/stateful.config.base'; import { agentBuilderApiServices } from '../../agent_builder/services/api'; -export default createStatefulTestConfig({ - services: agentBuilderApiServices, - testFiles: [require.resolve('../apis')], - junit: { - reportName: 'X-Pack Agent Builder Stateful API Integration Tests', - }, - // @ts-expect-error - kbnTestServer: { - serverArgs: [ - `--logging.loggers=${JSON.stringify([ - { - name: 'plugins.agentBuilder', - level: 'all', - appenders: ['default'], - }, - ])}`, - '--uiSettings.overrides.agentBuilder:experimentalFeatures=true', - ], - }, -}); +export default async (context: FtrConfigProviderContext) => { + const pluginsServerPort = await getPort({ port: getPort.makeRange(18300, 18399) }); + + const configProvider = createStatefulTestConfig({ + services: agentBuilderApiServices, + testFiles: [require.resolve('../apis')], + junit: { + reportName: 'X-Pack Agent Builder Stateful API Integration Tests', + }, + // @ts-expect-error + kbnTestServer: { + serverArgs: [ + `--logging.loggers=${JSON.stringify([ + { + name: 'plugins.agentBuilder', + level: 'all', + appenders: ['default'], + }, + ])}`, + '--uiSettings.overrides.agentBuilder:experimentalFeatures=true', + `--xpack.agentBuilder.githubBaseUrl=http://localhost:${pluginsServerPort}`, + ], + }, + }); + + return configProvider(context); +}; diff --git a/x-pack/platform/test/agent_builder_api_integration/utils/plugins_server/index.ts b/x-pack/platform/test/agent_builder_api_integration/utils/plugins_server/index.ts new file mode 100644 index 0000000000000..b1919a58a1d3d --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/utils/plugins_server/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PluginsTestServer } from './plugins_server'; diff --git a/x-pack/platform/test/agent_builder_api_integration/utils/plugins_server/plugins_server.ts b/x-pack/platform/test/agent_builder_api_integration/utils/plugins_server/plugins_server.ts new file mode 100644 index 0000000000000..b12da96fcdc56 --- /dev/null +++ b/x-pack/platform/test/agent_builder_api_integration/utils/plugins_server/plugins_server.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import http from 'http'; +import archiver from 'archiver'; +import type { ToolingLog } from '@kbn/tooling-log'; + +interface PluginsTestServerOptions { + port: number; + assetsDir: string; + log: ToolingLog; +} + +/** + * A lightweight HTTP server that serves plugin zip archives for integration tests. + * + * Supports two modes: + * - Direct zip: `GET /plugins/{name}.zip` — archive with plugin at root + * - GitHub-style: `GET /{owner}/{repo}/archive/{ref}.zip` — archive with `{repo}-{ref}/` root prefix + */ +export class PluginsTestServer { + private server?: http.Server; + private readonly port: number; + private readonly assetsDir: string; + private readonly log: ToolingLog; + + constructor({ port, assetsDir, log }: PluginsTestServerOptions) { + this.port = port; + this.assetsDir = assetsDir; + this.log = log; + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = http + .createServer((req, res) => { + this.handleRequest(req, res); + }) + .on('error', reject) + .listen(this.port, () => { + this.log.info(`PluginsTestServer listening on port ${this.port}`); + resolve(); + }); + }); + } + + stop(): void { + if (this.server) { + this.server.close(); + this.log.info('PluginsTestServer stopped'); + } + } + + getUrl(): string { + return `http://localhost:${this.port}`; + } + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = req.url ?? ''; + + // Direct zip: /plugins/{name}.zip + const directZipMatch = url.match(/^\/plugins\/([^/]+)\.zip$/); + if (directZipMatch) { + const pluginName = directZipMatch[1]; + this.serveDirectZip(pluginName, res); + return; + } + + // GitHub-style: /{owner}/{repo}/archive/{ref}.zip + const githubMatch = url.match(/^\/[^/]+\/([^/]+)\/archive\/([^/]+)\.zip$/); + if (githubMatch) { + const repo = githubMatch[1]; + const ref = githubMatch[2]; + this.serveGithubArchive(repo, ref, res); + return; + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + } + + /** + * Serves a zip with the plugin files directly at the archive root. + */ + private serveDirectZip(pluginName: string, res: http.ServerResponse): void { + const pluginDir = `${this.assetsDir}/${pluginName}`; + const archive = archiver('zip', { zlib: { level: 0 } }); + + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${pluginName}.zip"`, + }); + + archive.pipe(res); + archive.directory(pluginDir, false); + void archive.finalize(); + } + + /** + * Serves a zip that mimics GitHub's archive format: + * all files are nested under a `{repo}-{ref}/` root prefix. + */ + private serveGithubArchive(repo: string, ref: string, res: http.ServerResponse): void { + const pluginDir = `${this.assetsDir}/${repo}`; + const rootPrefix = `${repo}-${ref}`; + const archive = archiver('zip', { zlib: { level: 0 } }); + + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${repo}-${ref}.zip"`, + }); + + archive.pipe(res); + archive.directory(pluginDir, rootPrefix); + void archive.finalize(); + } +}