/ common / websocket / knowledge2_api.go
knowledge2_api.go
   1  // Copyright (c) 2024-2026 Tencent Zhuque Lab. All rights reserved.
   2  //
   3  // Licensed under the Apache License, Version 2.0 (the "License");
   4  // you may not use this file except in compliance with the License.
   5  // You may obtain a copy of the License at
   6  //
   7  //     http://www.apache.org/licenses/LICENSE-2.0
   8  //
   9  // Unless required by applicable law or agreed to in writing, software
  10  // distributed under the License is distributed on an "AS IS" BASIS,
  11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12  // See the License for the specific language governing permissions and
  13  // limitations under the License.
  14  //
  15  // Requirement: Any integration or derivative work must explicitly attribute
  16  // Tencent Zhuque Lab (https://github.com/Tencent/AI-Infra-Guard) in its
  17  // documentation or user interface, as detailed in the NOTICE file.
  18  
  19  package websocket
  20  
  21  import (
  22  	"encoding/json"
  23  	"errors"
  24  	"fmt"
  25  	"net/http"
  26  	"os"
  27  	"path/filepath"
  28  	"sort"
  29  	"strings"
  30  
  31  	"github.com/Tencent/AI-Infra-Guard/common/utils"
  32  	"github.com/Tencent/AI-Infra-Guard/internal/gologger"
  33  	"github.com/Tencent/AI-Infra-Guard/internal/mcp"
  34  	"github.com/gin-gonic/gin"
  35  	"gopkg.in/yaml.v3"
  36  )
  37  
  38  func HandleList(root string, loadFile func(filePath string) (interface{}, error)) gin.HandlerFunc {
  39  	return func(c *gin.Context) {
  40  		var allItems []interface{}
  41  		err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
  42  			if err != nil {
  43  				return nil // skip entry on error
  44  			}
  45  			if !d.IsDir() {
  46  				item, err := loadFile(path)
  47  				if err != nil {
  48  					return err
  49  				}
  50  				allItems = append(allItems, item)
  51  			}
  52  			return nil
  53  		})
  54  		if err != nil {
  55  			c.JSON(http.StatusInternalServerError, gin.H{
  56  				"status":  1,
  57  				"message": err.Error(),
  58  			})
  59  			return
  60  		}
  61  		c.JSON(http.StatusOK, gin.H{
  62  			"status":  0,
  63  			"message": "success",
  64  			"data": gin.H{
  65  				"total": len(allItems),
  66  				"items": allItems,
  67  			},
  68  		})
  69  	}
  70  }
  71  func HandleCreate(readAndSave func(content string) error) gin.HandlerFunc {
  72  	return func(c *gin.Context) {
  73  		var request struct {
  74  			Content string `json:"content" binding:"required"`
  75  		}
  76  		if err := c.ShouldBindJSON(&request); err != nil {
  77  			c.JSON(http.StatusBadRequest, gin.H{"status": 1, "message": "content parameter is required"})
  78  			return
  79  		}
  80  		if err := readAndSave(request.Content); err != nil {
  81  			c.JSON(http.StatusInternalServerError, gin.H{"status": 1, "message": "failed to save config: " + err.Error()})
  82  			return
  83  		}
  84  		c.JSON(http.StatusOK, gin.H{"status": 0, "message": "created"})
  85  	}
  86  }
  87  
  88  // HandleEdit returns a HandlerFunc for edit requests
  89  func HandleEdit(updateFunc func(id string, content string) error) gin.HandlerFunc {
  90  	return func(c *gin.Context) {
  91  		name := c.Param("id")
  92  		if name == "" {
  93  			c.JSON(http.StatusBadRequest, gin.H{"status": 1, "message": "name must not be empty"})
  94  			return
  95  		}
  96  
  97  		var request struct {
  98  			Content string `json:"content" binding:"required"`
  99  		}
 100  		if err := c.ShouldBindJSON(&request); err != nil {
 101  			c.JSON(http.StatusBadRequest, gin.H{"status": 1, "message": "content parameter is required"})
 102  			return
 103  		}
 104  
 105  		if err := updateFunc(c.Param("id"), request.Content); err != nil {
 106  			c.JSON(http.StatusInternalServerError, gin.H{"status": 1, "message": "update failed: " + err.Error()})
 107  			return
 108  		}
 109  
 110  		c.JSON(http.StatusOK, gin.H{"status": 0, "message": "updated"})
 111  	}
 112  }
 113  
 114  // HandleDelete returns a HandlerFunc for delete requests
 115  func HandleDelete(deleteFunc func(id string) error) gin.HandlerFunc {
 116  	return func(c *gin.Context) {
 117  		name := c.Param("id")
 118  		if name == "" {
 119  			c.JSON(http.StatusBadRequest, gin.H{"status": 1, "message": "name must not be empty"})
 120  			return
 121  		}
 122  
 123  		if err := deleteFunc(name); err != nil {
 124  			c.JSON(http.StatusInternalServerError, gin.H{"status": 1, "message": "delete failed: " + err.Error()})
 125  			return
 126  		}
 127  
 128  		c.JSON(http.StatusOK, gin.H{"status": 0, "message": "deleted"})
 129  	}
 130  }
 131  
 132  // MCP prompt management
 133  const MCPROOT = "data/mcp"
 134  
 135  func McpLoadFile(filePath string) (interface{}, error) {
 136  	if filePath == "" {
 137  		return nil, nil
 138  	}
 139  	if !strings.HasSuffix(filePath, ".yaml") {
 140  		return nil, nil
 141  	}
 142  	var ret struct {
 143  		mcp.PluginConfig `yaml:",inline"`
 144  		RawData          string `yaml:"raw_data"`
 145  	}
 146  	data, err := os.ReadFile(filePath)
 147  	if err != nil {
 148  		return nil, err
 149  	}
 150  
 151  	var config mcp.PluginConfig
 152  	err = yaml.Unmarshal(data, &config)
 153  	if err != nil {
 154  		return nil, err
 155  	}
 156  	ret.RawData = string(data)
 157  	ret.PluginConfig = config
 158  	return ret, nil
 159  }
 160  
 161  func mcpReadAndSave(content string) error {
 162  	// Ensure directory exists
 163  	if err := os.MkdirAll(MCPROOT, 0755); err != nil {
 164  		return fmt.Errorf("failed to create directory: %w", err)
 165  	}
 166  
 167  	// Parse YAML to validate format
 168  	var config mcp.PluginConfig
 169  	err := yaml.Unmarshal([]byte(content), &config)
 170  	if err != nil {
 171  		return fmt.Errorf("failed to parse YAML: %w", err)
 172  	}
 173  
 174  	// Extract ID
 175  	id := config.Info.ID
 176  	if id == "" {
 177  		return errors.New("missing info.id field")
 178  	}
 179  
 180  	// Safety check
 181  	if strings.Contains(id, "..") || strings.ContainsAny(id, "/\\<>:\"|?*") {
 182  		return errors.New("invalid filename")
 183  	}
 184  
 185  	filename, err := safeJoinPath(MCPROOT, id+".yaml")
 186  	if err != nil {
 187  		return err
 188  	}
 189  	return os.WriteFile(filename, []byte(content), 0644)
 190  }
 191  
 192  func mcpUpdateFunc(id string, content string) error {
 193  	// Parse YAML to validate content format
 194  	var config mcp.PluginConfig
 195  	if err := yaml.Unmarshal([]byte(content), &config); err != nil {
 196  		return fmt.Errorf("failed to parse YAML: %w", err)
 197  	}
 198  
 199  	// Validate filename safety
 200  	if strings.Contains(id, "..") || strings.ContainsAny(id, "/\\<>:\"|?*") {
 201  		return errors.New("invalid filename")
 202  	}
 203  
 204  	// Use provided name as filename; allow file update without forcing a rename
 205  	filePath, err := safeJoinPath(MCPROOT, id+".yaml")
 206  	if err != nil {
 207  		return err
 208  	}
 209  	return os.WriteFile(filePath, []byte(content), 0644)
 210  }
 211  
 212  func mcpDeleteFunc(id string) error {
 213  	// Validate filename safety
 214  	if strings.Contains(id, "..") || strings.ContainsAny(id, "/\\<>:\"|?*") {
 215  		return errors.New("invalid filename")
 216  	}
 217  
 218  	filePath, pathErr := safeJoinPath(MCPROOT, id+".yaml")
 219  	if pathErr != nil {
 220  		return pathErr
 221  	}
 222  	// Check if file exists
 223  	if _, err := os.Stat(filePath); os.IsNotExist(err) {
 224  		return errors.New("file not found")
 225  	}
 226  	return os.Remove(filePath)
 227  }
 228  
 229  // AI application inspector management
 230  const PromptCollectionsRoot = "data/prompt_collections"
 231  
 232  type PromptCollection struct {
 233  	CodeExec     bool   `json:"code_exec"`
 234  	UploadFile   bool   `json:"upload_file"`
 235  	Product      string `json:"product"`
 236  	MultiModal   bool   `json:"multi_modal"`
 237  	ModelVersion string `json:"model_version"`
 238  	Prompt       string `json:"prompt"`
 239  	UpdateDate   string `json:"update_date"`
 240  	WebSearch    bool   `json:"web_search"`
 241  	SecPolicies  bool   `json:"sec_policies"`
 242  	Affiliation  string `json:"affiliation"`
 243  	Id           string `json:"id"`
 244  }
 245  
 246  func promptCollectionLoadFile(filePath string) (interface{}, error) {
 247  	if filePath == "" {
 248  		return nil, nil
 249  	}
 250  	if !strings.HasSuffix(filePath, ".json") {
 251  		return nil, nil
 252  	}
 253  	data, err := os.ReadFile(filePath)
 254  	if err != nil {
 255  		return nil, err
 256  	}
 257  	var config PromptCollection
 258  	err = json.Unmarshal(data, &config)
 259  	if err != nil {
 260  		return nil, err
 261  	}
 262  	base := filepath.Base(filePath)
 263  	config.Id = strings.Split(base, ".")[0]
 264  	return config, nil
 265  }
 266  
 267  func promptCollectionReadAndSave(content string) error {
 268  	// Validate JSON format
 269  	var collection map[string]interface{}
 270  	err := json.Unmarshal([]byte(content), &collection)
 271  	if err != nil {
 272  		return fmt.Errorf("failed to parse JSON: %w", err)
 273  	}
 274  
 275  	// Use ID as filename
 276  	id, ok := collection["id"].(string)
 277  	if !ok || id == "" {
 278  		return errors.New("missing id field")
 279  	}
 280  
 281  	// Safety check
 282  	if strings.Contains(id, "..") || strings.ContainsAny(id, "/\\<>:\"|?*") {
 283  		return errors.New("invalid filename")
 284  	}
 285  
 286  	filename, err := safeJoinPath(PromptCollectionsRoot, id+".json")
 287  	if err != nil {
 288  		return err
 289  	}
 290  	return os.WriteFile(filename, []byte(content), 0644)
 291  }
 292  
 293  func promptCollectionUpdateFunc(id string, content string) error {
 294  	// Validate JSON format
 295  	var collection map[string]interface{}
 296  	err := json.Unmarshal([]byte(content), &collection)
 297  	if err != nil {
 298  		return fmt.Errorf("invalid JSON format: %w", err)
 299  	}
 300  
 301  	// Validate filename safety
 302  	if strings.Contains(id, "..") || strings.ContainsAny(id, "/\\<>:\"|?*") {
 303  		return errors.New("invalid filename")
 304  	}
 305  
 306  	filename, err := safeJoinPath(PromptCollectionsRoot, id+".json")
 307  	if err != nil {
 308  		return err
 309  	}
 310  	return os.WriteFile(filename, []byte(content), 0644)
 311  }
 312  
 313  func promptCollectionDeleteFunc(id string) error {
 314  	// Validate filename safety
 315  	if strings.Contains(id, "..") || strings.ContainsAny(id, "/\\<>:\"|?*") {
 316  		return errors.New("invalid filename")
 317  	}
 318  
 319  	filePath, err := safeJoinPath(PromptCollectionsRoot, id+".json")
 320  	if err != nil {
 321  		return err
 322  	}
 323  
 324  	// Check if file exists
 325  	if _, err := os.Stat(filePath); os.IsNotExist(err) {
 326  		return errors.New("file not found")
 327  	}
 328  
 329  	return os.Remove(filePath)
 330  }
 331  func GetJailBreak(c *gin.Context) {
 332  	promptSecurityDir, err := utils.ResolvePromptSecurityDir()
 333  	if err != nil {
 334  		c.JSON(http.StatusOK, gin.H{
 335  			"status":  1,
 336  			"message": "Failed to resolve prompt security directory: " + err.Error(),
 337  		})
 338  		return
 339  	}
 340  	dataPath := filepath.Join(promptSecurityDir, "utils", "strategy_map.json")
 341  	data, err := os.ReadFile(dataPath)
 342  	if err != nil {
 343  		c.JSON(http.StatusOK, gin.H{
 344  			"status":  1,
 345  			"message": "Failed to read strategy map: " + err.Error(),
 346  		})
 347  		return
 348  	}
 349  	var data1 interface{}
 350  	err = json.Unmarshal(data, &data1)
 351  	if err != nil {
 352  		c.JSON(http.StatusOK, gin.H{
 353  			"status":  1,
 354  			"message": "Failed to parse strategy map: " + err.Error(),
 355  		})
 356  		return
 357  	}
 358  	c.JSON(http.StatusOK, gin.H{
 359  		"status":  0,
 360  		"message": "success",
 361  		"data":    data1,
 362  	})
 363  }
 364  
 365  // ============== Agent Scan Config Management ==============
 366  const AgentConfigRoot = "data/agents"
 367  const PublicUser = "public_user"
 368  
 369  // getAgentUserDir returns the agent config directory for a user.
 370  // username must have passed validateUsername; safeJoinPath provides an extra defence layer.
 371  func getAgentUserDir(username string) string {
 372  	p, err := safeJoinPath(AgentConfigRoot, username)
 373  	if err != nil {
 374  		// fallback to public user dir; validateUsername should have caught this
 375  		return filepath.Join(AgentConfigRoot, PublicUser)
 376  	}
 377  	return p
 378  }
 379  
 380  // validateUsername checks that a username is safe to use in file paths (prevents path traversal)
 381  func validateUsername(username string) bool {
 382  	if username == "" {
 383  		return false
 384  	}
 385  	if strings.Contains(username, "..") || strings.ContainsAny(username, "/\\<>:\"|?*") {
 386  		return false
 387  	}
 388  	return true
 389  }
 390  
 391  func HandleListAgentNames(c *gin.Context) {
 392  	username := c.GetString("username")
 393  	if !validateUsername(username) {
 394  		username = PublicUser
 395  	}
 396  
 397  	names, err := listAgentConfigNames(username)
 398  	if err != nil {
 399  		c.JSON(http.StatusInternalServerError, gin.H{
 400  			"status":  1,
 401  			"message": "failed to retrieve: " + err.Error(),
 402  		})
 403  		return
 404  	}
 405  	c.JSON(http.StatusOK, gin.H{
 406  		"status":  0,
 407  		"message": "success",
 408  		"data":    names,
 409  	})
 410  }
 411  
 412  func HandleGetAgentConfig(c *gin.Context) {
 413  	username := c.GetString("username")
 414  	if !validateUsername(username) {
 415  		username = PublicUser
 416  	}
 417  
 418  	name := strings.TrimSpace(c.Param("name"))
 419  	if name == "" || !isValidName(name) {
 420  		c.JSON(http.StatusBadRequest, gin.H{
 421  			"status":  1,
 422  			"message": "invalid config name",
 423  		})
 424  		return
 425  	}
 426  
 427  	data, err := readAgentConfigContent(username, name)
 428  	if err != nil {
 429  		if errors.Is(err, os.ErrNotExist) {
 430  			c.JSON(http.StatusNotFound, gin.H{
 431  				"status":  1,
 432  				"message": "config not found",
 433  			})
 434  			return
 435  		}
 436  		c.JSON(http.StatusInternalServerError, gin.H{
 437  			"status":  1,
 438  			"message": "failed to read config: " + err.Error(),
 439  		})
 440  		return
 441  	}
 442  
 443  	c.JSON(http.StatusOK, gin.H{
 444  		"status":  0,
 445  		"message": "success",
 446  		"data":    string(data),
 447  	})
 448  }
 449  
 450  // testAgentConnectivity verifies Agent configuration connectivity.
 451  // Returns (success, message, error).
 452  func testAgentConnectivity(content string) (bool, string, error) {
 453  	agentScanDir, err := utils.ResolveAgentScanDir()
 454  	if err != nil {
 455  		return false, "", fmt.Errorf("failed to resolve agent-scan directory: %v", err)
 456  	}
 457  	uvBin, err := utils.ResolveUvBin()
 458  	if err != nil {
 459  		return false, "", fmt.Errorf("failed to resolve uv binary: %v", err)
 460  	}
 461  	// Create temporary file for the YAML content
 462  	tmpFile, err := os.CreateTemp("", "agent_connect_*.yaml")
 463  	if err != nil {
 464  		return false, "", fmt.Errorf("failed to create temporary config file: %v", err)
 465  	}
 466  	defer os.Remove(tmpFile.Name())
 467  
 468  	// Write YAML content to temp file
 469  	if _, err := tmpFile.WriteString(content); err != nil {
 470  		tmpFile.Close()
 471  		return false, "", fmt.Errorf("failed to write config file: %v", err)
 472  	}
 473  	tmpFile.Close()
 474  
 475  	// Run Python connectivity test script using uv
 476  	var lastLine string
 477  	err = utils.RunCmd(
 478  		agentScanDir,
 479  		uvBin,
 480  		[]string{"run", "test_client_connect.py", "--client_file", tmpFile.Name()},
 481  		func(line string) {
 482  			lastLine += line
 483  		},
 484  	)
 485  
 486  	if err != nil {
 487  		return false, "", fmt.Errorf("connectivity test execution failed: %v", err)
 488  	}
 489  	if lastLine != "" {
 490  		gologger.Infoln("test_agent_connect", lastLine)
 491  	}
 492  
 493  	// Parse the JSON output from Python script
 494  	var result ConnectResultUpdate
 495  	if err := json.Unmarshal([]byte(lastLine), &result); err != nil {
 496  		return false, "", fmt.Errorf("failed to parse connectivity test result: %v", err)
 497  	}
 498  
 499  	return result.Content.Success, result.Content.Message, nil
 500  }
 501  
 502  func HandleSaveAgentConfig(c *gin.Context) {
 503  	username := c.GetString("username")
 504  	if !validateUsername(username) {
 505  		username = PublicUser
 506  	}
 507  
 508  	name := strings.TrimSpace(c.Param("name"))
 509  	if name == "" || !isValidName(name) {
 510  		c.JSON(http.StatusBadRequest, gin.H{
 511  			"status":  1,
 512  			"message": "invalid config name",
 513  		})
 514  		return
 515  	}
 516  
 517  	var req struct {
 518  		Content string `json:"content" binding:"required"`
 519  	}
 520  	if err := c.ShouldBindJSON(&req); err != nil {
 521  		c.JSON(http.StatusBadRequest, gin.H{
 522  			"status":  1,
 523  			"message": "content parameter is required",
 524  		})
 525  		return
 526  	}
 527  	content := strings.TrimSpace(req.Content)
 528  	if content == "" {
 529  		c.JSON(http.StatusBadRequest, gin.H{
 530  			"status":  1,
 531  			"message": "content must not be empty",
 532  		})
 533  		return
 534  	}
 535  
 536  	// Validate Agent connectivity before saving the config (skip when ?verify=false).
 537  	skipVerify := strings.ToLower(strings.TrimSpace(c.Query("verify"))) == "false"
 538  	if !skipVerify {
 539  		success, message, err := testAgentConnectivity(content)
 540  		if err != nil {
 541  			c.JSON(http.StatusInternalServerError, gin.H{
 542  				"status":  1,
 543  				"message": "connectivity check failed: " + err.Error(),
 544  			})
 545  			return
 546  		}
 547  		if !success {
 548  			c.JSON(http.StatusOK, gin.H{
 549  				"status":  1,
 550  				"message": "connectivity check failed: " + message,
 551  			})
 552  			return
 553  		}
 554  	}
 555  
 556  	// Create user-specific directory
 557  	userDir := getAgentUserDir(username)
 558  	if err := os.MkdirAll(userDir, 0755); err != nil {
 559  		c.JSON(http.StatusInternalServerError, gin.H{
 560  			"status":  1,
 561  			"message": "failed to create directory: " + err.Error(),
 562  		})
 563  		return
 564  	}
 565  
 566  	targetPath, err := resolveAgentConfigPathForWrite(username, name)
 567  	if err != nil {
 568  		c.JSON(http.StatusInternalServerError, gin.H{
 569  			"status":  1,
 570  			"message": "failed to save config: " + err.Error(),
 571  		})
 572  		return
 573  	}
 574  
 575  	if err := os.WriteFile(targetPath, []byte(content), 0644); err != nil {
 576  		c.JSON(http.StatusInternalServerError, gin.H{
 577  			"status":  1,
 578  			"message": "failed to save config: " + err.Error(),
 579  		})
 580  		return
 581  	}
 582  
 583  	c.JSON(http.StatusOK, gin.H{
 584  		"status":  0,
 585  		"message": "Saved successfully and connectivity check passed",
 586  	})
 587  }
 588  
 589  func HandleDeleteAgentConfig(c *gin.Context) {
 590  	username := c.GetString("username")
 591  	if !validateUsername(username) {
 592  		username = PublicUser
 593  	}
 594  
 595  	name := strings.TrimSpace(c.Param("name"))
 596  	if name == "" || !isValidName(name) {
 597  		c.JSON(http.StatusBadRequest, gin.H{
 598  			"status":  1,
 599  			"message": "invalid config name",
 600  		})
 601  		return
 602  	}
 603  
 604  	deleted, err := deleteAgentConfig(username, name)
 605  	if err != nil {
 606  		c.JSON(http.StatusInternalServerError, gin.H{
 607  			"status":  1,
 608  			"message": "delete failed: " + err.Error(),
 609  		})
 610  		return
 611  	}
 612  	if !deleted {
 613  		c.JSON(http.StatusNotFound, gin.H{
 614  			"status":  1,
 615  			"message": "config not found",
 616  		})
 617  		return
 618  	}
 619  
 620  	c.JSON(http.StatusOK, gin.H{
 621  		"status":  0,
 622  		"message": "deleted",
 623  	})
 624  }
 625  
 626  // listAgentConfigNamesFromDir reads config names from a given directory
 627  func listAgentConfigNamesFromDir(dir string) ([]string, error) {
 628  	entries, err := os.ReadDir(dir)
 629  	if err != nil {
 630  		if errors.Is(err, os.ErrNotExist) {
 631  			return []string{}, nil
 632  		}
 633  		return nil, err
 634  	}
 635  
 636  	var names []string
 637  	for _, entry := range entries {
 638  		if entry.IsDir() {
 639  			continue
 640  		}
 641  		switch {
 642  		case strings.HasSuffix(entry.Name(), ".yaml"):
 643  			names = append(names, strings.TrimSuffix(entry.Name(), ".yaml"))
 644  		case strings.HasSuffix(entry.Name(), ".yml"):
 645  			names = append(names, strings.TrimSuffix(entry.Name(), ".yml"))
 646  		}
 647  	}
 648  	return names, nil
 649  }
 650  
 651  // listAgentConfigNames lists config names for a user, merging user-dir and public-dir entries with deduplication
 652  func listAgentConfigNames(username string) ([]string, error) {
 653  	// Read configs from user directory
 654  	userDir := getAgentUserDir(username)
 655  	userNames, err := listAgentConfigNamesFromDir(userDir)
 656  	if err != nil {
 657  		return nil, err
 658  	}
 659  
 660  	// If not the public user, also merge configs from the public directory
 661  	if username != PublicUser {
 662  		publicDir := getAgentUserDir(PublicUser)
 663  		publicNames, err := listAgentConfigNamesFromDir(publicDir)
 664  		if err != nil {
 665  			return nil, err
 666  		}
 667  
 668  		// Merge and deduplicate
 669  		nameSet := make(map[string]struct{})
 670  		for _, name := range userNames {
 671  			nameSet[name] = struct{}{}
 672  		}
 673  		for _, name := range publicNames {
 674  			nameSet[name] = struct{}{}
 675  		}
 676  
 677  		userNames = make([]string, 0, len(nameSet))
 678  		for name := range nameSet {
 679  			userNames = append(userNames, name)
 680  		}
 681  	}
 682  
 683  	sort.Strings(userNames)
 684  	return userNames, nil
 685  }
 686  
 687  // readAgentConfigContentFromDir reads config file content from a directory
 688  func readAgentConfigContentFromDir(dir, name string) ([]byte, error) {
 689  	name = strings.TrimSpace(name)
 690  	if name == "" || !isValidName(name) {
 691  		return nil, os.ErrNotExist
 692  	}
 693  
 694  	cleanDir := filepath.Clean(dir)
 695  	rootDir := filepath.Clean(AgentConfigRoot)
 696  	relDir, err := filepath.Rel(rootDir, cleanDir)
 697  	if err != nil || relDir == ".." || strings.HasPrefix(relDir, ".."+string(filepath.Separator)) {
 698  		return nil, os.ErrNotExist
 699  	}
 700  
 701  	for _, ext := range []string{".yaml", ".yml"} {
 702  		path := filepath.Join(cleanDir, name+ext)
 703  		data, err := os.ReadFile(path)
 704  		if err == nil {
 705  			return data, nil
 706  		}
 707  		if !errors.Is(err, os.ErrNotExist) {
 708  			return nil, err
 709  		}
 710  	}
 711  	return nil, os.ErrNotExist
 712  }
 713  
 714  // readAgentConfigContent reads config content, preferring the user directory with fallback to the public directory
 715  func readAgentConfigContent(username, name string) ([]byte, error) {
 716  	// Ensure the username is safe for path construction; fall back to public user directory otherwise
 717  	safeUsername := username
 718  	if !validateUsername(safeUsername) {
 719  		safeUsername = PublicUser
 720  	}
 721  	name = strings.TrimSpace(name)
 722  	if name == "" || !isValidName(name) {
 723  		return nil, os.ErrNotExist
 724  	}
 725  
 726  	// Prefer user directory
 727  	userDir := getAgentUserDir(safeUsername)
 728  	data, err := readAgentConfigContentFromDir(userDir, name)
 729  	if err == nil {
 730  		return data, nil
 731  	}
 732  	if !errors.Is(err, os.ErrNotExist) {
 733  		return nil, err
 734  	}
 735  
 736  	// Fall back to public directory when user directory has no match
 737  	if safeUsername != PublicUser {
 738  		publicDir := getAgentUserDir(PublicUser)
 739  		return readAgentConfigContentFromDir(publicDir, name)
 740  	}
 741  
 742  	return nil, os.ErrNotExist
 743  }
 744  
 745  // resolveAgentConfigPathForWrite resolves the write path (always writes to user directory)
 746  func resolveAgentConfigPathForWrite(username, name string) (string, error) {
 747  	userDir := getAgentUserDir(username)
 748  	p1, err1 := safeJoinPath(userDir, name+".yaml")
 749  	p2, err2 := safeJoinPath(userDir, name+".yml")
 750  	if err1 != nil && err2 != nil {
 751  		return "", err1
 752  	}
 753  	candidates := []string{}
 754  	if err1 == nil {
 755  		candidates = append(candidates, p1)
 756  	}
 757  	if err2 == nil {
 758  		candidates = append(candidates, p2)
 759  	}
 760  	for _, path := range candidates {
 761  		_, statErr := os.Stat(path)
 762  		if statErr == nil {
 763  			return path, nil
 764  		}
 765  		if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
 766  			return "", statErr
 767  		}
 768  	}
 769  	return p1, nil
 770  }
 771  
 772  // deleteAgentConfig deletes a config entry (only from the user directory)
 773  func deleteAgentConfig(username, name string) (bool, error) {
 774  	userDir := getAgentUserDir(username)
 775  	for _, ext := range []string{".yaml", ".yml"} {
 776  		path, pathErr := safeJoinPath(userDir, name+ext)
 777  		if pathErr != nil {
 778  			continue
 779  		}
 780  		err := os.Remove(path)
 781  		if err == nil {
 782  			return true, nil
 783  		}
 784  		if errors.Is(err, os.ErrNotExist) {
 785  			continue
 786  		}
 787  		return false, err
 788  	}
 789  	return false, nil
 790  }
 791  
 792  // AgentConnectRequest represents the request body for agent connect test
 793  type AgentConnectRequest struct {
 794  	Content string `json:"content"`
 795  }
 796  
 797  // AgentPromptTestRequest represents the request body for agent prompt test
 798  type AgentPromptTestRequest struct {
 799  	Content string `json:"content"`
 800  	Prompt  string `json:"prompt"`
 801  }
 802  
 803  // ProviderResponse represents the provider_response field in result
 804  type ProviderResponse struct {
 805  	Raw    interface{} `json:"raw"`
 806  	Output *string     `json:"output"`
 807  	Error  *string     `json:"error"`
 808  }
 809  
 810  // ConnectResultContent represents the content of resultUpdate response
 811  type ConnectResultContent struct {
 812  	Success          bool              `json:"success"`
 813  	Message          string            `json:"message"`
 814  	ProviderResponse *ProviderResponse `json:"provider_response"`
 815  }
 816  
 817  // ConnectResultUpdate represents the resultUpdate response from Python script
 818  type ConnectResultUpdate struct {
 819  	Type    string               `json:"type"`
 820  	Content ConnectResultContent `json:"content"`
 821  }
 822  
 823  func HandleAgentConnect(c *gin.Context) {
 824  	var req AgentConnectRequest
 825  	if err := c.ShouldBindJSON(&req); err != nil {
 826  		c.JSON(http.StatusBadRequest, gin.H{
 827  			"status":  1,
 828  			"message": "Invalid request body: " + err.Error(),
 829  		})
 830  		return
 831  	}
 832  
 833  	if req.Content == "" {
 834  		c.JSON(http.StatusBadRequest, gin.H{
 835  			"status":  1,
 836  			"message": "Content cannot be empty",
 837  		})
 838  		return
 839  	}
 840  
 841  	// Delegate to shared connectivity test helper
 842  	success, message, err := testAgentConnectivity(req.Content)
 843  	if err != nil {
 844  		c.JSON(http.StatusInternalServerError, gin.H{
 845  			"status":  1,
 846  			"message": "Failed to run connectivity test: " + err.Error(),
 847  		})
 848  		return
 849  	}
 850  
 851  	if success {
 852  		c.JSON(http.StatusOK, gin.H{
 853  			"status":  0,
 854  			"message": message,
 855  		})
 856  	} else {
 857  		c.JSON(http.StatusOK, gin.H{
 858  			"status":  1,
 859  			"message": message,
 860  		})
 861  	}
 862  }
 863  
 864  func HandleAgentPromptTest(c *gin.Context) {
 865  	var req AgentPromptTestRequest
 866  	if err := c.ShouldBindJSON(&req); err != nil {
 867  		c.JSON(http.StatusBadRequest, gin.H{
 868  			"status":  1,
 869  			"message": "Invalid request body: " + err.Error(),
 870  		})
 871  		return
 872  	}
 873  
 874  	if req.Content == "" {
 875  		c.JSON(http.StatusBadRequest, gin.H{
 876  			"status":  1,
 877  			"message": "Content cannot be empty",
 878  		})
 879  		return
 880  	}
 881  
 882  	if req.Prompt == "" {
 883  		c.JSON(http.StatusBadRequest, gin.H{
 884  			"status":  1,
 885  			"message": "Prompt cannot be empty",
 886  		})
 887  		return
 888  	}
 889  
 890  	// Create temporary file for the YAML content
 891  	tmpFile, err := os.CreateTemp("", "agent_prompt_test_*.yaml")
 892  	if err != nil {
 893  		c.JSON(http.StatusInternalServerError, gin.H{
 894  			"status":  1,
 895  			"message": "Failed to create temporary file: " + err.Error(),
 896  		})
 897  		return
 898  	}
 899  	defer os.Remove(tmpFile.Name())
 900  
 901  	// Write YAML content to temp file
 902  	if _, err := tmpFile.WriteString(req.Content); err != nil {
 903  		tmpFile.Close()
 904  		c.JSON(http.StatusInternalServerError, gin.H{
 905  			"status":  1,
 906  			"message": "Failed to write config file: " + err.Error(),
 907  		})
 908  		return
 909  	}
 910  	tmpFile.Close()
 911  
 912  	// Run Python prompt test script using uv
 913  	agentScanDir, err := utils.ResolveAgentScanDir()
 914  	if err != nil {
 915  		c.JSON(http.StatusInternalServerError, gin.H{
 916  			"status":  1,
 917  			"message": "Failed to resolve agent-scan directory: " + err.Error(),
 918  		})
 919  		return
 920  	}
 921  	uvBin, err := utils.ResolveUvBin()
 922  	if err != nil {
 923  		c.JSON(http.StatusInternalServerError, gin.H{
 924  			"status":  1,
 925  			"message": "Failed to resolve uv binary: " + err.Error(),
 926  		})
 927  		return
 928  	}
 929  	var lastLine string
 930  	err = utils.RunCmd(
 931  		agentScanDir,
 932  		uvBin,
 933  		[]string{"run", "test_client_connect.py", "--client_file", tmpFile.Name(), "--prompt", req.Prompt},
 934  		func(line string) {
 935  			lastLine += line
 936  		},
 937  	)
 938  
 939  	if err != nil {
 940  		c.JSON(http.StatusInternalServerError, gin.H{
 941  			"status":  1,
 942  			"message": "Failed to run prompt test: " + err.Error(),
 943  		})
 944  		return
 945  	}
 946  	gologger.Infof("prompt test result: %s", lastLine)
 947  
 948  	// Parse the JSON output from Python script
 949  	var result ConnectResultUpdate
 950  	if err := json.Unmarshal([]byte(lastLine), &result); err != nil {
 951  		c.JSON(http.StatusInternalServerError, gin.H{
 952  			"status":  1,
 953  			"message": "Failed to parse result: " + err.Error(),
 954  		})
 955  		return
 956  	}
 957  
 958  	// Return result based on prompt test outcome
 959  	if result.Content.Success {
 960  		// Extract output from provider_response
 961  		var output string
 962  		if result.Content.ProviderResponse != nil {
 963  			if result.Content.ProviderResponse.Output != nil && *result.Content.ProviderResponse.Output != "" {
 964  				output = *result.Content.ProviderResponse.Output
 965  			} else if result.Content.ProviderResponse.Raw != nil {
 966  				// Fallback to raw response
 967  				rawBytes, _ := json.Marshal(result.Content.ProviderResponse.Raw)
 968  				output = string(rawBytes)
 969  			}
 970  		}
 971  		if output == "" {
 972  			output = result.Content.Message
 973  		}
 974  		c.JSON(http.StatusOK, gin.H{
 975  			"status":  0,
 976  			"message": output,
 977  		})
 978  	} else {
 979  		c.JSON(http.StatusOK, gin.H{
 980  			"status":  1,
 981  			"message": result.Content.Message,
 982  		})
 983  	}
 984  }
 985  
 986  func HandleAgentTemplate(c *gin.Context) {
 987  	enConfig := "agent-scan/config/provider_config_en.json"
 988  	zhConfig := "agent-scan/config/provider_config_zh.json"
 989  	language := c.DefaultQuery("language", "zh")
 990  	var data []byte
 991  	var err error
 992  	if language == "zh" {
 993  		data, err = os.ReadFile(zhConfig)
 994  		if err != nil {
 995  			gologger.WithError(err).Errorln("read zh config")
 996  		}
 997  	} else {
 998  		data, err = os.ReadFile(enConfig)
 999  		if err != nil {
1000  			gologger.WithError(err).Errorln("read en config")
1001  		}
1002  	}
1003  	c.Data(http.StatusOK, "application/json", data)
1004  }