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