mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* feat(agents): add per-agent runtime skill controls Co-authored-by: multica-agent <github@multica.ai> * fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle Address the MUL-5101 review blockers on PR #5686: - Rebase onto main and renumber the runtime-skill-disable migration 202 -> 203. main added 202_runtime_profile_add_qwen, so the pair collided on prefix 202 and migrations_lint_test would reject the duplicate. 203 is the next free prefix. - Publish an "agent:status" event after persisting a disabled_runtime_skills override, mirroring the workspace-skill toggle in writeUpdatedAgentSkills. The realtime layer keys off this event to invalidate workspaceKeys.agents, so other open web/desktop/mobile clients now drop their stale toggle state instead of only the initiating tab refreshing. Reload junction-table skills before the broadcast so it doesn't signal cleared skills (#3459). - Add a handler regression test proving the broadcast fires on both disable and enable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Walt <walt@multica.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
4.3 KiB
Go
160 lines
4.3 KiB
Go
package execenv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const claudeRuntimeSkillSettingsFile = "claude-runtime-skill-settings.json"
|
|
|
|
// RuntimeSkillRefForEnv identifies a runtime-local skill for provider-specific
|
|
// task environment filtering. Provider and runtime are already selected by the
|
|
// task, so only the discovery root and provider-native key are needed here.
|
|
type RuntimeSkillRefForEnv struct {
|
|
Root string
|
|
Key string
|
|
Name string
|
|
Plugin string
|
|
}
|
|
|
|
func cleanRuntimeSkillKey(key string) (string, bool) {
|
|
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(key)))
|
|
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
|
|
return "", false
|
|
}
|
|
return filepath.ToSlash(cleaned), true
|
|
}
|
|
|
|
func prepareClaudeSkillSettings(envRoot string, disabled []RuntimeSkillRefForEnv, workspaceSkills []SkillContextForEnv) (string, error) {
|
|
path := filepath.Join(envRoot, claudeRuntimeSkillSettingsFile)
|
|
if len(disabled) == 0 {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
overrides := make(map[string]string)
|
|
deny := make([]string, 0, len(disabled)*2)
|
|
seenDeny := make(map[string]struct{}, len(disabled)*2)
|
|
addDeny := func(rule string) {
|
|
if _, exists := seenDeny[rule]; exists {
|
|
return
|
|
}
|
|
seenDeny[rule] = struct{}{}
|
|
deny = append(deny, rule)
|
|
}
|
|
for _, skill := range disabled {
|
|
key, ok := cleanRuntimeSkillKey(skill.Key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
invocationName := strings.TrimSpace(skill.Name)
|
|
if invocationName == "" {
|
|
invocationName = filepath.Base(filepath.FromSlash(key))
|
|
}
|
|
if workspaceClaimsRuntimeSkill(invocationName, workspaceSkills) {
|
|
continue
|
|
}
|
|
// Claude Code's skillOverrides fully hides personal/project skills.
|
|
// Plugin skills ignore that setting, so the permission deny below is
|
|
// also emitted for every key and is the enforcement path for plugins.
|
|
if skill.Root != "plugin" {
|
|
overrides[invocationName] = "off"
|
|
} else {
|
|
invocationName = key
|
|
}
|
|
addDeny("Skill(" + invocationName + ")")
|
|
addDeny("Skill(" + invocationName + " *)")
|
|
}
|
|
if len(overrides) == 0 && len(deny) == 0 {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
return "", nil
|
|
}
|
|
payload := map[string]any{
|
|
"skillOverrides": overrides,
|
|
"permissions": map[string]any{
|
|
"deny": deny,
|
|
},
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func ensureCodexDisabledSkillsConfig(configPath, codexHome string, disabled []RuntimeSkillRefForEnv, workspaceSkills []SkillContextForEnv) error {
|
|
if len(disabled) == 0 {
|
|
return nil
|
|
}
|
|
home := ""
|
|
paths := make([]string, 0, len(disabled))
|
|
seen := make(map[string]struct{}, len(disabled))
|
|
for _, skill := range disabled {
|
|
key, ok := cleanRuntimeSkillKey(skill.Key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var skillPath string
|
|
switch skill.Root {
|
|
case "provider":
|
|
firstKeyPart := strings.SplitN(key, "/", 2)[0]
|
|
if workspaceClaimsRuntimeSkill(firstKeyPart, workspaceSkills) {
|
|
continue
|
|
}
|
|
skillPath = filepath.Join(codexHome, "skills", filepath.FromSlash(key), "SKILL.md")
|
|
case "universal":
|
|
if home == "" {
|
|
var err error
|
|
home, err = os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve user home for disabled Codex skills: %w", err)
|
|
}
|
|
}
|
|
skillPath = filepath.Join(home, ".agents", "skills", filepath.FromSlash(key), "SKILL.md")
|
|
default:
|
|
continue
|
|
}
|
|
if _, exists := seen[skillPath]; exists {
|
|
continue
|
|
}
|
|
seen[skillPath] = struct{}{}
|
|
paths = append(paths, skillPath)
|
|
}
|
|
if len(paths) == 0 {
|
|
return nil
|
|
}
|
|
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
for _, path := range paths {
|
|
block := "\n[[skills.config]]\npath = " + strconv.Quote(filepath.ToSlash(path)) + "\nenabled = false\n"
|
|
if _, err := file.WriteString(block); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func workspaceClaimsRuntimeSkill(name string, workspaceSkills []SkillContextForEnv) bool {
|
|
claim := sanitizeSkillName(name)
|
|
for _, skill := range workspaceSkills {
|
|
if sanitizeSkillName(skill.Name) == claim {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|