diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bf6e03b..988e077 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -149,7 +149,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.25.12" + go-version: "1.25.13" cache: true cache-dependency-path: go.sum @@ -181,7 +181,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.25.12" + go-version: "1.25.13" cache: true cache-dependency-path: go.sum diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 17ee96c..10a0ea0 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.25.12" + go-version: "1.25.13" cache: true cache-dependency-path: go.sum diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5077547..c28051f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -47,7 +47,7 @@ jobs: uses: actions/setup-go@v7 with: # Pinned to match go.mod. Bump both together when upgrading. - go-version: "1.25.12" + go-version: "1.25.13" # Module cache only — golangci-lint-action below brings its # own analysis cache that subsumes ~/.cache/go-build for # this job. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84d458c..cd020b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,6 +160,10 @@ description and keep ownership on the side listed here. - Adapter-specific state directories must be derived from `AgentStateKey` under `~/.parsar/`; never use the repo checkout, container image working directory, or the process CWD as hidden state. +- Keep uploaded Skill archives harness-neutral. Materialize adapter-managed + copies below the `AgentStateKey` runtime directory and register that root + through the engine's native CLI, config, or RPC surface instead of coupling + runtime discovery to the ingestion tool's temporary directory layout. ### Human interaction lifecycle diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills.go b/apps/parsar-daemon/internal/agent/claudecode/skills.go index 272add8..3ed2d60 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/skills.go +++ b/apps/parsar-daemon/internal/agent/claudecode/skills.go @@ -23,11 +23,12 @@ type skillDescriptor struct { SHA256 string } -// SkillInstallResult carries warnings the session should surface. Unlike -// PluginInstallResult there is no Dirs list — skill targets are auto- -// scanned by Claude Code from /.claude/skills/, no CLI flag. +// SkillInstallResult carries installed directories and warnings the session +// should surface. Codex and OpenCode use the directories to decide whether +// to register the managed root; Claude Code auto-scans its project root. type SkillInstallResult struct { - Warnings []string + SkillDirs []string + Warnings []string } // installSkills materialises every skill under @@ -49,17 +50,51 @@ func installSkills( if strings.TrimSpace(workDir) == "" { return SkillInstallResult{}, errors.New("claudecode skills: workDir is required") } + return installSkillsAtRoot(ctx, logger, filepath.Join(workDir, ".claude", "skills"), skills) +} + +// InstallManagedSkills decodes the portable agent_options["skills"] wire +// payload, materializes it under root, and removes skills no longer active. +func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, raw any) (SkillInstallResult, error) { + if strings.TrimSpace(root) == "" { + return SkillInstallResult{}, errors.New("managed skills: root is required") + } + skills, decodeWarnings := decodeSkillDescriptors(raw) + result, err := installSkillsAtRoot(ctx, logger, root, skills) + result.Warnings = append(decodeWarnings, result.Warnings...) + if err != nil { + return result, err + } + if err := pruneManagedSkills(root, result.SkillDirs); err != nil { + return result, err + } + return result, nil +} - root := filepath.Join(workDir, ".claude", "skills") +func installSkillsAtRoot( + ctx context.Context, + logger *slog.Logger, + root string, + skills []skillDescriptor, +) (SkillInstallResult, error) { + if logger == nil { + logger = obslog.Bg() + } + if len(skills) == 0 { + return SkillInstallResult{}, nil + } + if strings.TrimSpace(root) == "" { + return SkillInstallResult{}, errors.New("managed skills: root is required") + } if err := os.MkdirAll(root, 0o755); err != nil { - return SkillInstallResult{}, fmt.Errorf("claudecode skills: mkdir %s: %w", root, err) + return SkillInstallResult{}, fmt.Errorf("managed skills: mkdir %s: %w", root, err) } result := SkillInstallResult{} for _, s := range skills { if err := s.validate(); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) - logger.Warn("claudecode skills: invalid descriptor", "err", err.Error()) + logger.Warn("managed skills: invalid descriptor", "err", err.Error()) continue } @@ -68,8 +103,9 @@ func installSkills( expectedKey := s.cacheKey() if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { - logger.Info("claudecode skills: cache hit", + logger.Info("managed skills: cache hit", "name", s.Name, "version", s.Version, "dir", dir) + result.SkillDirs = append(result.SkillDirs, dir) continue } @@ -80,16 +116,44 @@ func installSkills( if err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) - logger.Warn("claudecode skills: install failed", + logger.Warn("managed skills: install failed", "name", s.Name, "version", s.Version, "err", err.Error()) continue } - logger.Info("claudecode skills: installed", + result.SkillDirs = append(result.SkillDirs, dir) + logger.Info("managed skills: installed", "name", s.Name, "version", s.Version, "dir", dir) } return result, nil } +func pruneManagedSkills(root string, activeDirs []string) error { + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("managed skills: read root %s: %w", root, err) + } + active := make(map[string]struct{}, len(activeDirs)) + for _, dir := range activeDirs { + active[filepath.Base(dir)] = struct{}{} + } + for _, entry := range entries { + if entry.Name() == ".tmp" { + continue + } + if _, ok := active[entry.Name()]; ok { + continue + } + path := filepath.Join(root, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("managed skills: remove stale entry %s: %w", path, err) + } + } + return nil +} + // installOneSkill: same shape as installOnePlugin, only target dir differs. // Reuses fetchPluginZip / verifyPluginSHA256FromFD / extractPluginZipFromFD // — the helpers are skill-agnostic and applying them to skill zips keeps diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills_test.go b/apps/parsar-daemon/internal/agent/claudecode/skills_test.go index b3b89b6..9a01b27 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/skills_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/skills_test.go @@ -68,6 +68,38 @@ func TestInstallSkills_CacheHitSkipsDownload(t *testing.T) { } } +func TestInstallManagedSkillsPrunesInactiveEntries(t *testing.T) { + body := validSkillZipBytes(t) + srv := startPluginServer(t, body) + root := t.TempDir() + stale := filepath.Join(root, "old-skill") + if err := os.MkdirAll(stale, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := InstallManagedSkills(context.Background(), discardLogger(), root, []any{ + map[string]any{ + "name": "code-review", "version": "1.0.0", + "download_url": srv.URL, "sha256": sha256Hex(body), + }, + }) + if err != nil { + t.Fatalf("InstallManagedSkills: %v", err) + } + if len(res.SkillDirs) != 1 || res.SkillDirs[0] != filepath.Join(root, "code-review") { + t.Fatalf("skill dirs = %v", res.SkillDirs) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale skill still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "code-review", "SKILL.md")); err != nil { + t.Fatalf("active skill missing: %v", err) + } +} + func TestInstallSkills_SHA256MismatchDemotesToWarning(t *testing.T) { t.Parallel() body := validSkillZipBytes(t) diff --git a/apps/parsar-daemon/internal/agent/codex/protocol.go b/apps/parsar-daemon/internal/agent/codex/protocol.go index f33592d..977e173 100644 --- a/apps/parsar-daemon/internal/agent/codex/protocol.go +++ b/apps/parsar-daemon/internal/agent/codex/protocol.go @@ -83,6 +83,10 @@ type InitializeResult struct { PlatformOs string `json:"platformOs,omitempty"` } +type SkillsExtraRootsSetParams struct { + ExtraRoots []string `json:"extraRoots"` +} + // --------------------------------------------------------------------------- // Approval / sandbox policies // --------------------------------------------------------------------------- diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 7539f65..69ed030 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -13,6 +13,7 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -111,6 +112,26 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan return nil, fmt.Errorf("codex: build session plan: %w", err) } + var skillRoot string + if rawSkills, ok := req.AgentOptions["skills"]; ok { + skillRoot, err = agent.ManagedSkillsRoot("codex", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + plan.Cleanup() + return nil, fmt.Errorf("codex: resolve managed skills root: %w", err) + } + installResult, installErr := claudecode.InstallManagedSkills(parent, cfg.logger, skillRoot, rawSkills) + if installErr != nil { + plan.Cleanup() + return nil, fmt.Errorf("codex: install skills: %w", installErr) + } + for _, warning := range installResult.Warnings { + cfg.logger.Warn("codex: skill install warning", "run_id", req.RunID, "msg", warning) + } + if len(installResult.SkillDirs) == 0 { + skillRoot = "" + } + } + cancelCtx, cancelFn := context.WithCancel(parent) rpcCfg := JSONRPCConfig{ @@ -152,6 +173,14 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan plan.Cleanup() return nil, fmt.Errorf("codex: rpc start: %w", err) } + if skillRoot != "" { + if err := setSkillExtraRoots(cancelCtx, rpc, []string{skillRoot}); err != nil { + cancelFn() + _ = rpc.Close() + plan.Cleanup() + return nil, fmt.Errorf("codex: register skill root: %w", err) + } + } // thread/start (or resume) + turn/start happen in the run goroutine // so newSession returns quickly; if any of those fail the failure @@ -161,6 +190,14 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan return s, nil } +func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { + if len(roots) == 0 { + return nil + } + _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}) + return err +} + func (s *Session) Cancel(_ context.Context) error { s.cancelOnce.Do(func() { s.stopCodexInteractionTimers() diff --git a/apps/parsar-daemon/internal/agent/codex/skills_test.go b/apps/parsar-daemon/internal/agent/codex/skills_test.go new file mode 100644 index 0000000..bc316ee --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/skills_test.go @@ -0,0 +1,40 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" +) + +func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + + result := make(chan error, 1) + go func() { + result <- setSkillExtraRoots(context.Background(), client.JSONRPCClient, []string{"/managed/skills"}) + }() + + decoder := json.NewDecoder(server.FromClient) + var request struct { + ID string `json:"id"` + Method string `json:"method"` + Params SkillsExtraRootsSetParams `json:"params"` + } + if err := decoder.Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.Method != "skills/extraRoots/set" { + t.Fatalf("method = %q", request.Method) + } + if len(request.Params.ExtraRoots) != 1 || request.Params.ExtraRoots[0] != "/managed/skills" { + t.Fatalf("params = %+v", request.Params) + } + response, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{}}) + if _, err := server.ToClient.Write(append(response, '\n')); err != nil { + t.Fatalf("write response: %v", err) + } + if err := <-result; err != nil { + t.Fatalf("setSkillExtraRoots: %v", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go index 3e9be91..7d9e701 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options.go +++ b/apps/parsar-daemon/internal/agent/opencode/options.go @@ -62,6 +62,12 @@ func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, return result, err } } + if roots, ok := opts["skill_roots"]; ok && roots != nil { + rawConfig, err = mergeSkillConfig(rawConfig, roots) + if err != nil { + return result, err + } + } if rawConfig != "" { configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) if err != nil { @@ -78,6 +84,43 @@ func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, return result, nil } +func mergeSkillConfig(rawConfig string, rawRoots any) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + if config == nil { + config = map[string]any{} + } + } + roots, err := stringSlice(rawRoots) + if err != nil { + return "", fmt.Errorf("opencode: skill_roots: %w", err) + } + skills, ok := config["skills"].(map[string]any) + if !ok && config["skills"] != nil { + return "", fmt.Errorf("opencode: opencode_json skills must be object, got %T", config["skills"]) + } + if skills == nil { + skills = map[string]any{} + } + existingPaths, err := stringSlice(skills["paths"]) + if err != nil { + return "", fmt.Errorf("opencode: opencode_json skills.paths: %w", err) + } + paths := mergeStringSlices(existingPaths, roots) + if len(paths) > 0 { + skills["paths"] = paths + config["skills"] = skills + } + encoded, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal merged skill config: %w", err) + } + return string(encoded), nil +} + func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { config := map[string]any{} if strings.TrimSpace(rawConfig) != "" { @@ -163,6 +206,53 @@ func stringMap(value any) map[string]string { } } +func cloneAgentOptions(opts map[string]any) map[string]any { + cloned := make(map[string]any, len(opts)) + for key, value := range opts { + cloned[key] = value + } + return cloned +} + +func mergeStringSlices(existing any, added []string) []string { + current, _ := stringSlice(existing) + seen := make(map[string]struct{}, len(current)+len(added)) + merged := make([]string, 0, len(current)+len(added)) + for _, item := range append(current, added...) { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + merged = append(merged, item) + } + return merged +} + +func stringSlice(value any) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case []string: + return append([]string{}, typed...), nil + case []any: + result := make([]string, 0, len(typed)) + for i, raw := range typed { + item, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("item %d must be string, got %T", i, raw) + } + result = append(result, item) + } + return result, nil + default: + return nil, fmt.Errorf("must be array, got %T", value) + } +} + func resolveWorkDir(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go index 879d828..7942c27 100644 --- a/apps/parsar-daemon/internal/agent/opencode/options_test.go +++ b/apps/parsar-daemon/internal/agent/opencode/options_test.go @@ -134,6 +134,39 @@ func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { } } +func TestBuildArgsMergesManagedSkillRoots(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + res, err := opencode.BuildArgs("run-skills", "hello", "", map[string]any{ + "opencode_json": `{"skills":{"paths":["/preset"],"urls":["https://example.com/skills"]}}`, + "skill_roots": []string{"/managed", "/preset"}, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config struct { + Skills struct { + Paths []string `json:"paths"` + URLs []string `json:"urls"` + } `json:"skills"` + } + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + if !slices.Equal(config.Skills.Paths, []string{"/preset", "/managed"}) { + t.Fatalf("skill paths = %v", config.Skills.Paths) + } + if !slices.Equal(config.Skills.URLs, []string{"https://example.com/skills"}) { + t.Fatalf("skill urls = %v", config.Skills.URLs) + } +} + func TestBuildArgsRejectsBadEnvShape(t *testing.T) { _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) if err == nil || !strings.Contains(err.Error(), "env") { diff --git a/apps/parsar-daemon/internal/agent/opencode/session.go b/apps/parsar-daemon/internal/agent/opencode/session.go index 1a051bc..8327d04 100644 --- a/apps/parsar-daemon/internal/agent/opencode/session.go +++ b/apps/parsar-daemon/internal/agent/opencode/session.go @@ -17,6 +17,7 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -73,7 +74,26 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan cfg.killTimeout = 3 * time.Second } - buildRes, err := BuildArgs(req.RunID, req.Prompt, req.WorkDir, req.AgentOptions) + opts := req.AgentOptions + if rawSkills, ok := opts["skills"]; ok { + skillRoot, rootErr := agent.ManagedSkillsRoot("opencode", req.AgentStateKey, req.ConversationID, req.RunID) + if rootErr != nil { + return nil, fmt.Errorf("opencode: resolve managed skills root: %w", rootErr) + } + installResult, installErr := claudecode.InstallManagedSkills(parent, cfg.logger, skillRoot, rawSkills) + if installErr != nil { + return nil, fmt.Errorf("opencode: install skills: %w", installErr) + } + for _, warning := range installResult.Warnings { + cfg.logger.Warn("opencode: skill install warning", "run_id", req.RunID, "msg", warning) + } + if len(installResult.SkillDirs) > 0 { + opts = cloneAgentOptions(opts) + opts["skill_roots"] = mergeStringSlices(opts["skill_roots"], []string{skillRoot}) + } + } + + buildRes, err := BuildArgs(req.RunID, req.Prompt, req.WorkDir, opts) if err != nil { return nil, fmt.Errorf("opencode: build args: %w", err) } diff --git a/apps/parsar-daemon/internal/agent/opencode/session_test.go b/apps/parsar-daemon/internal/agent/opencode/session_test.go index b41da5a..492a858 100644 --- a/apps/parsar-daemon/internal/agent/opencode/session_test.go +++ b/apps/parsar-daemon/internal/agent/opencode/session_test.go @@ -1,10 +1,17 @@ package opencode_test import ( + "archive/zip" + "bytes" "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" + "net/http" + "net/http/httptest" "os" + "path/filepath" "slices" "strings" "testing" @@ -29,6 +36,17 @@ func TestMain(m *testing.M) { } func runFakeOpenCode(role string) { + if dumpPath := os.Getenv("OPENCODE_TESTHELPER_CONFIG_DUMP"); dumpPath != "" { + body, err := os.ReadFile(filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "opencode", "opencode.json")) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "read managed config: %v\n", err) + os.Exit(65) + } + if err := os.WriteFile(dumpPath, body, 0o600); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "dump managed config: %v\n", err) + os.Exit(65) + } + } enc := json.NewEncoder(os.Stdout) enc.SetEscapeHTML(false) @@ -167,6 +185,73 @@ func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { } } +func TestSessionInstallsAndRegistersManagedSkills(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + body := openCodeSkillZip(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + dumpPath := filepath.Join(t.TempDir(), "opencode.json") + out := make(chan proto.Envelope, 32) + req := opencodeHelperReq("run_skills", "hello", "json-success") + req.ConversationID = "conv-skills" + req.AgentStateKey = "conv-skills/agent-1/opencode" + req.AgentOptions["skills"] = []any{map[string]any{ + "name": "find-skills", "version": "1.0.0", "download_url": srv.URL, + "sha256": fmt.Sprintf("%x", sha256.Sum256(body)), + }} + req.AgentOptions["env"].(map[string]any)["OPENCODE_TESTHELPER_CONFIG_DUMP"] = dumpPath + + sess, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainOpenCode(t, out, 5*time.Second); !closed { + t.Fatal("out did not close") + } + + skillRoot := filepath.Join(home, "runtime", "opencode", "state", "conv-skills", "agent-1", "opencode", "skills") + if _, err := os.Stat(filepath.Join(skillRoot, "find-skills", "SKILL.md")); err != nil { + t.Fatalf("managed skill missing: %v", err) + } + dumped, err := os.ReadFile(dumpPath) + if err != nil { + t.Fatalf("read dumped config: %v", err) + } + var config struct { + Skills struct { + Paths []string `json:"paths"` + } `json:"skills"` + } + if err := json.Unmarshal(dumped, &config); err != nil { + t.Fatalf("decode dumped config: %v", err) + } + if !slices.Contains(config.Skills.Paths, skillRoot) { + t.Fatalf("skills.paths = %v, want %q", config.Skills.Paths, skillRoot) + } +} + +func openCodeSkillZip(t *testing.T) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + entry, err := writer.Create("SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("---\nname: find-skills\ndescription: Find skills\n---\nUse the catalog.")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + func TestSessionPlainStdoutFallsBackToDeltaAndDone(t *testing.T) { out := make(chan proto.Envelope, 16) sess, err := opencode.NewSessionForTest(context.Background(), diff --git a/apps/parsar-daemon/internal/agent/runtime_paths.go b/apps/parsar-daemon/internal/agent/runtime_paths.go new file mode 100644 index 0000000..cafdb9a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/runtime_paths.go @@ -0,0 +1,64 @@ +package agent + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +// ManagedSkillsRoot returns the adapter-owned skill directory for one +// agent state scope. It never derives runtime state from the subprocess cwd. +func ManagedSkillsRoot(agentKind, agentStateKey, conversationID, runID string) (string, error) { + root, err := paths.Root() + if err != nil { + return "", fmt.Errorf("agent: resolve managed skills root: %w", err) + } + kind := safeRuntimePathPart(agentKind) + if kind == "" { + return "", fmt.Errorf("agent: invalid agent kind %q", agentKind) + } + base := filepath.Join(root, "runtime", kind) + if key := strings.TrimSpace(agentStateKey); key != "" { + parts := safeRuntimePathParts(key) + if len(parts) == 0 { + return "", fmt.Errorf("agent: invalid agent state key %q", agentStateKey) + } + return filepath.Join(append([]string{base, "state"}, append(parts, "skills")...)...), nil + } + if id := safeRuntimePathPart(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "skills"), nil + } + if id := safeRuntimePathPart(runID); id != "" { + return filepath.Join(base, "run-"+id, "skills"), nil + } + return "", fmt.Errorf("agent: agent state key, conversation id, or run id is required") +} + +func safeRuntimePathParts(value string) []string { + raw := strings.Split(value, "/") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + if safe := safeRuntimePathPart(part); safe != "" { + parts = append(parts, safe) + } + } + return parts +} + +func safeRuntimePathPart(value string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(value) { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + value = b.String() + if value == "." || value == ".." { + return "" + } + return value +} diff --git a/apps/parsar-daemon/internal/agent/runtime_paths_test.go b/apps/parsar-daemon/internal/agent/runtime_paths_test.go new file mode 100644 index 0000000..6367fd0 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/runtime_paths_test.go @@ -0,0 +1,32 @@ +package agent + +import ( + "path/filepath" + "testing" +) + +func TestManagedSkillsRootUsesStableAgentState(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + got, err := ManagedSkillsRoot("codex", "conv-1/agent-1/codex", "ignored", "ignored") + if err != nil { + t.Fatalf("ManagedSkillsRoot: %v", err) + } + want := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills") + if got != want { + t.Fatalf("root = %q, want %q", got, want) + } +} + +func TestManagedSkillsRootSanitizesFallback(t *testing.T) { + home := t.TempDir() + t.Setenv("PARSAR_HOME", home) + got, err := ManagedSkillsRoot("opencode", "", "../conv name", "ignored") + if err != nil { + t.Fatalf("ManagedSkillsRoot: %v", err) + } + want := filepath.Join(home, "runtime", "opencode", "conv-.._conv_name", "skills") + if got != want { + t.Fatalf("root = %q, want %q", got, want) + } +} diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 1b3892e..3b5f762 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -349,6 +349,7 @@ "badge": "Run failed", "fallbackCapability": "this capability", "capability_credential_missing": "Capability {{name}} failed to start: missing {{kind}} credential.", + "capability_unsupported": "Capability {{name}} is not supported by this Agent engine.", "capability_credential_decrypt_failed": "Capability {{name}} credential could not be decrypted. Please contact an administrator.", "capability_credential_kind_mismatch": "Capability {{name}} credential type does not match. Please set it again.", "capability_version_unavailable": "Capability {{name}} has no usable upload yet. Re-upload it as a zip, pick a newer version, or switch the binding to 'latest'.", @@ -1668,8 +1669,6 @@ "noTagsMember": "This workspace has no reusable capability tags yet.", "goCapabilities": "Go to Capabilities →", "noCapabilityVersion": "No version", - "deprecatedCapabilityBadge": "Deprecated", - "deprecatedCapabilityTooltip": "This capability has been deprecated. Existing bindings keep working; once you uncheck it you cannot re-add it.", "capabilityTypeTabs": { "all": "All", "mcp": "MCP", @@ -1705,7 +1704,7 @@ }, "capabilities": { "title": "Capabilities", - "summary": "Skills and MCPs enabled by default" + "summary": "Skills and MCPs available to {{engine}}" } }, "actions": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index c6e813c..0ea3f71 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -349,6 +349,7 @@ "badge": "运行失败", "fallbackCapability": "此能力", "capability_credential_missing": "能力 {{name}} 启动失败:缺少 {{kind}} 凭据。", + "capability_unsupported": "当前 Agent 引擎不支持能力 {{name}}。", "capability_credential_decrypt_failed": "能力 {{name}} 凭据解密失败,请联系管理员。", "capability_credential_kind_mismatch": "能力 {{name}} 凭据类型不匹配,请重新设置。", "capability_version_unavailable": "能力 {{name}} 还没有可用的上传版本,请重新上传 zip、选择更新的版本,或将该能力切换为 latest 模式。", @@ -1668,8 +1669,6 @@ "noTagsMember": "当前工作区还没有可复用的能力标签。", "goCapabilities": "去能力页 →", "noCapabilityVersion": "无可用版本", - "deprecatedCapabilityBadge": "已下架", - "deprecatedCapabilityTooltip": "该能力已下架,保留旧绑定可继续使用;取消勾选后无法再选回。", "capabilityTypeTabs": { "all": "全部", "mcp": "MCP", @@ -1705,7 +1704,7 @@ }, "capabilities": { "title": "能力", - "summary": "为这个 Agent 启用 Skill / MCP" + "summary": "为 {{engine}} 启用 Skill / MCP" } }, "actions": { diff --git a/apps/web/src/lib/agent-view-model.ts b/apps/web/src/lib/agent-view-model.ts index d4e396d..c70694b 100644 --- a/apps/web/src/lib/agent-view-model.ts +++ b/apps/web/src/lib/agent-view-model.ts @@ -92,7 +92,7 @@ export function agentEngineSupportsCapability(engine: AgentEngine, capabilityTyp return true case "codex": case "opencode": - return capabilityType === "mcp" || capabilityType === "system_prompt" + return capabilityType === "mcp" || capabilityType === "skill" || capabilityType === "system_prompt" case "pi": return capabilityType === "skill" || capabilityType === "system_prompt" } diff --git a/apps/web/src/pages/admin/ConversationsPage.tsx b/apps/web/src/pages/admin/ConversationsPage.tsx index 5120b13..90286f7 100644 --- a/apps/web/src/pages/admin/ConversationsPage.tsx +++ b/apps/web/src/pages/admin/ConversationsPage.tsx @@ -1300,6 +1300,14 @@ function runtimeErrorViewModel( : "?admin=capabilities" switch (subKind) { + case "capability_unsupported": + return { + message: t("conversations.runtime_error.capability_unsupported", { + name: capabilityName, + }), + action: t("conversations.runtime_error.manageCapability"), + href: manageCapabilityHref, + } case "capability_credential_missing": return { message: t("conversations.runtime_error.capability_credential_missing", { diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index 55bbc72..962f1f8 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -17,7 +17,7 @@ import { import { Input } from "../../components/ui/input" import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs" import { ApiError } from "../../lib/api-client" -import { agentCodexModeOf, type CodexCollaborationMode } from "../../lib/agent-view-model" +import { agentCodexModeOf, agentEngineLabel, agentEngineSupportsCapability, agentEnginesSupportingCapability, type CodexCollaborationMode } from "../../lib/agent-view-model" import { modelProtocols, modelSupportedEndpointTypes, @@ -322,16 +322,13 @@ export function CreateAgentDialog({ const selectedModelID = modelID || (mode === "create" ? firstModelID : "") const selectedModel = useMemo(() => activeModels.find((m) => m.id === selectedModelID) ?? null, [activeModels, selectedModelID]) const capabilityOptions = useMemo(() => { - // `type: ""` is a sentinel for ghost rows (deprecated bindings whose real - // type is unknown); downstream filters treat it as wildcard. type PickerOption = { id: string name: string - type: CapabilityType | "" + type: CapabilityType description: string latestVersionID: string latestVersion: string - deprecated: boolean section: "workspace" | "marketplace" requiredCredentials: RequiredCredential[] } @@ -345,7 +342,6 @@ export function CreateAgentDialog({ description: cap.description ?? "", latestVersionID: cap.latest_version_id ?? "", latestVersion: cap.latest_version ?? cap.latest_published_version ?? "", - deprecated: false, section: "workspace", requiredCredentials: cap.required_credentials ?? [], })) @@ -356,43 +352,15 @@ export function CreateAgentDialog({ description: cap.description ?? "", latestVersionID: cap.latest_version_id ?? "", latestVersion: cap.latest_version ?? "", - deprecated: false, section: "marketplace", requiredCredentials: cap.required_credentials ?? [], })) marketplace.sort((a, b) => a.name.localeCompare(b.name)) - const live: PickerOption[] = [...workspace, ...marketplace] - // Ghost bindings (edit mode): when an admin deprecates a capability the - // agent still binds, ListCapabilities hides it and the row would silently - // vanish from the picker. Merge it back as a disabled row so the user can - // deliberately unbind. The agent profile only stores names (not types), - // so type is left empty and treated as wildcard downstream. - if (mode === "edit") { - const known = new Set(live.map((c) => c.name)) - for (const name of capabilities) { - if (!known.has(name)) { - live.push({ - id: `ghost:${name}`, - name, - type: "", - description: "", - latestVersionID: "", - latestVersion: "", - deprecated: true, - section: "workspace", - requiredCredentials: [], - }) - } - } - } - return live - }, [capabilitiesQ.data, mode, capabilities]) + return [...workspace, ...marketplace] + }, [capabilitiesQ.data]) const capabilityTypeCounts = useMemo(() => { - // Ghost rows have unknown type, so they're excluded from per-type tallies - // (still count toward "all"). const counts = { all: capabilityOptions.length, mcp: 0, skill: 0 } for (const cap of capabilityOptions) { - if (cap.deprecated) continue if (cap.type === "mcp") counts.mcp++ else if (cap.type === "skill") counts.skill++ } @@ -401,9 +369,7 @@ export function CreateAgentDialog({ const visibleCapabilityOptions = useMemo( () => capabilityTypeFilter === "all" ? capabilityOptions - // Ghost rows surface under every type tab; hiding them on a non-matching - // tab would resurrect the "binding seems to have vanished" footgun. - : capabilityOptions.filter((cap) => cap.deprecated || cap.type === capabilityTypeFilter), + : capabilityOptions.filter((cap) => cap.type === capabilityTypeFilter), [capabilityOptions, capabilityTypeFilter] ) // Models the current engine can't drive (wrong wire protocol). Kept in the @@ -468,6 +434,16 @@ export function CreateAgentDialog({ ) const admin = isAdminRole(workspaceRole) + function selectAgentEngine(nextEngine: AgentEngine) { + setAgentEngine(nextEngine) + if (mode !== "create") return + const capabilityByID = new Map(allCapabilitiesPool.map((capability) => [capability.id, capability])) + setSelectedCapabilityIDs((current) => current.filter((id) => { + const capability = capabilityByID.get(id) + return !capability || agentEngineSupportsCapability(nextEngine, capability.type) + })) + } + const modelFieldRef = useRef(null) const modelComboboxRef = useRef(null) const modelListboxID = useId() @@ -779,11 +755,14 @@ export function CreateAgentDialog({ // the user a clearer error tied to the input instead of a stream error. return } - if (mode === "create" && allCapabilitiesQ.isLoading) return + if (!allCapabilitiesQ.isSuccess) return const selectedCapabilities = mode === "create" - ? allCapabilitiesPool.filter((cap) => selectedCapabilityIDs.includes(cap.id) && cap.latest_version_id) + ? allCapabilitiesPool.filter((cap) => selectedCapabilityIDs.includes(cap.id) && cap.latest_version_id && agentEngineSupportsCapability(agentEngine, cap.type)) : [] - const capabilityNames = mode === "create" ? selectedCapabilities.map((cap) => cap.name) : capabilities + const selectableCapabilityNames = new Set(allCapabilitiesPool.map((cap) => cap.name)) + const capabilityNames = mode === "create" + ? selectedCapabilities.map((cap) => cap.name) + : capabilities.filter((name) => selectableCapabilityNames.has(name)) // initialCapabilities carries the per-binding pin choice. Empty // versionID falls back to the capability's latest_version_id so the // server's NOT NULL capability_version_id constraint is satisfied @@ -960,7 +939,7 @@ export function CreateAgentDialog({ hasRequiredModel && (connector !== "agent_daemon" || executionMode !== "local_device" || deviceID !== "") && workDirValid && - (mode !== "create" || !allCapabilitiesQ.isLoading) && + allCapabilitiesQ.isSuccess && (aggregatedRequiredKinds.length === 0 || allCredentialsSatisfied) const step1Valid = @@ -1015,7 +994,9 @@ export function CreateAgentDialog({ totalSteps={totalSteps} progressPercent={progressPercent} title={t(`agents.form.wizard.steps.${step === 1 ? "setup" : "capabilities"}.title` as never)} - summary={t(`agents.form.wizard.steps.${step === 1 ? "setup" : "capabilities"}.summary` as never)} + summary={t(`agents.form.wizard.steps.${step === 1 ? "setup" : "capabilities"}.summary` as never, { + engine: t(agentEngineLabel(agentEngine)), + })} stepOfLabel={t("agents.form.wizard.stepOf", { current: step, total: totalSteps })} completeLabel={t("agents.form.wizard.complete", { percent: progressPercent })} /> @@ -1079,25 +1060,25 @@ export function CreateAgentDialog({ icon={} title={t("agents.engine.claudeCode.title")} selected={agentEngine === "claude_code"} - onSelect={() => setAgentEngine("claude_code")} + onSelect={() => selectAgentEngine("claude_code")} /> } title={t("agents.engine.codex.title")} selected={agentEngine === "codex"} - onSelect={() => setAgentEngine("codex")} + onSelect={() => selectAgentEngine("codex")} /> } title={t("agents.engine.pi.title")} selected={agentEngine === "pi"} - onSelect={() => setAgentEngine("pi")} + onSelect={() => selectAgentEngine("pi")} /> } title={t("agents.engine.opencode.title")} selected={agentEngine === "opencode"} - onSelect={() => setAgentEngine("opencode")} + onSelect={() => selectAgentEngine("opencode")} disabled /> @@ -1504,11 +1485,18 @@ export function CreateAgentDialog({ const index = rowCounter++ const checked = mode === "create" ? selectedCapabilityIDs.includes(cap.id) : capabilities.includes(cap.name) const lockedNoVersion = mode === "create" && !cap.latestVersionID - const lockedDeprecatedAndUnchecked = cap.deprecated && !checked - const disabled = lockedNoVersion || lockedDeprecatedAndUnchecked - const ghostTitle = cap.deprecated ? t("agents.form.deprecatedCapabilityTooltip") : undefined + const incompatible = !agentEngineSupportsCapability(agentEngine, cap.type) + const lockedIncompatibleAndUnchecked = incompatible && !checked + const disabled = lockedNoVersion || lockedIncompatibleAndUnchecked + const compatibilityTitle = incompatible + ? t("agents.detail.capabilities.compatibility.unsupported", { + engine: t(agentEngineLabel(agentEngine)), + type: t(`agents.detail.capabilities.compatibility.types.${cap.type}`), + engines: agentEnginesSupportingCapability(cap.type).map((engine) => t(agentEngineLabel(engine))).join(", "), + }) + : undefined return ( -