package controlplane import ( "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "regexp" "strings" "time" ) // maxSkillContentBytes bounds one Skill Markdown document. The Agent applies the // same limit so content is never silently truncated after deployment. const maxSkillContentBytes = 50000 // Skill is a Markdown-only capability guide owned by the controller. type Skill struct { ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Checksum string `json:"checksum"` UpdatedAt string `json:"updatedAt"` } type SkillInput struct { Name string `json:"name"` Version string `json:"version"` Content string `json:"content"` } // SkillDocument is the safe, read-only Markdown view exposed to the local UI. // It intentionally contains no write capability or executable entry point. type SkillDocument struct { ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Content string `json:"content"` UpdatedAt string `json:"updatedAt"` } type AgentSkillStatus struct { AgentID string `json:"agentID"` AgentName string `json:"agentName"` SkillID string `json:"skillID"` SkillName string `json:"skillName"` Version string `json:"version"` ApplyStatus string `json:"applyStatus"` LastError string `json:"lastError"` UpdatedAt string `json:"updatedAt"` } func (c *ControlPlane) Skills() ([]Skill, error) { rows, err := c.db.Query("SELECT skill_id, name, version, checksum, updated_at FROM skill_packages ORDER BY name") if err != nil { return nil, fmt.Errorf("无法读取 Skills: %w", err) } defer rows.Close() skills := make([]Skill, 0) for rows.Next() { var skill Skill if err := rows.Scan(&skill.ID, &skill.Name, &skill.Version, &skill.Checksum, &skill.UpdatedAt); err != nil { return nil, err } skills = append(skills, skill) } return skills, rows.Err() } // SkillDocument returns one controller-owned Markdown file for read-only preview. // Credentials embedded in legacy reference material are redacted before leaving // the controller process. func (c *ControlPlane) SkillDocument(skillID string) (SkillDocument, error) { if !isSkillID(skillID) { return SkillDocument{}, errors.New("Skill 标识无效") } var document SkillDocument err := c.db.QueryRow("SELECT skill_id, name, version, content, updated_at FROM skill_packages WHERE skill_id = ?", skillID).Scan( &document.ID, &document.Name, &document.Version, &document.Content, &document.UpdatedAt, ) if errors.Is(err, sql.ErrNoRows) { return SkillDocument{}, errors.New("未找到 Skill Markdown 文件") } if err != nil { return SkillDocument{}, fmt.Errorf("无法读取 Skill Markdown 文件: %w", err) } document.Content = redactSkillMarkdown(document.Content) return document, nil } // SaveSkill stores one Markdown-only Skill package. A skill is identified by its // display name, so importing an updated version keeps the identity — and therefore // every existing Agent assignment — intact instead of creating a duplicate. func (c *ControlPlane) SaveSkill(input SkillInput) (Skill, error) { name, version, content := strings.TrimSpace(input.Name), strings.TrimSpace(input.Version), strings.TrimSpace(input.Content) if name == "" || version == "" || content == "" { return Skill{}, errors.New("请完整填写 Skill 名称、版本和内容") } if len([]rune(name)) > 80 || len([]rune(version)) > 40 || len(content) > maxSkillContentBytes { return Skill{}, errors.New("Skill 内容或名称过长") } sum := sha256.Sum256([]byte(content)) checksum := hex.EncodeToString(sum[:]) now := time.Now().UTC().Format(time.RFC3339Nano) skillID := "" err := c.db.QueryRow("SELECT skill_id FROM skill_packages WHERE name = ?", name).Scan(&skillID) switch { case errors.Is(err, sql.ErrNoRows): skillID, err = randomHex(16) if err != nil { return Skill{}, err } if _, err = c.db.Exec("INSERT INTO skill_packages(skill_id, name, version, content, checksum, updated_at) VALUES (?, ?, ?, ?, ?, ?)", skillID, name, version, content, checksum, now); err != nil { return Skill{}, fmt.Errorf("无法保存 Skill: %w", err) } c.recordAudit("skill.package_saved", "controller", "skill:"+skillID, "completed", "已保存 Skill 包。") case err != nil: return Skill{}, fmt.Errorf("无法读取 Skill: %w", err) default: if _, err = c.db.Exec("UPDATE skill_packages SET version = ?, content = ?, checksum = ?, updated_at = ? WHERE skill_id = ?", version, content, checksum, now, skillID); err != nil { return Skill{}, fmt.Errorf("无法保存 Skill: %w", err) } c.recordAudit("skill.package_updated", "controller", "skill:"+skillID, "completed", "已按名称覆盖更新 Skill 包内容与版本。") } c.redeploySkill(skillID) return Skill{ID: skillID, Name: name, Version: version, Checksum: checksum, UpdatedAt: now}, nil } // redeploySkill re-sends an updated Skill to the Agents that already selected it. func (c *ControlPlane) redeploySkill(skillID string) { rows, err := c.db.Query("SELECT agent_id FROM agent_skill_assignments WHERE skill_id = ?", skillID) if err != nil { return } agentIDs := make([]string, 0) for rows.Next() { var agentID string if rows.Scan(&agentID) == nil { agentIDs = append(agentIDs, agentID) } } rows.Close() for _, agentID := range agentIDs { if _, err := c.db.Exec("UPDATE agent_skill_assignments SET apply_status = 'pending', last_error = '', updated_at = ? WHERE agent_id = ? AND skill_id = ?", time.Now().UTC().Format(time.RFC3339Nano), agentID, skillID); err != nil { continue } go c.pushSkills(agentID) } } func (c *ControlPlane) DeploySkill(skillID string, agentIDs []string) error { if skillID == "" || len(agentIDs) == 0 { return errors.New("请选择 Skill 和至少一个 Agent") } var exists int if err := c.db.QueryRow("SELECT COUNT(*) FROM skill_packages WHERE skill_id = ?", skillID).Scan(&exists); err != nil || exists != 1 { return errors.New("未找到要下发的 Skill") } now := time.Now().UTC().Format(time.RFC3339Nano) for _, agentID := range agentIDs { if _, err := c.db.Exec("INSERT INTO agent_skill_assignments(agent_id, skill_id, apply_status, last_error, updated_at) VALUES (?, ?, 'pending', '', ?) ON CONFLICT(agent_id, skill_id) DO UPDATE SET apply_status = 'pending', last_error = '', updated_at = excluded.updated_at", agentID, skillID, now); err != nil { return fmt.Errorf("无法保存 Skill 下发任务: %w", err) } c.recordAudit("skill.deployment_requested", "controller", "agent:"+agentID, "pending", "已请求下发一个已批准 Skill。") c.pushSkills(agentID) } return nil } // SetAgentSkills atomically replaces one Agent's selected controller-approved Skills. func (c *ControlPlane) SetAgentSkills(agentID string, skillIDs []string) error { if strings.TrimSpace(agentID) == "" { return errors.New("Agent 标识无效") } var agentExists int if err := c.db.QueryRow("SELECT COUNT(*) FROM managed_agents WHERE agent_id = ?", agentID).Scan(&agentExists); err != nil || agentExists != 1 { return errors.New("目标 Agent 不存在") } unique := make(map[string]struct{}, len(skillIDs)) for _, skillID := range skillIDs { if _, seen := unique[skillID]; seen || skillID == "" { continue } var exists int if err := c.db.QueryRow("SELECT COUNT(*) FROM skill_packages WHERE skill_id = ?", skillID).Scan(&exists); err != nil || exists != 1 { return errors.New("选择了不存在的 Skill") } unique[skillID] = struct{}{} } transaction, err := c.db.Begin() if err != nil { return err } defer transaction.Rollback() if _, err = transaction.Exec("DELETE FROM agent_skill_assignments WHERE agent_id = ?", agentID); err != nil { return fmt.Errorf("无法更新 Agent Skill 选择: %w", err) } now := time.Now().UTC().Format(time.RFC3339Nano) if _, err = transaction.Exec("INSERT INTO agent_skill_selections(agent_id, updated_at) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET updated_at = excluded.updated_at", agentID, now); err != nil { return fmt.Errorf("无法保存 Agent Skill 选择: %w", err) } for skillID := range unique { if _, err = transaction.Exec("INSERT INTO agent_skill_assignments(agent_id, skill_id, apply_status, last_error, updated_at) VALUES (?, ?, 'pending', '', ?)", agentID, skillID, now); err != nil { return fmt.Errorf("无法保存 Agent Skill 选择: %w", err) } } if err = transaction.Commit(); err != nil { return err } c.recordAudit("skill.selection_updated", "controller", "agent:"+agentID, "pending", fmt.Sprintf("已更新为 %d 个已批准 Skill。", len(unique))) c.pushSkills(agentID) return nil } func (c *ControlPlane) AgentSkillStatuses() ([]AgentSkillStatus, error) { rows, err := c.db.Query("SELECT a.agent_id, a.name, s.skill_id, s.name, s.version, x.apply_status, x.last_error, x.updated_at FROM agent_skill_assignments x JOIN managed_agents a ON a.agent_id=x.agent_id JOIN skill_packages s ON s.skill_id=x.skill_id ORDER BY a.name, s.name") if err != nil { return nil, fmt.Errorf("无法读取 Skill 状态: %w", err) } defer rows.Close() result := make([]AgentSkillStatus, 0) for rows.Next() { var status AgentSkillStatus if err := rows.Scan(&status.AgentID, &status.AgentName, &status.SkillID, &status.SkillName, &status.Version, &status.ApplyStatus, &status.LastError, &status.UpdatedAt); err != nil { return nil, err } result = append(result, status) } return result, rows.Err() } func (c *ControlPlane) pushSkills(agentID string) { client := c.clientFor(agentID) if client == nil { return } rows, err := c.db.Query("SELECT s.skill_id, s.name, s.version, s.content, s.checksum FROM agent_skill_assignments x JOIN skill_packages s ON s.skill_id=x.skill_id WHERE x.agent_id=?", agentID) if err != nil { return } defer rows.Close() skills := make([]map[string]string, 0) for rows.Next() { var id, name, version, content, checksum string if rows.Scan(&id, &name, &version, &content, &checksum) != nil { return } skills = append(skills, map[string]string{"id": id, "name": name, "version": version, "content": content, "checksum": checksum}) } if len(skills) == 0 { var selectionCount int if c.db.QueryRow("SELECT COUNT(*) FROM agent_skill_selections WHERE agent_id = ?", agentID).Scan(&selectionCount) != nil || selectionCount == 0 { return } } if err := client.send(map[string]any{"type": "skill_snapshot", "schema_version": protocolVersion, "skills": skills}); err == nil { _, _ = c.db.Exec("UPDATE agent_skill_assignments SET apply_status='sent', updated_at=? WHERE agent_id=? AND apply_status='pending'", time.Now().UTC().Format(time.RFC3339Nano), agentID) c.recordAudit("skill.snapshot_sent", "controller", "agent:"+agentID, "sent", fmt.Sprintf("已下发 %d 个已选择 Skill。", len(skills))) } } func (c *ControlPlane) applySkillAck(agentID string, message map[string]any) { skillID, _ := message["skill_id"].(string) status, _ := message["status"].(string) errorText, _ := message["error"].(string) if skillID == "" || (status != "applied" && status != "failed") { return } _, _ = c.db.Exec("UPDATE agent_skill_assignments SET apply_status=?, last_error=?, updated_at=? WHERE agent_id=? AND skill_id=?", status, limit(sanitizeDisplayText(errorText), 1000), time.Now().UTC().Format(time.RFC3339Nano), agentID, skillID) c.recordAudit("skill.deployment_ack", "agent:"+agentID, "skill:"+skillID, status, errorText) } func isSkillID(value string) bool { return len(value) == 32 && regexp.MustCompile(`^[a-f0-9]{32}$`).MatchString(value) } var skillSecretLinePattern = regexp.MustCompile(`(?im)^(\s*(?:api[_ -]?key|password|passwd|secret|token|credential|authorization|用户名|账号|密码|密钥)\s*[:=]\s*).*$`) func redactSkillMarkdown(content string) string { content = sanitizeDisplayText(content) return skillSecretLinePattern.ReplaceAllString(content, `${1}[已脱敏]`) }