Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 74 additions & 10 deletions apps/parsar-daemon/internal/agent/claudecode/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workDir>/.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
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions apps/parsar-daemon/internal/agent/claudecode/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ type InitializeResult struct {
PlatformOs string `json:"platformOs,omitempty"`
}

type SkillsExtraRootsSetParams struct {
ExtraRoots []string `json:"extraRoots"`
}

// ---------------------------------------------------------------------------
// Approval / sandbox policies
// ---------------------------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/skills_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading