mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-05-25 18:36:59 +08:00
### What problem does this PR solve? issue #13714 ### Type of change - [x] New Feature (non-breaking change which adds functionality)
438 lines
14 KiB
Go
438 lines
14 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package filesystem
|
|
|
|
import (
|
|
stdctx "context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/net/http2"
|
|
"golang.org/x/net/publicsuffix"
|
|
|
|
"ragflow/internal/cli/filesystem/skill_hub/security"
|
|
"ragflow/internal/cli/filesystem/skill_hub/source"
|
|
)
|
|
|
|
// InstallSkillArgs holds the parsed arguments for install-skill command
|
|
type InstallSkillArgs struct {
|
|
SpaceID string // Target skills space ID
|
|
SourceRef string // Source reference (path or identifier)
|
|
Version string // Skill version
|
|
SkillName string // Optional: override skill name
|
|
Force bool // Force reinstall
|
|
SkipVerify bool // Skip security verification
|
|
ShowHelp bool
|
|
}
|
|
|
|
// SkillInstallCommand handles the install-skill command
|
|
type SkillInstallCommand struct {
|
|
client HTTPClientInterface
|
|
fileProvider *FileProvider
|
|
skillProvider Provider
|
|
scanner *security.Scanner
|
|
guard *security.Guard
|
|
sourceResolver *source.SourceResolver
|
|
}
|
|
|
|
// sourceHTTPClientAdapter adapts filesystem.HTTPClientInterface to source.HTTPClientInterface
|
|
// This allows us to use the existing HTTP client infrastructure with the source package
|
|
type sourceHTTPClientAdapter struct {
|
|
client HTTPClientInterface
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func (a *sourceHTTPClientAdapter) Do(req *http.Request) (*http.Response, error) {
|
|
// Use standard http.Client for direct requests (e.g., GitHub API)
|
|
// This bypasses the RAGFlow API client which adds its own base URL
|
|
return a.httpClient.Do(req)
|
|
}
|
|
|
|
func (a *sourceHTTPClientAdapter) Get(url string) (*http.Response, error) {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.Do(req)
|
|
}
|
|
|
|
// NewInstallSkillCommand creates a new install-skill command handler
|
|
func NewInstallSkillCommand(client HTTPClientInterface, fileProvider *FileProvider, skillProvider Provider) *SkillInstallCommand {
|
|
// Log proxy settings
|
|
if httpProxy := os.Getenv("http_proxy"); httpProxy != "" {
|
|
fmt.Printf("Using HTTP proxy: %s\n", httpProxy)
|
|
}
|
|
if httpsProxy := os.Getenv("https_proxy"); httpsProxy != "" {
|
|
fmt.Printf("Using HTTPS proxy: %s\n", httpsProxy)
|
|
}
|
|
|
|
// Create transport with HTTP/2 support and connection reuse
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
// Enable connection pooling
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
// Enable keep-alive
|
|
DisableKeepAlives: false,
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
// Enable HTTP/2
|
|
http2.ConfigureTransport(transport)
|
|
|
|
// Check what proxy will be used
|
|
testURL, _ := url.Parse("https://github.com")
|
|
if proxy, err := transport.Proxy(&http.Request{URL: testURL}); err == nil && proxy != nil {
|
|
fmt.Printf("Proxy enabled for GitHub: %s\n", proxy.String())
|
|
} else if err != nil {
|
|
fmt.Printf("Warning: proxy detection error: %v\n", err)
|
|
}
|
|
|
|
// Create cookie jar for session persistence
|
|
jar, err := cookiejar.New(&cookiejar.Options{
|
|
PublicSuffixList: publicsuffix.List,
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to create cookie jar: %v\n", err)
|
|
jar = nil
|
|
}
|
|
|
|
// Wrap client with adapter - use standard http.Client with timeout for direct external requests
|
|
adaptedClient := &sourceHTTPClientAdapter{
|
|
client: client,
|
|
httpClient: &http.Client{
|
|
Timeout: 60 * time.Second,
|
|
Transport: transport,
|
|
Jar: jar,
|
|
},
|
|
}
|
|
|
|
return &SkillInstallCommand{
|
|
client: client,
|
|
fileProvider: fileProvider,
|
|
skillProvider: skillProvider,
|
|
scanner: security.NewScanner(),
|
|
guard: security.NewGuard(),
|
|
sourceResolver: source.NewSourceResolver(adaptedClient),
|
|
}
|
|
}
|
|
|
|
// Execute runs the install-skill command
|
|
func (c *SkillInstallCommand) Execute(args []string) error {
|
|
parsedArgs, err := c.parseArgs(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if parsedArgs.ShowHelp {
|
|
c.PrintHelp()
|
|
return nil
|
|
}
|
|
|
|
ctx := stdctx.Background()
|
|
|
|
// 1. Resolve source
|
|
fmt.Printf("Resolving source reference: %s\n", parsedArgs.SourceRef)
|
|
src, identifier, err := c.sourceResolver.Resolve(parsedArgs.SourceRef)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid source reference: %w", err)
|
|
}
|
|
|
|
// 2. Fetch skill bundle
|
|
// If version specified, append to identifier for sources that support it
|
|
fetchIdentifier := identifier
|
|
if parsedArgs.Version != "" {
|
|
fetchIdentifier = fmt.Sprintf("%s@%s", identifier, parsedArgs.Version)
|
|
fmt.Printf("Fetching skill from %s (version %s)...\n", src.SourceID(), parsedArgs.Version)
|
|
} else {
|
|
fmt.Printf("Fetching skill from %s...\n", src.SourceID())
|
|
}
|
|
bundle, err := src.Fetch(fetchIdentifier)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch skill: %w", err)
|
|
}
|
|
fmt.Printf("Found skill '%s' (v%s) with %d files\n",
|
|
bundle.Name, bundle.Metadata.Version, len(bundle.Files))
|
|
|
|
// Override skill name if specified
|
|
if parsedArgs.SkillName != "" {
|
|
bundle.Name = parsedArgs.SkillName
|
|
}
|
|
|
|
// 3. Check if skill already exists
|
|
exists, err := c.skillExists(ctx, parsedArgs.SpaceID, bundle.Name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check existing skill: %w", err)
|
|
}
|
|
|
|
if exists && !parsedArgs.Force {
|
|
return fmt.Errorf("skill '%s' already exists in space '%s'. Use --force to reinstall", bundle.Name, parsedArgs.SpaceID)
|
|
}
|
|
|
|
// 4. Security scan (unless skipped)
|
|
if !parsedArgs.SkipVerify {
|
|
fmt.Println("Running security scan...")
|
|
trustLevel := src.TrustLevel(identifier)
|
|
scanResult := c.scanner.ScanSkill(bundle.Name, src.SourceID(), trustLevel, bundle.Files)
|
|
|
|
allowed, reason := c.guard.ShouldAllowInstall(scanResult, parsedArgs.Force)
|
|
if !allowed {
|
|
fmt.Println(c.guard.FormatScanReport(scanResult))
|
|
return fmt.Errorf("installation blocked: %s", reason)
|
|
}
|
|
|
|
fmt.Println(c.guard.FormatScanReport(scanResult))
|
|
fmt.Printf("✓ Security check passed: %s\n\n", reason)
|
|
}
|
|
|
|
// 5. Force mode: delete existing skill first
|
|
if parsedArgs.Force && exists {
|
|
fmt.Printf("Force mode: removing existing skill '%s'...\n", bundle.Name)
|
|
if err := c.uninstallSkill(ctx, parsedArgs.SpaceID, bundle.Name); err != nil {
|
|
return fmt.Errorf("failed to remove existing skill: %w", err)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// 6. Install skill
|
|
fmt.Printf("Installing skill '%s' to space '%s'...\n", bundle.Name, parsedArgs.SpaceID)
|
|
if err := c.installSkill(ctx, parsedArgs.SpaceID, bundle, parsedArgs.Force); err != nil {
|
|
return fmt.Errorf("failed to install skill: %w", err)
|
|
}
|
|
|
|
// 7. Update index
|
|
fmt.Printf("Updating search index for skill '%s'...\n", bundle.Name)
|
|
if err := c.updateIndex(ctx, parsedArgs.SpaceID, bundle.Name); err != nil {
|
|
fmt.Printf("⚠ Warning: failed to update index: %v\n", err)
|
|
}
|
|
|
|
fmt.Printf("✓ Successfully installed skill '%s' (version: %s)\n", bundle.Name, bundle.Metadata.Version)
|
|
return nil
|
|
}
|
|
|
|
// uninstallSkill removes an existing skill (for --force mode)
|
|
func (c *SkillInstallCommand) uninstallSkill(ctx stdctx.Context, spaceID, skillName string) error {
|
|
var indexErr, folderErr error
|
|
|
|
// Delete index
|
|
if skillProv, ok := c.skillProvider.(*SkillProvider); ok {
|
|
if err := skillProv.DeleteSkill(ctx, spaceID, skillName); err != nil {
|
|
indexErr = fmt.Errorf("failed to delete search index: %w", err)
|
|
fmt.Printf("⚠ Warning: %v\n", indexErr)
|
|
} else {
|
|
fmt.Printf("✓ Search index deleted\n")
|
|
}
|
|
}
|
|
|
|
// Delete folder
|
|
if c.fileProvider != nil {
|
|
folderPath := fmt.Sprintf("skills/%s/%s", spaceID, skillName)
|
|
if err := c.fileProvider.DeleteFolderByPath(ctx, folderPath); err != nil {
|
|
folderErr = fmt.Errorf("failed to delete skill folder: %w", err)
|
|
fmt.Printf("⚠ Warning: %v\n", folderErr)
|
|
} else {
|
|
fmt.Printf("✓ Skill folder deleted\n")
|
|
}
|
|
}
|
|
|
|
// Return error if both failed
|
|
if indexErr != nil && folderErr != nil {
|
|
return fmt.Errorf("failed to uninstall: index (%v), folder (%v)", indexErr, folderErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// installSkill installs a skill bundle using existing SkillUploader
|
|
func (c *SkillInstallCommand) installSkill(ctx stdctx.Context, spaceID string, bundle *source.SkillBundle, force bool) error {
|
|
// Create a temporary directory to hold the skill files
|
|
tempDir, err := os.MkdirTemp("", "skill-install-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Write files to temp directory
|
|
skillDir := filepath.Join(tempDir, bundle.Name)
|
|
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create skill directory: %w", err)
|
|
}
|
|
|
|
for relPath, content := range bundle.Files {
|
|
filePath := filepath.Join(skillDir, relPath)
|
|
dir := filepath.Dir(filePath)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
|
}
|
|
if err := os.WriteFile(filePath, content, 0644); err != nil {
|
|
return fmt.Errorf("failed to write file %s: %w", relPath, err)
|
|
}
|
|
}
|
|
|
|
// Use existing SkillUploader to upload the skill
|
|
uploader := NewSkillUploader(c.client, c.fileProvider)
|
|
uploader.SetSkillProvider(c.skillProvider)
|
|
uploader.SetForce(force)
|
|
|
|
version := bundle.Metadata.Version
|
|
if version == "" {
|
|
version = "1.0.0"
|
|
}
|
|
|
|
return uploader.UploadSkill(ctx, skillDir, version, fmt.Sprintf("skills/%s", spaceID), bundle.Name)
|
|
}
|
|
|
|
// skillExists checks if a skill already exists
|
|
func (c *SkillInstallCommand) skillExists(ctx stdctx.Context, spaceID, skillName string) (bool, error) {
|
|
folderPath := fmt.Sprintf("skills/%s/%s", spaceID, skillName)
|
|
_, err := c.fileProvider.List(ctx, folderPath, nil)
|
|
if err != nil {
|
|
// If error, likely doesn't exist
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// updateIndex updates the search index for a skill
|
|
// Note: Indexing is now handled by SkillUploader during upload
|
|
func (c *SkillInstallCommand) updateIndex(ctx stdctx.Context, spaceID, skillName string) error {
|
|
// Indexing is automatically performed by SkillUploader.UploadSkill
|
|
// This method is kept for potential future use
|
|
return nil
|
|
}
|
|
|
|
// parseArgs parses command arguments
|
|
func (c *SkillInstallCommand) parseArgs(args []string) (*InstallSkillArgs, error) {
|
|
result := &InstallSkillArgs{}
|
|
|
|
var nonFlagArgs []string
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
|
|
switch arg {
|
|
case "-h", "--help":
|
|
result.ShowHelp = true
|
|
return result, nil
|
|
case "-v", "--version":
|
|
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
|
result.Version = args[i+1]
|
|
i++
|
|
} else {
|
|
return nil, fmt.Errorf("version flag requires a value")
|
|
}
|
|
case "-n", "--name":
|
|
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
|
result.SkillName = args[i+1]
|
|
i++
|
|
} else {
|
|
return nil, fmt.Errorf("name flag requires a value")
|
|
}
|
|
case "-f", "--force":
|
|
result.Force = true
|
|
case "--skip-verify":
|
|
result.SkipVerify = true
|
|
default:
|
|
if !strings.HasPrefix(arg, "-") {
|
|
nonFlagArgs = append(nonFlagArgs, arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse space and source ref
|
|
if len(nonFlagArgs) < 1 {
|
|
return nil, fmt.Errorf("space ID is required")
|
|
}
|
|
if len(nonFlagArgs) < 2 {
|
|
return nil, fmt.Errorf("source reference is required (local path or remote identifier)")
|
|
}
|
|
|
|
result.SpaceID = nonFlagArgs[0]
|
|
result.SourceRef = nonFlagArgs[1]
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// PrintHelp prints the help message
|
|
func (c *SkillInstallCommand) PrintHelp() {
|
|
fmt.Println(`Usage: install-skill <space> <source> [options]
|
|
|
|
Install a skill from multiple sources into a RAGFlow space.
|
|
|
|
Arguments:
|
|
<space> Target skills space ID (required)
|
|
<source> Skill source reference (required):
|
|
- Local: ./path/to/skill or /absolute/path
|
|
- GitHub: github.com/owner/repo/path/to/skill
|
|
- ClawHub: clawhub://owner/skill-name or clawhub.ai/owner/skill-name
|
|
- skills.sh: skill://skill-name or skills.sh/skill/name
|
|
|
|
Options:
|
|
-v, --version string Specify skill version (default: from SKILL.md or 1.0.0)
|
|
-n, --name string Override skill name (default: from SKILL.md)
|
|
-f, --force Force reinstall if skill exists (deletes existing first)
|
|
--skip-verify Skip security verification (use with caution)
|
|
-h, --help Show this help message
|
|
|
|
Security:
|
|
By default, all skills are scanned for potential security threats before
|
|
installation. The scan checks for:
|
|
- Data exfiltration patterns (curl $SECRET, .ssh access, etc.)
|
|
- Prompt injection attempts (DAN mode, ignore instructions, etc.)
|
|
- Destructive commands (rm -rf /, mkfs, etc.)
|
|
- Persistence mechanisms (cron, .bashrc, authorized_keys, etc.)
|
|
- Network threats (reverse shells, tunneling, etc.)
|
|
- Obfuscation (base64 | bash, eval(), etc.)
|
|
|
|
Trust levels:
|
|
- builtin: Official RAGFlow skills (always allowed)
|
|
- trusted: openai/skills, anthropics/skills (caution allowed)
|
|
- community: All other sources (findings blocked unless --force)
|
|
|
|
Examples:
|
|
# Install from local path
|
|
install-skill my-space ./my-local-skill
|
|
|
|
# Install from GitHub
|
|
install-skill my-space github.com/openai/skills/skill-creator
|
|
|
|
# Force reinstall (delete existing and reinstall)
|
|
install-skill my-space ./my-skill --force
|
|
|
|
# Force install with custom name, skip security check
|
|
install-skill my-space claw://unknown-skill --force --name my-skill --skip-verify
|
|
|
|
# Install specific version
|
|
install-skill my-space skill://kubernetes --version 2.1.0
|
|
|
|
Note: 'add-skill' command is deprecated. Use 'install-skill' instead.`)
|
|
}
|
|
|
|
// getDir extracts directory from file path
|
|
func getDir(path string) string {
|
|
idx := strings.LastIndex(path, "/")
|
|
if idx == -1 {
|
|
return ""
|
|
}
|
|
return path[:idx]
|
|
}
|