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
102 changes: 101 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ description and keep ownership on the side listed here.
- `runtime_id` chooses the concrete paired runtime/device/sandbox that will
receive a run. It is a routing handle, not agent configuration.
- `agent_kind` chooses the daemon-side engine (`claude_code`, `codex`,
`pi`, `opencode`). It is interpreted only by `parsar-daemon`.
`pi`, `opencode`, `deepseek_harness`). It is interpreted only by
`parsar-daemon`. The wire value is `snake_case`; the web layer normalizes
dashes and aliases in `apps/web/src/lib/agent-view-model.ts`.
- Placement labels such as local device, cloud sandbox, and external agent
are UI/product concepts. Do not branch business logic on display copy.
Derive placement from typed runtime/provider/config fields in one shared
Expand Down Expand Up @@ -157,9 +159,101 @@ description and keep ownership on the side listed here.
`agent_engine_sessions` and pass `AgentSessionID` plus `AgentStateKey` over
the daemon protocol. Do not keep resume ids only in adapter memory, files
without a server record, or frontend state.
- The server also sends a bounded durable transcript tail for stale-session
recovery. A resume-capable adapter must use it only after the engine
explicitly rejects the stored session id, then return the replacement id;
normal resume must not receive duplicate history.
- 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.
- Engine discovery is one table: `apps/parsar-daemon/internal/cli/agent_cli.go`
owns the per-engine capability descriptor, version probe, and operator
preflight output. Add an engine by extending that table, not by copying
another probe-and-report block.
- The heartbeat capability descriptor states what the adapter actually
delivers. An engine whose only supported automation surface is one-shot
(no event stream, token accounting, resume flag, or approval channel)
advertises none of them and must not synthesize a `done` session id, a fake
usage total, or an auto-approved permission.
- When one engine has two automation surfaces of unequal capability, the
descriptor is computed from the run location, not hardcoded. `dsh` is the
live example: in a sandbox it runs as a resident HTTP server and reports
streaming, usage and resume; on a local device it runs one-shot headless and
reports none. Keep that decision in one function next to the descriptor
table (`deepseekHarnessCapabilities` in `agent_cli.go`), and keep the
adapter's surface choice reading the same predicate, so the advertised
capability and the code path cannot disagree.
- Conversation continuity for an engine that advertises
`Capabilities.Resume=false` is the server's job, not the adapter's: the
connector folds a bounded transcript tail into the system-prompt slot
(`server/internal/connector/agentdaemon/history_injection.go`). Gate that
behaviour on the device's live descriptor rather than a list of engine
names, and keep it bounded — these engines re-send the whole prompt every
turn with no cache reuse. Adapters must not invent their own history.
- When an adapter materializes engine config per prompt, scope that file to
the run and delete it on cleanup if the engine watches its config layers
for live edits. A shared, rewritten-in-place config would re-apply one
run's model onto another run of the same conversation.

### Resident engine servers (`internal/enginehost`)

Some engines expose their full surface — streaming events, approvals,
cross-process session resume — only through a long-lived local server rather
than a one-shot invocation. `apps/parsar-daemon/internal/enginehost` owns that
pattern for every engine. It is engine-agnostic by construction: nothing in it
names a concrete engine or speaks an engine's protocol.

- Adapters must not launch, port-assign, health-check, or reap a resident
engine server themselves. Contribute an `enginehost.ServerSpec` and take an
`Acquire` lease. Adding the second such engine means filling in a spec, not
copying a supervisor.
- `ServerSpec.Key` is the reuse identity, and it must be a state key plus a
fingerprint of everything the engine reads once at boot (model route,
credential env, workspace, binary). Reusing a running server across a
changed route silently runs the turn on the stale one; a changed
fingerprint must yield a different server instead.
- `ServerSpec.StateKey` is the exclusive ownership identity for mutable
engine state. Configuration variants may have different reuse keys, but the
supervisor must drain and stop the old variant before another process with
the same state key starts; two processes must never share a single-writer
session store or rewrite the same generated profile concurrently.
- `Acquire`/`Release` are balanced exactly once per run. The lease keeps the
engine alive; the last release starts the idle clock. Adapters must not
retain a base URL past `Release`, and must not kill the process to cancel a
run — a resident server is shared, so cancellation targets the engine's own
session-cancel call.
- Every resident engine binds `enginehost.LoopbackHost` only. These engines
authenticate nobody: they gate requests on "the peer is on loopback" and
nothing else. That is acceptable only where the loopback namespace is itself
a boundary, which means the sandbox container. Do not open such a port on a
local device, and do not add trusted-host entries.
- Readiness is a protocol probe, not a TCP connect. A bound port only proves
the engine's web-server row came up; the gateway, its transport carrier and
its session store are separate rows, and a profile missing any of them
answers on a listening socket. `ServerSpec.Ready` must call a real method.
- Generated engine config belongs in the engine's own profile layer, written
by `ServerSpec.Prepare` at launch. Do not write it to a layer the engine
watches for live edits — that re-applies one launch's config onto a running
server.
- Managed capabilities for a resident engine stay adapter-owned. The sandbox
DeepSeek Harness adapter materialises archive-backed and inline Markdown
Skills under its state-scoped `DSH_HOME`, translates MCP entries into
`dsh-mcp-client` profile rows, and includes the normalized MCP configuration
in `ServerSpec.Key`. Skill reconciliation may remove only installer-stamped
directories. The local one-shot/headless surface continues to reject Skill
and MCP options because it has no isolated resident-server boundary.
- Resident servers must not outlive the daemon. Adapters acquire from the
process-wide `enginehost` supervisor, whose `Shutdown` is wired once into
daemon teardown; adding an engine must not add another engine-specific
teardown call.
- Sandbox egress proxies are operator configuration, inherited through the
standard upper- and lower-case HTTP proxy variables. Docker sandbox creation
must merge loopback and internal Compose service names into `NO_PROXY`, keep
proxy values out of process arguments, and enable Node's environment-proxy
support for Node-based engines. When the host proxy listens on loopback,
Compose operators use the matching `PARSAR_CONTAINER_*_PROXY` override with
`host.docker.internal`; do not hard-code public DNS or rewrite proxy URLs in
application code.

### Human interaction lifecycle

Expand Down Expand Up @@ -248,6 +342,12 @@ description and keep ownership on the side listed here.
- Eager acquisition must be best-effort. Failure to prewarm a sandbox should
surface as runtime health/provisioning state, not crash unrelated startup
paths.
- A sandbox container may host processes besides `parsar-daemon` when an engine
needs a resident server (see “Resident engine servers”). Such a process is
the daemon's child, bound to loopback, and is never published through the
image's exposed ports or the runtime's port mapping. `IS_SANDBOX` is the
marker that distinguishes this environment from a local device; treat it as
the single predicate rather than sniffing for Docker.
- Cloud sandbox maintenance runs once at server startup and every five minutes.
Automatic renewal requires a TTL longer than that interval; interrupted
renewals remain retryable, while a provider rejection disables the policy.
Expand Down
13 changes: 11 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@
ARG NODE_VERSION=22-alpine
ARG GO_VERSION=1.25-bookworm
ARG RUNTIME_BASE=debian:bookworm-slim
# Full builder references so a host that cannot reach docker.io can point
# them at a mirror, the way RUNTIME_BASE already allows for the final stage.
ARG NODE_IMAGE=node:${NODE_VERSION}
ARG GO_IMAGE=golang:${GO_VERSION}

FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS web-builder
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder
ENV PNPM_HOME=/pnpm
ENV PATH=/pnpm:$PATH
RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
Expand All @@ -65,9 +69,14 @@ RUN pnpm --filter @parsar/web build
# minimal runtime. trimpath strips build-host file paths from the
# binary (defence-in-depth against operator info leaks).
###############################################################################
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-builder
ARG TARGETOS
ARG TARGETARCH
# Overridable module proxy: the default matches Go's own, but a build host
# that cannot reach proxy.golang.org can point at a mirror
# (--build-arg GOPROXY=https://goproxy.cn,direct).
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /src

# Module graph first, source second — keeps `go mod download` cacheable.
Expand Down
176 changes: 7 additions & 169 deletions apps/parsar-daemon/internal/agent/claudecode/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,198 +2,36 @@ package claudecode

import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"

obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log"
"github.com/google/uuid"
"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/skillinstall"
)

// skillDescriptor is the daemon-side view of one server-sent skill entry
// under agent_options["skills"]. Wire-identical to pluginDescriptor.
type skillDescriptor struct {
Name string
Version string
DownloadURL string
SHA256 string
}
type skillDescriptor = skillinstall.Descriptor

// 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.
type SkillInstallResult struct {
Warnings []string
}

// installSkills materialises every skill under
// <workDir>/.claude/skills/<name>/. Pipeline mirrors installPlugins;
// only the target subdir differs (Claude Code auto-registers skills
// from that path).
func installSkills(
ctx context.Context,
logger *slog.Logger,
workDir string,
skills []skillDescriptor,
) (SkillInstallResult, error) {
if logger == nil {
logger = obslog.Bg()
}
if len(skills) == 0 {
return SkillInstallResult{}, nil
}
if strings.TrimSpace(workDir) == "" {
return SkillInstallResult{}, errors.New("claudecode skills: workDir is required")
}

root := filepath.Join(workDir, ".claude", "skills")
if err := os.MkdirAll(root, 0o755); err != nil {
return SkillInstallResult{}, fmt.Errorf("claudecode 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())
continue
}

dir := filepath.Join(root, s.Name)
cacheKey := filepath.Join(dir, ".cache-key")
expectedKey := s.cacheKey()

if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey {
logger.Info("claudecode skills: cache hit",
"name", s.Name, "version", s.Version, "dir", dir)
continue
}

// Same timeout / cap as plugins — they share the install pipeline.
perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout)
err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s)
cancel()
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",
"name", s.Name, "version", s.Version, "err", err.Error())
continue
}
logger.Info("claudecode skills: installed",
"name", s.Name, "version", s.Version, "dir", dir)
return SkillInstallResult{}, fmt.Errorf("claudecode skills: workDir is required")
}
return result, 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
// the path-traversal / TOCTOU / SHA256 defences identical.
func installOneSkill(
ctx context.Context,
logger *slog.Logger,
root, dir, cacheKey, expectedKey string,
s skillDescriptor,
) error {
tmpDir := filepath.Join(root, ".tmp")
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("mkdir tmp: %w", err)
}

zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString()))
defer func() {
_ = os.Remove(zipPath)
}()

fd, err := fetchPluginZip(ctx, s.DownloadURL, zipPath)
result, err := skillinstall.Install(ctx, logger, filepath.Join(workDir, ".claude", "skills"), skills)
if err != nil {
return err
}
defer fd.Close()

if err := verifyPluginSHA256FromFD(fd, s.SHA256); err != nil {
return err
}
if _, err := fd.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("seek: %w", err)
return SkillInstallResult{}, err
}
fi, err := fd.Stat()
if err != nil {
return fmt.Errorf("stat: %w", err)
}

if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("rm old dir: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir target: %w", err)
}
if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil {
_ = os.RemoveAll(dir)
return err
}

if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil {
logger.Warn("claudecode skills: write cache key failed",
"path", cacheKey, "err", err.Error())
}
return nil
return SkillInstallResult{Warnings: result.Warnings}, nil
}

// decodeSkillDescriptors converts agent_options["skills"] into typed
// descriptors. Mirrors decodePluginDescriptors.
func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) {
if raw == nil {
return nil, nil
}
items, ok := raw.([]any)
if !ok {
return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)}
}
out := make([]skillDescriptor, 0, len(items))
warnings := make([]string, 0)
for i, item := range items {
obj, ok := item.(map[string]any)
if !ok {
warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i))
continue
}
s := skillDescriptor{
Name: stringField(obj, "name"),
Version: stringField(obj, "version"),
DownloadURL: stringField(obj, "download_url"),
SHA256: stringField(obj, "sha256"),
}
if err := s.validate(); err != nil {
warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err))
continue
}
out = append(out, s)
}
return out, warnings
}

func (s skillDescriptor) validate() error {
if strings.TrimSpace(s.Name) == "" {
return errors.New("name is required")
}
if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." {
return fmt.Errorf("name %q contains path separator or dot-ref", s.Name)
}
if strings.TrimSpace(s.DownloadURL) == "" {
return errors.New("download_url is required")
}
if len(s.SHA256) != 64 {
return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256))
}
return nil
}

func (s skillDescriptor) cacheKey() string {
return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256))
return skillinstall.Decode(raw)
}
22 changes: 22 additions & 0 deletions apps/parsar-daemon/internal/agent/claudecode/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ func TestInstallSkills_EmptyListIsNoop(t *testing.T) {
}
}

func TestInstallSkills_InlineMarkdown(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
content := "---\nname: inline\ndescription: inline\n---\nBody\n"
res, err := installSkills(context.Background(), discardLogger(), workDir, []skillDescriptor{
{Name: "inline", Version: "1.0.0", Content: content},
})
if err != nil {
t.Fatalf("inline install: %v", err)
}
if len(res.Warnings) != 0 {
t.Fatalf("warnings = %v", res.Warnings)
}
got, err := os.ReadFile(filepath.Join(workDir, ".claude", "skills", "inline", "SKILL.md"))
if err != nil {
t.Fatalf("read SKILL.md: %v", err)
}
if string(got) != content {
t.Fatalf("SKILL.md = %q, want %q", got, content)
}
}

func TestDecodeSkillDescriptors_ArrayShape(t *testing.T) {
raw := []any{
map[string]any{
Expand Down
Loading
Loading