mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +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>
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
package execenv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestPrepareClaudeSkillSettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
path, err := prepareClaudeSkillSettings(root, []RuntimeSkillRefForEnv{
|
|
{Root: "provider", Key: "review-dir", Name: "review"},
|
|
{Root: "plugin", Key: "paper:design-to-code", Plugin: "paper@market"},
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("prepareClaudeSkillSettings: %v", err)
|
|
}
|
|
if path == "" {
|
|
t.Fatal("expected task-local settings path")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read settings: %v", err)
|
|
}
|
|
var got struct {
|
|
SkillOverrides map[string]string `json:"skillOverrides"`
|
|
Permissions struct {
|
|
Deny []string `json:"deny"`
|
|
} `json:"permissions"`
|
|
}
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Fatalf("decode settings: %v", err)
|
|
}
|
|
if got.SkillOverrides["review"] != "off" {
|
|
t.Fatalf("ordinary skill override = %q, want off", got.SkillOverrides["review"])
|
|
}
|
|
if _, exists := got.SkillOverrides["paper:design-to-code"]; exists {
|
|
t.Fatal("plugin skills must not use Claude's unsupported skillOverrides path")
|
|
}
|
|
for _, want := range []string{
|
|
"Skill(review)",
|
|
"Skill(review *)",
|
|
"Skill(paper:design-to-code)",
|
|
"Skill(paper:design-to-code *)",
|
|
} {
|
|
found := false
|
|
for _, rule := range got.Permissions.Deny {
|
|
found = found || rule == want
|
|
}
|
|
if !found {
|
|
t.Errorf("missing deny rule %q in %v", want, got.Permissions.Deny)
|
|
}
|
|
}
|
|
|
|
cleared, err := prepareClaudeSkillSettings(root, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("clear settings: %v", err)
|
|
}
|
|
if cleared != "" {
|
|
t.Fatalf("cleared settings path = %q, want empty", cleared)
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("stale settings file still exists: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCodexDisabledSkillsConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
configPath := filepath.Join(root, "config.toml")
|
|
if err := os.WriteFile(configPath, []byte("model = \"gpt-5\"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureCodexDisabledSkillsConfig(configPath, root, []RuntimeSkillRefForEnv{
|
|
{Root: "provider", Key: "review"},
|
|
{Root: "universal", Key: "shared/release"},
|
|
{Root: "provider", Key: "../escape"},
|
|
}, nil); err != nil {
|
|
t.Fatalf("ensureCodexDisabledSkillsConfig: %v", err)
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := string(data)
|
|
if strings.Count(content, "[[skills.config]]") != 2 {
|
|
t.Fatalf("disabled entry count mismatch:\n%s", content)
|
|
}
|
|
wantProvider := filepath.ToSlash(filepath.Join(root, "skills", "review", "SKILL.md"))
|
|
if !strings.Contains(content, wantProvider) {
|
|
t.Fatalf("missing provider skill path %q:\n%s", wantProvider, content)
|
|
}
|
|
if strings.Contains(content, "escape") {
|
|
t.Fatalf("unsafe key leaked into config:\n%s", content)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSkillPoliciesYieldToWorkspaceSkills(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
workspaceSkills := []SkillContextForEnv{{Name: "Review"}}
|
|
settingsPath, err := prepareClaudeSkillSettings(root, []RuntimeSkillRefForEnv{
|
|
{Root: "provider", Key: "review-dir", Name: "review"},
|
|
}, workspaceSkills)
|
|
if err != nil {
|
|
t.Fatalf("prepareClaudeSkillSettings: %v", err)
|
|
}
|
|
if settingsPath != "" {
|
|
t.Fatalf("workspace-owned Claude skill was disabled via %q", settingsPath)
|
|
}
|
|
|
|
configPath := filepath.Join(root, "config.toml")
|
|
if err := ensureCodexDisabledSkillsConfig(configPath, root, []RuntimeSkillRefForEnv{
|
|
{Root: "provider", Key: "review"},
|
|
}, workspaceSkills); err != nil {
|
|
t.Fatalf("ensureCodexDisabledSkillsConfig: %v", err)
|
|
}
|
|
if data, err := os.ReadFile(configPath); err == nil && strings.Contains(string(data), "[[skills.config]]") {
|
|
t.Fatalf("workspace-owned Codex skill was disabled:\n%s", data)
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|