From b1d73da7583573978ff958abff770489f99f971b Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 19 Aug 2026 22:44:20 +0800 Subject: [PATCH 1/6] feat(runtime): add DeepSeek Harness engine and no-resume history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds agent_kind="deepseek_harness" as a fifth daemon engine, driven through the harness's only supported automation surface, `dsh --profile headless `: one prompt is one fresh dsh process that prints the final assistant text and exits. Verified against a real dsh install (0.1.0-rc.6 local, 0.1.0-rc.7 in an isolated prefix): the headless app takes a task positional only, its runner mints a random session id per invocation, and a second run in the same DSH_HOME/workspace has no memory of the first. The engine therefore advertises no streaming, usage, resume or permission capability, and the adapter never synthesizes a session id. Because that leaves every turn contextless, the server now injects a bounded transcript tail into the system-prompt slot for any engine whose heartbeat descriptor reports Capabilities.Resume=false — deepseek_harness and opencode today. Gating on the live descriptor rather than an engine name list means an engine that gains resume support stops receiving a duplicate transcript as soon as it advertises it. Daemon adapter: - state under ~/.parsar keyed by AgentStateKey (DSH_HOME), run-scoped `--patch` overlay deleted on cleanup, secrets only ever in env - managed models arrive as a declared llm-pi-ai route, reusing the pi wire-protocol mapping (both embed pi-ai) - unattended runs declare and select a permission preset pairing workspace-write with approval:never; patching the approval row alone fails dsh boot with "composed sandbox and approval defaults match no preset" and is re-pinned per session anyway Refactors pulled in by the new engine: - engine preflight moves from four hand-written probe blocks in connect.go to one table in agent_cli.go (identical operator output) - the AgentStateKey path sanitizer moves to internal/paths, shared with pi - the duplicated formatAgentKindLabel in RuntimePage and LocalDeviceRuntimesPanel becomes agentKindDisplayName in lib/ Also threads the trigger message id through PromptInput so the injected transcript excludes the task the run is already carrying, and pins DSH_VERSION in the sandbox image. --- CONTRIBUTING.md | 24 +- .../agent/deepseekharness/export_test.go | 49 ++++ .../internal/agent/deepseekharness/options.go | 175 +++++++++++ .../agent/deepseekharness/options_test.go | 276 +++++++++++++++++ .../internal/agent/deepseekharness/parser.go | 86 ++++++ .../agent/deepseekharness/parser_test.go | 94 ++++++ .../agent/deepseekharness/patch_config.go | 277 ++++++++++++++++++ .../deepseekharness/patch_config_test.go | 148 ++++++++++ .../internal/agent/deepseekharness/session.go | 192 ++++++++++++ .../agent/deepseekharness/session_test.go | 277 ++++++++++++++++++ .../internal/agent/deepseekharness/version.go | 31 ++ .../agent/deepseekharness/version_test.go | 18 ++ .../internal/agent/pi/provider_config.go | 29 +- apps/parsar-daemon/internal/cli/agent_cli.go | 211 +++++++++++++ .../internal/cli/agent_cli_test.go | 269 +++++++++++++++++ apps/parsar-daemon/internal/cli/connect.go | 156 ---------- .../internal/cli/connect_test.go | 202 ------------- apps/parsar-daemon/internal/paths/statekey.go | 37 +++ apps/web/src/i18n/locales/en-US/admin.json | 5 + apps/web/src/i18n/locales/zh-CN/admin.json | 5 + apps/web/src/lib/agent-view-model.ts | 32 +- .../web/src/pages/admin/CreateAgentDialog.tsx | 22 +- apps/web/src/pages/admin/RuntimePage.tsx | 17 +- .../runtimes/LocalDeviceRuntimesPanel.tsx | 21 +- docs/openapi/openapi.yaml | 7 + docs/spec-memory-module.md | 2 +- infra/sandbox/Dockerfile | 9 +- infra/sandbox/scripts/install-agents.sh | 12 +- server/cmd/server/main.go | 29 +- .../capability/render/deepseekharness.go | 37 +++ server/internal/capability/render/renderer.go | 13 +- .../capability/render/renderer_test.go | 5 +- .../capability_runtime_dispatch_test.go | 1 + .../connector/agentdaemon/connector.go | 91 +++--- .../agentdaemon/history_injection.go | 182 ++++++++++++ .../agentdaemon/history_injection_test.go | 248 ++++++++++++++++ .../connector/agentdaemon/model_injection.go | 12 + .../agentdaemon/model_injection_deepseek.go | 77 +++++ .../model_injection_deepseek_test.go | 213 ++++++++++++++ .../connector/agentdaemon/sandbox_seed.go | 16 +- .../agentdaemon/sandbox_seed_test.go | 27 +- server/internal/connector/types.go | 10 +- server/internal/db/queries/store.sql | 23 ++ server/internal/db/sqlc/store.sql.go | 65 ++++ server/internal/dev/run_stream.go | 1 + server/internal/store/conversation_history.go | 52 ++++ .../store/conversation_history_test.go | 99 +++++++ server/internal/store/store.go | 25 +- 48 files changed, 3393 insertions(+), 516 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/export_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/options.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/options_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/parser.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/session.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/session_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/version.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/version_test.go create mode 100644 apps/parsar-daemon/internal/cli/agent_cli.go create mode 100644 apps/parsar-daemon/internal/cli/agent_cli_test.go create mode 100644 apps/parsar-daemon/internal/paths/statekey.go create mode 100644 server/internal/capability/render/deepseekharness.go create mode 100644 server/internal/connector/agentdaemon/history_injection.go create mode 100644 server/internal/connector/agentdaemon/history_injection_test.go create mode 100644 server/internal/connector/agentdaemon/model_injection_deepseek.go create mode 100644 server/internal/connector/agentdaemon/model_injection_deepseek_test.go create mode 100644 server/internal/store/conversation_history.go create mode 100644 server/internal/store/conversation_history_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84d458c3..596836aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -160,6 +162,26 @@ 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. +- 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 — + `deepseek_harness` today) advertises none of them and must not synthesize a + `done` session id, a fake usage total, or an auto-approved permission. +- 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. ### Human interaction lifecycle diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/export_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/export_test.go new file mode 100644 index 00000000..c485a92d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/export_test.go @@ -0,0 +1,49 @@ +package deepseekharness + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + Binary string + ExtraArgs []string + KillTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + binary: cfg.Binary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + }) +} + +type Translator translator + +func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } + +func (t *Translator) AppendLine(line string) { (*translator)(t).appendLine(line) } + +func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) +} + +func RenderPatchForTest(raw any, model, provider string) ([]byte, error) { + cfg, hasProvider, err := normaliseProvider(raw) + if err != nil { + return nil, err + } + return renderPatch(cfg, hasProvider, model, provider) +} + +func ResolveHomeForTest(agentStateKey, conversationID, runID string) (string, error) { + return resolveHome(agentStateKey, conversationID, runID) +} + +const ( + HomeEnvVarForTest = dshHomeEnvVar + ManagedRouteForTest = managedRoute +) diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/options.go b/apps/parsar-daemon/internal/agent/deepseekharness/options.go new file mode 100644 index 00000000..41e64071 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/options.go @@ -0,0 +1,175 @@ +package deepseekharness + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "sort" + "strings" +) + +// BuildResult is the dsh CLI launch plan for one prompt. Cleanup is +// always non-nil so callers can defer it blindly. +type BuildResult struct { + Args []string + Env []string + WorkDir string + Cleanup func() +} + +// StateKeys carries the identifiers the adapter derives its DSH_HOME and +// per-run patch overlay from. +type StateKeys struct { + AgentStateKey string + ConversationID string + RunID string +} + +// BuildArgs translates the daemon prompt_request into a +// `dsh --profile headless ` invocation. +func BuildArgs(prompt, workDir string, opts map[string]any, state StateKeys) (BuildResult, error) { + result := BuildResult{Cleanup: func() {}} + + resolvedWorkDir, err := resolveWorkDir(workDir) + if err != nil { + return result, err + } + + // dsh headless takes the task as one positional argument and offers + // no --system-prompt flag, so an injected system prompt is prepended + // to the task text (same as the opencode adapter). + promptText, err := buildPrompt(prompt, opts) + if err != nil { + return result, err + } + + provider, hasProvider, err := normaliseProvider(opts["dsh_provider"]) + if err != nil { + return result, err + } + home, err := resolveHome(state.AgentStateKey, state.ConversationID, state.RunID) + if err != nil { + return result, err + } + if err := os.MkdirAll(home, 0o700); err != nil { + return result, fmt.Errorf("deepseekharness: mkdir dsh home %s: %w", home, err) + } + patchPath, cleanup, err := writeRuntimePatch(home, state.RunID, provider, hasProvider, + stringOpt(opts, "model"), stringOpt(opts, "provider")) + if err != nil { + return result, err + } + + args := []string{"--profile", headlessProfile, "--patch", patchPath} + // The launcher consumes one `--`, so everything after it reaches the + // headless app verbatim — a task starting with a dash included. + args = append(args, "--", promptText) + + envOpt, err := envMap(opts["env"]) + if err != nil { + cleanup() + return result, err + } + // Assigned after the caller's env is copied so agent_options cannot + // redirect the state root, widen the file-effect boundary, or turn + // telemetry back on for an unattended run. + envOpt[dshHomeEnvVar] = home + envOpt[dshPermissionModeEnvVar] = sandboxPermissionMode + envOpt[dshTelemetryDisabledEnvVar] = "1" + env, err := buildEnv(envOpt) + if err != nil { + cleanup() + return result, err + } + + result.Args = args + result.Env = env + result.WorkDir = resolvedWorkDir + result.Cleanup = cleanup + return result, nil +} + +func resolveWorkDir(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("deepseekharness: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("deepseekharness: work_dir must be absolute or start with ~/, got %q", trimmed) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("deepseekharness: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func buildPrompt(prompt string, opts map[string]any) (string, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "", fmt.Errorf("deepseekharness: empty prompt") + } + systemPrompt := stringOpt(opts, "system_prompt") + if override := stringOpt(opts, "override_system_prompt"); override != "" { + systemPrompt = override + } + if systemPrompt == "" { + return prompt, nil + } + return systemPrompt + "\n\n" + prompt, nil +} + +func envMap(raw any) (map[string]any, error) { + if raw == nil { + return map[string]any{}, nil + } + m, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("deepseekharness.BuildArgs: env must be object, got %T", raw) + } + out := make(map[string]any, len(m)+1) + maps.Copy(out, m) + return out, nil +} + +func buildEnv(envOpt map[string]any) ([]string, error) { + env := make([]string, 0, len(envOpt)) + keys := make([]string, 0, len(envOpt)) + for k := range envOpt { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envOpt[k].(string) + if !ok { + return nil, fmt.Errorf("deepseekharness.BuildArgs: env[%q] must be string, got %T", k, envOpt[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/options_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/options_test.go new file mode 100644 index 00000000..47851d58 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/options_test.go @@ -0,0 +1,276 @@ +package deepseekharness_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" +) + +func stateKeys(runID string) deepseekharness.StateKeys { + return deepseekharness.StateKeys{AgentStateKey: "conv1/agent1/deepseek_harness", RunID: runID} +} + +func TestBuildArgsUsesHeadlessProfileAndTaskLast(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", os.TempDir(), nil, stateKeys("run-1")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + if !containsPair(res.Args, "--profile", "headless") { + t.Fatalf("args missing --profile headless: %v", res.Args) + } + // The launcher consumes one `--`, so the task must be the final arg + // directly behind it or a task starting with a dash is parsed as a + // launcher flag. + n := len(res.Args) + if n < 2 || res.Args[n-2] != "--" || res.Args[n-1] != "hello" { + t.Fatalf("expected args to end with -- hello, got %v", res.Args) + } + if res.WorkDir != os.TempDir() { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, os.TempDir()) + } +} + +func TestBuildArgsWritesRunScopedPatchCleanedUp(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", nil, stateKeys("run-patch")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + patchPath := flagValue(res.Args, "--patch") + if patchPath == "" { + t.Fatalf("args missing --patch: %v", res.Args) + } + body, err := os.ReadFile(patchPath) + if err != nil { + t.Fatalf("read patch: %v", err) + } + // A daemon run has no approval answerer, so the overlay must always + // select the unattended permission preset even without a managed model. + if !strings.Contains(string(body), "id: permission") || !strings.Contains(string(body), "defaultPreset: parsar-unattended") { + t.Fatalf("patch missing permission preset override:\n%s", body) + } + res.Cleanup() + if _, err := os.Stat(patchPath); !os.IsNotExist(err) { + t.Fatalf("patch file must be removed by Cleanup, stat err = %v", err) + } +} + +func TestBuildArgsPinsDshHomeUnderParsarRoot(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "env": map[string]any{"DSH_HOME": "/tmp/attacker"}, + }, stateKeys("run-home")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + home := envValue(res.Env, deepseekharness.HomeEnvVarForTest) + if !strings.HasPrefix(home, root) { + t.Fatalf("DSH_HOME = %q, want a path under %q", home, root) + } + if strings.Contains(home, "attacker") { + t.Fatalf("adapter DSH_HOME must win over agent_options env: %q", home) + } + info, err := os.Stat(home) + if err != nil || !info.IsDir() { + t.Fatalf("DSH_HOME %q not created: err=%v", home, err) + } +} + +func TestBuildArgsSystemPromptPrependsToTask(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "system_prompt": "be terse", + }, stateKeys("run-sys")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + task := res.Args[len(res.Args)-1] + if task != "be terse\n\nhello" { + t.Fatalf("task = %q, want system prompt prepended", task) + } +} + +func TestBuildArgsOverrideSystemPromptWins(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "system_prompt": "be terse", + "override_system_prompt": "you are root", + }, stateKeys("run-override")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + task := res.Args[len(res.Args)-1] + if task != "you are root\n\nhello" { + t.Fatalf("task = %q, want override prepended", task) + } +} + +func TestBuildArgsKeepsSecretsOffArgv(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "dsh_provider": map[string]any{ + "base_url": "https://gw.example/v1", + "api": "openai-completions", + "api_key_env": "PARSAR_DSH_API_KEY", + "model": "deepseek-v4", + }, + "env": map[string]any{"PARSAR_DSH_API_KEY": "sk-secret"}, + }, stateKeys("run-secret")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if slices.Contains(res.Args, "sk-secret") { + t.Fatalf("api key must not reach argv: %v", res.Args) + } + if envValue(res.Env, "PARSAR_DSH_API_KEY") != "sk-secret" { + t.Fatalf("api key must ride the environment: %v", res.Env) + } + body, err := os.ReadFile(flagValue(res.Args, "--patch")) + if err != nil { + t.Fatalf("read patch: %v", err) + } + if strings.Contains(string(body), "sk-secret") { + t.Fatalf("patch overlay must reference the env var, not the key:\n%s", body) + } +} + +// The telemetry opt-out and the file-effect boundary are adapter policy for +// an unattended run, so agent_options must not be able to widen either. +func TestBuildArgsForcesTelemetryOptOutAndPermissionMode(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "env": map[string]any{ + "DSH_TELEMETRY_DISABLED": "", + "DSH_PERMISSION_MODE": "danger-full-access", + }, + }, stateKeys("run-telemetry")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if got := envValue(res.Env, "DSH_TELEMETRY_DISABLED"); got != "1" { + t.Fatalf("DSH_TELEMETRY_DISABLED = %q, want the forced opt-out; env=%v", got, res.Env) + } + if got := envValue(res.Env, "DSH_PERMISSION_MODE"); got != "workspace-write" { + t.Fatalf("DSH_PERMISSION_MODE = %q, want workspace-write; env=%v", got, res.Env) + } + // A single entry per key: cmd.Env resolves duplicates to the last one, + // so a caller copy left in place could still win. + if n := envCount(res.Env, "DSH_PERMISSION_MODE"); n != 1 { + t.Fatalf("DSH_PERMISSION_MODE appears %d times, want exactly 1: %v", n, res.Env) + } +} + +func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.BuildArgs("hello", "./relative", nil, stateKeys("run-rel")) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) + } +} + +func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + res, err := deepseekharness.BuildArgs("hello", target, nil, stateKeys("run-mkdir")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + info, err := os.Stat(target) + if err != nil || !info.IsDir() { + t.Fatalf("work dir %q not created: err=%v", target, err) + } +} + +func TestBuildArgsRejectsEmptyPrompt(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.BuildArgs(" ", "", nil, stateKeys("run-empty")) + if err == nil || !strings.Contains(err.Error(), "prompt") { + t.Fatalf("BuildArgs empty prompt err = %v, want prompt error", err) + } +} + +func TestBuildArgsRejectsBadEnvShape(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "env": map[string]any{"K": 1}, + }, stateKeys("run-badenv")) + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("BuildArgs env err = %v, want env shape error", err) + } +} + +func TestResolveHomeIsStablePerStateKey(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + first, err := deepseekharness.ResolveHomeForTest("conv1/agent1/deepseek_harness", "conv1", "run-a") + if err != nil { + t.Fatalf("ResolveHomeForTest: %v", err) + } + second, err := deepseekharness.ResolveHomeForTest("conv1/agent1/deepseek_harness", "conv1", "run-b") + if err != nil { + t.Fatalf("ResolveHomeForTest: %v", err) + } + if first != second { + t.Fatalf("home must be stable across runs of one state key: %q vs %q", first, second) + } + traversal, err := deepseekharness.ResolveHomeForTest("../../etc/passwd", "", "run-c") + if err != nil { + t.Fatalf("ResolveHomeForTest traversal: %v", err) + } + if strings.Contains(traversal, "..") { + t.Fatalf("state key must not escape the root: %q", traversal) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func flagValue(args []string, flag string) string { + for i, a := range args { + if a == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func envCount(env []string, key string) int { + prefix := key + "=" + count := 0 + for _, item := range env { + if strings.HasPrefix(item, prefix) { + count++ + } + } + return count +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if v, ok := strings.CutPrefix(item, prefix); ok { + return v + } + } + return "" +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/parser.go b/apps/parsar-daemon/internal/agent/deepseekharness/parser.go new file mode 100644 index 00000000..ead784a1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/parser.go @@ -0,0 +1,86 @@ +package deepseekharness + +import ( + "fmt" + "strings" + "sync/atomic" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// usageProvider tags the done frame so downstream usage attribution can +// tell a dsh run apart even though the CLI reports no token counts. +const usageProvider = "deepseek-harness" + +// translator collects the headless run's stdout. `dsh --profile headless` +// prints the final assistant text and nothing else — no event stream — so +// the whole answer is buffered and emitted as one delta before done. +type translator struct { + runID string + seq atomic.Uint64 + + answer strings.Builder +} + +func newTranslator(runID string) *translator { return &translator{runID: runID} } + +func (t *translator) appendLine(line string) { + if t.answer.Len() > 0 { + t.answer.WriteByte('\n') + } + t.answer.WriteString(line) +} + +func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + var envs []proto.Envelope + content := strings.TrimSpace(t.answer.String()) + if content != "" { + if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: content, + Sequence: t.seq.Add(1), + }); err == nil { + envs = append(envs, env) + } + } + if waitErr != nil || cancelled { + if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{ + Error: terminalErrorMessage(waitErr, stderr, cancelled), + }); err == nil { + envs = append(envs, env) + } + } + usage := proto.Usage{Provider: usageProvider} + if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ + Content: content, + Transcript: content, + Usage: usage, + Metadata: map[string]any{"connector_path": "dsh_headless"}, + }); err == nil { + envs = append(envs, env) + } + return envs +} + +// terminalErrorMessage folds the exit status and stderr into one message. +// dsh exits non-zero for any turn that did not complete and writes the +// durable error code plus message to stderr, so stderr is the useful part. +func terminalErrorMessage(waitErr error, stderr string, cancelled bool) string { + if cancelled { + return "deepseek-harness: cancelled" + } + msg := "deepseek-harness: dsh exited without completing the turn" + if waitErr != nil { + msg = fmt.Sprintf("deepseek-harness: dsh exited: %v", waitErr) + } + if trimmed := strings.TrimSpace(stderr); trimmed != "" { + msg += ": " + truncate(trimmed, 400) + } + return msg +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go new file mode 100644 index 00000000..6f8281a5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go @@ -0,0 +1,94 @@ +package deepseekharness_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func decodePayload[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} + +func TestTerminalEnvelopesSuccessEmitsDeltaThenDone(t *testing.T) { + tr := deepseekharness.NewTranslatorForTest("run-1") + tr.AppendLine("first line") + tr.AppendLine("second line") + + envs := tr.TerminalEnvelopes(nil, "", false) + if len(envs) != 2 { + t.Fatalf("expected delta+done, got %d: %v", len(envs), envTypes(envs)) + } + if envs[0].Type != proto.TypeDelta || envs[1].Type != proto.TypeDone { + t.Fatalf("types = %v", envTypes(envs)) + } + delta := decodePayload[proto.DeltaPayload](t, envs[0]) + if delta.Delta != "first line\nsecond line" { + t.Fatalf("delta = %q", delta.Delta) + } + done := decodePayload[proto.DonePayload](t, envs[1]) + if done.Content != "first line\nsecond line" { + t.Fatalf("done content = %q", done.Content) + } + // dsh headless creates a fresh session per run and prints no session + // id, so the server must not be handed a resume handle. + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("done metadata must carry no session id: %#v", done.Metadata) + } + if done.Usage.Provider != "deepseek-harness" { + t.Fatalf("done usage = %#v", done.Usage) + } + for _, env := range envs { + if env.ID != "run-1" { + t.Fatalf("env %s ID = %q, want run-1", env.Type, env.ID) + } + } +} + +func TestTerminalEnvelopesFailureFoldsStderr(t *testing.T) { + tr := deepseekharness.NewTranslatorForTest("run-2") + envs := tr.TerminalEnvelopes(errors.New("exit status 1"), "MODEL_ERROR: upstream refused", false) + types := envTypes(envs) + if len(envs) != 2 || envs[0].Type != proto.TypeError || envs[1].Type != proto.TypeDone { + t.Fatalf("types = %v, want error+done", types) + } + payload := decodePayload[proto.ErrorPayload](t, envs[0]) + if !strings.Contains(payload.Error, "exit status 1") || !strings.Contains(payload.Error, "upstream refused") { + t.Fatalf("error payload = %q", payload.Error) + } +} + +func TestTerminalEnvelopesCancelledReportsCancellation(t *testing.T) { + tr := deepseekharness.NewTranslatorForTest("run-3") + tr.AppendLine("partial") + envs := tr.TerminalEnvelopes(errors.New("signal: terminated"), "", true) + if envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("last env = %v, want done", envTypes(envs)) + } + var errPayload proto.ErrorPayload + for _, env := range envs { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "cancelled") { + t.Fatalf("error payload = %q, want cancelled", errPayload.Error) + } +} + +func envTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, env := range envs { + out[i] = env.Type + } + return out +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go new file mode 100644 index 00000000..6fe95e10 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go @@ -0,0 +1,277 @@ +package deepseekharness + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "gopkg.in/yaml.v3" +) + +const ( + // dshHomeEnvVar is dsh's only override for its state root: it holds + // profiles/, sessions/, settings.yaml and .credentials.yaml. Parsar + // pins it under ~/.parsar so a run never writes into the repo + // checkout or the daemon's CWD. + dshHomeEnvVar = "DSH_HOME" + + // dshPermissionModeEnvVar drives dsh's sandbox-policy row. Pinning it + // keeps the file-effect boundary independent of what a developer-preview + // release ships as its default. + dshPermissionModeEnvVar = "DSH_PERMISSION_MODE" + + // sandboxPermissionMode keeps bash and filesystem mutations inside the + // run's workspace. Parsar runs dsh unattended, so a wider mode would + // let a turn write outside the directory the operator bound. + sandboxPermissionMode = "workspace-write" + + // unattendedPreset is the permission preset the overlay declares and + // selects: workspace-scoped writes with approval prompts off. + unattendedPreset = "parsar-unattended" + + // dshTelemetryDisabledEnvVar is dsh's authoritative hard opt-out: any + // non-empty value wins over the composed telemetry row. + dshTelemetryDisabledEnvVar = "DSH_TELEMETRY_DISABLED" + + // headlessProfile is the shipped one-shot profile (dsh-base + + // dsh-headless). It auto-initialises from the installation template + // on first use, so a fresh DSH_HOME needs no provisioning step. + headlessProfile = "headless" + + // managedRoute is the llm-pi-ai provider route the daemon + // materialises for a Parsar-managed model, mirroring the "parsar" + // slug the codex and pi adapters already use. + managedRoute = "parsar" + + // shippedProviderRoute is dsh-base's own DeepSeek route. It is the + // provider a model-only agent_options selection has to name, because + // agent-default-model.provider must match a live llm route. + shippedProviderRoute = "deepseek-official" +) + +// providerConfig is the normalised form of agent_options["dsh_provider"], +// which the server emits for a Parsar-managed model. +type providerConfig struct { + Name string + BaseURL string + API string + APIKeyEnv string + Model string + Headers map[string]string +} + +type patchRow struct { + ID string `yaml:"id"` + Config any `yaml:"config"` +} + +type piAiConfig struct { + Providers map[string]piAiRoute `yaml:"providers"` +} + +// piAiRoute is one llm-pi-ai provider profile. The field set is dsh's, not +// pi's: apiKeyEnv is a bare env-var name (pi's models.json needs a "$NAME" +// template instead), and there is no auth-header knob because the adapter +// hands the resolved key to pi-ai, whose provider owns the wire auth form. +type piAiRoute struct { + DisplayName string `yaml:"displayName,omitempty"` + APIKeyEnv string `yaml:"apiKeyEnv"` + API string `yaml:"api"` + BaseURL string `yaml:"baseURL"` + Headers map[string]string `yaml:"headers,omitempty"` + Models []piAiModel `yaml:"models"` +} + +type piAiModel struct { + ID string `yaml:"id"` +} + +type defaultModelConfig struct { + Provider string `yaml:"provider"` + Model string `yaml:"model"` +} + +// permissionConfig replaces dsh's permission-preset table. The sandbox mode +// and approval policy cannot be patched independently: dsh validates the +// composed pair against this table (an unmatched pair fails boot with +// "match no preset") and re-pins both knobs from defaultPreset every time a +// session is created, so the unattended pairing has to arrive as a preset. +type permissionConfig struct { + Presets map[string]permissionPreset `yaml:"presets"` + DefaultPreset string `yaml:"defaultPreset"` +} + +type permissionPreset struct { + Sandbox string `yaml:"sandbox"` + Approval string `yaml:"approval"` + Name string `yaml:"name,omitempty"` + Description string `yaml:"description,omitempty"` +} + +// renderPatch builds the `--patch` overlay for one prompt. The overlay is +// the last layer dsh applies, and a patch replaces the addressed row's +// whole config rather than merging into it. +func renderPatch(cfg providerConfig, hasProvider bool, model, provider string) ([]byte, error) { + // A daemon run has no human answerer for dsh's approval seam, so the + // shipped `ask` policy would stall every tool call that asks. Writes + // still stay inside the run's workspace. + rows := []patchRow{{ + ID: "permission", + Config: permissionConfig{ + DefaultPreset: unattendedPreset, + Presets: map[string]permissionPreset{ + unattendedPreset: { + Sandbox: sandboxPermissionMode, + Approval: "never", + Name: "Parsar unattended", + Description: "Workspace-scoped writes with no approval prompts.", + }, + }, + }, + }} + + switch { + case hasProvider: + if err := validateProvider(cfg); err != nil { + return nil, err + } + // Replacing the llm-pi-ai row's config drops nothing: dsh-base + // mounts that adapter dormant with no config of its own, and + // routes come from whichever layer supplies them. + rows = append(rows, + patchRow{ID: "llm-pi-ai", Config: piAiConfig{Providers: map[string]piAiRoute{ + managedRoute: { + DisplayName: cfg.Name, + APIKeyEnv: cfg.APIKeyEnv, + API: cfg.API, + BaseURL: cfg.BaseURL, + Headers: cfg.Headers, + Models: []piAiModel{{ID: cfg.Model}}, + }, + }}}, + patchRow{ID: "agent-default-model", Config: defaultModelConfig{ + Provider: managedRoute, + Model: cfg.Model, + }}, + ) + case model != "": + route := provider + if route == "" { + route = shippedProviderRoute + } + rows = append(rows, patchRow{ID: "agent-default-model", Config: defaultModelConfig{ + Provider: route, + Model: model, + }}) + } + + body, err := yaml.Marshal(rows) + if err != nil { + return nil, fmt.Errorf("deepseekharness: marshal patch overlay: %w", err) + } + return body, nil +} + +func validateProvider(cfg providerConfig) error { + // A route pi-ai does not ship must declare api, baseURL and a + // non-empty model list or dsh refuses the whole profile at boot. + if strings.TrimSpace(cfg.BaseURL) == "" { + return fmt.Errorf("deepseekharness: provider base_url is required") + } + if strings.TrimSpace(cfg.API) == "" { + return fmt.Errorf("deepseekharness: provider api is required") + } + if strings.TrimSpace(cfg.APIKeyEnv) == "" { + return fmt.Errorf("deepseekharness: provider api_key_env is required") + } + if strings.TrimSpace(cfg.Model) == "" { + return fmt.Errorf("deepseekharness: provider model is required") + } + return nil +} + +// normaliseProvider flattens agent_options["dsh_provider"] into a typed +// providerConfig. hasProvider=false means the key was absent, so the run +// falls back to whatever credentials and model dsh resolves itself. +func normaliseProvider(raw any) (providerConfig, bool, error) { + if raw == nil { + return providerConfig{}, false, nil + } + m, ok := raw.(map[string]any) + if !ok { + return providerConfig{}, false, fmt.Errorf("deepseekharness: dsh_provider must be object, got %T", raw) + } + cfg := providerConfig{ + Name: stringOpt(m, "name"), + BaseURL: stringOpt(m, "base_url"), + API: stringOpt(m, "api"), + APIKeyEnv: stringOpt(m, "api_key_env"), + Model: stringOpt(m, "model"), + } + if headers, ok := m["headers"].(map[string]any); ok { + cfg.Headers = make(map[string]string, len(headers)) + for k, v := range headers { + if s, ok := v.(string); ok { + cfg.Headers[k] = s + } + } + } + return cfg, true, nil +} + +// resolveHome returns the DSH_HOME for this prompt. AgentStateKey is +// preferred because it scopes by conversation, agent and engine; +// conversation/run fallbacks exist for older callers and tests. +// +// One home is shared by every run of a state key so the profile is +// initialised once and session logs stay grouped per conversation. Two +// concurrent first runs of the same key therefore both trigger dsh's +// first-use profile initialisation; sequential turns are the normal case +// and a per-run home would re-provision the profile on every prompt. +func resolveHome(agentStateKey, conversationID, runID string) (string, error) { + root, err := paths.Root() + if err != nil { + return "", fmt.Errorf("deepseekharness: resolve state root: %w", err) + } + base := filepath.Join(root, "runtime", "deepseek-harness") + if key := strings.TrimSpace(agentStateKey); key != "" { + parts := paths.StateKeyParts(key) + if len(parts) == 0 { + return "", fmt.Errorf("deepseekharness: invalid agentStateKey %q", agentStateKey) + } + dirParts := append([]string{base, "state"}, parts...) + return filepath.Join(append(dirParts, "home")...), nil + } + if id := strings.TrimSpace(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "home"), nil + } + return filepath.Join(base, "run-"+strings.TrimSpace(runID), "home"), nil +} + +// writeRuntimePatch materialises the overlay for one run and returns its +// path plus a cleanup that removes it. The file is run-scoped rather than +// written to $DSH_HOME/cordis.patch.yml because dsh watches the home +// layer for live edits: a concurrent run of the same conversation would +// otherwise re-apply its own model onto an already-booted process. +func writeRuntimePatch(home, runID string, cfg providerConfig, hasProvider bool, model, provider string) (string, func(), error) { + noop := func() {} + body, err := renderPatch(cfg, hasProvider, model, provider) + if err != nil { + return "", noop, err + } + dir := filepath.Join(home, "patches") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", noop, fmt.Errorf("deepseekharness: mkdir patch dir %s: %w", dir, err) + } + name := paths.SafePathPart(runID) + if name == "" { + name = "run" + } + path := filepath.Join(dir, name+".patch.yml") + if err := os.WriteFile(path, body, 0o600); err != nil { + return "", noop, fmt.Errorf("deepseekharness: write %s: %w", path, err) + } + return path, func() { _ = os.Remove(path) }, nil +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go new file mode 100644 index 00000000..66b432fd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go @@ -0,0 +1,148 @@ +package deepseekharness_test + +import ( + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "gopkg.in/yaml.v3" +) + +type patchRow struct { + ID string `yaml:"id"` + Config map[string]any `yaml:"config"` +} + +func decodeRows(t *testing.T, body []byte) map[string]map[string]any { + t.Helper() + var rows []patchRow + if err := yaml.Unmarshal(body, &rows); err != nil { + t.Fatalf("unmarshal patch: %v\n%s", err, body) + } + out := make(map[string]map[string]any, len(rows)) + for _, row := range rows { + out[row.ID] = row.Config + } + return out +} + +func TestRenderPatchManagedProviderDeclaresRoute(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(map[string]any{ + "name": "Parsar Gateway", + "base_url": "https://gw.example/v1", + "api": "openai-completions", + "api_key_env": "PARSAR_DSH_API_KEY", + "model": "deepseek-v4", + "headers": map[string]any{"X-Sub-Module": "parsar"}, + }, "deepseek-v4", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + + providers, ok := rows["llm-pi-ai"]["providers"].(map[string]any) + if !ok { + t.Fatalf("llm-pi-ai row missing providers: %#v", rows["llm-pi-ai"]) + } + route, ok := providers[deepseekharness.ManagedRouteForTest].(map[string]any) + if !ok { + t.Fatalf("missing managed route: %#v", providers) + } + // A route pi-ai does not ship is refused unless api, baseURL and a + // non-empty models list are all declared. + if route["api"] != "openai-completions" || route["baseURL"] != "https://gw.example/v1" { + t.Fatalf("route transport fields = %#v", route) + } + if route["apiKeyEnv"] != "PARSAR_DSH_API_KEY" { + t.Fatalf("route must reference the key env var: %#v", route) + } + models, ok := route["models"].([]any) + if !ok || len(models) != 1 { + t.Fatalf("route models = %#v", route["models"]) + } + + defaultModel := rows["agent-default-model"] + if defaultModel["provider"] != deepseekharness.ManagedRouteForTest || defaultModel["model"] != "deepseek-v4" { + t.Fatalf("agent-default-model = %#v", defaultModel) + } +} + +func TestRenderPatchModelOnlyKeepsShippedRoute(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(nil, "deepseek-v4-pro", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + if _, ok := rows["llm-pi-ai"]; ok { + t.Fatalf("no managed provider means no llm-pi-ai row: %#v", rows) + } + if rows["agent-default-model"]["provider"] != "deepseek-official" { + t.Fatalf("agent-default-model = %#v", rows["agent-default-model"]) + } + if rows["agent-default-model"]["model"] != "deepseek-v4-pro" { + t.Fatalf("agent-default-model = %#v", rows["agent-default-model"]) + } +} + +func TestRenderPatchWithoutModelOnlyPinsPermissionPreset(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(nil, "", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + if len(rows) != 1 { + t.Fatalf("expected only the permission row, got %#v", rows) + } + if rows["permission"]["defaultPreset"] != "parsar-unattended" { + t.Fatalf("permission row = %#v", rows["permission"]) + } +} + +// dsh validates the composed sandbox+approval pair against the preset table +// and re-pins both knobs from defaultPreset on every session creation, so +// patching the approval row alone fails boot with "match no preset". The +// pairing has to arrive as a declared, selected preset. +func TestRenderPatchDeclaresUnattendedPresetPair(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(nil, "", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + presets, ok := rows["permission"]["presets"].(map[string]any) + if !ok { + t.Fatalf("permission row missing presets: %#v", rows["permission"]) + } + preset, ok := presets["parsar-unattended"].(map[string]any) + if !ok { + t.Fatalf("missing parsar-unattended preset: %#v", presets) + } + if preset["sandbox"] != "workspace-write" || preset["approval"] != "never" { + t.Fatalf("preset pair = %#v, want workspace-write + never", preset) + } + if _, ok := rows["approval"]; ok { + t.Fatalf("the approval row must not be patched on its own: %#v", rows) + } +} + +func TestRenderPatchRejectsIncompleteProvider(t *testing.T) { + cases := map[string]map[string]any{ + "base_url": {"api": "openai-completions", "api_key_env": "K", "model": "m"}, + "api": {"base_url": "https://x/v1", "api_key_env": "K", "model": "m"}, + "api_key": {"base_url": "https://x/v1", "api": "openai-completions", "model": "m"}, + "model": {"base_url": "https://x/v1", "api": "openai-completions", "api_key_env": "K"}, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + if _, err := deepseekharness.RenderPatchForTest(raw, "", ""); err == nil { + t.Fatalf("expected rejection for incomplete provider %#v", raw) + } + }) + } +} + +func TestRenderPatchRejectsBadProviderShape(t *testing.T) { + _, err := deepseekharness.RenderPatchForTest("not-an-object", "", "") + if err == nil || !strings.Contains(err.Error(), "dsh_provider") { + t.Fatalf("err = %v, want dsh_provider shape error", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session.go b/apps/parsar-daemon/internal/agent/deepseekharness/session.go new file mode 100644 index 00000000..f9be53f7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session.go @@ -0,0 +1,192 @@ +// Package deepseekharness is the agent_kind="deepseek_harness" adapter. +// It drives DeepSeek Harness through its one-shot surface, +// `dsh --profile headless `, which prints the final assistant text +// on stdout and exits non-zero for any turn that did not complete. +// +// The harness exposes no supported machine-readable event stream, resume +// flag, or approval channel for that surface, so this adapter advertises +// neither streaming, usage, resume nor permissions: one prompt is one +// fresh dsh session. +package deepseekharness + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// unsupportedOptions are agent_options Parsar renders for other engines +// that the dsh headless profile has no seam for. They are logged rather +// than dropped silently so an operator can see why a configured +// capability had no effect. +var unsupportedOptions = []string{"mcp_servers", "skills", "skill_dirs", "plugin_dirs"} + +type sessionConfig struct { + binary string + extraArgs []string + killTimeout time.Duration + logger *slog.Logger +} + +func defaultConfig() sessionConfig { + return sessionConfig{binary: defaultBinary, killTimeout: 3 * time.Second, logger: obslog.Bg()} +} + +// Factory implements agent.Factory for agent_kind="deepseek_harness". +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `dsh --profile headless` subprocess. +type Session struct { + runID string + cfg sessionConfig + + proc *clirunner.Process + out chan<- proto.Envelope + + cancelCtx context.Context + + cancelOnce sync.Once + closeOutOnce sync.Once + cleanup func() + + stderrMu sync.Mutex + stderr bytes.Buffer +} + +var _ agent.Session = (*Session)(nil) + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("deepseekharness: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.binary == "" { + cfg.binary = defaultBinary + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + for _, key := range unsupportedOptions { + if value, ok := req.AgentOptions[key]; ok && value != nil { + cfg.logger.Warn("deepseekharness: agent option unsupported by dsh headless, ignored", + "run_id", req.RunID, "option", key) + } + } + + buildRes, err := BuildArgs(req.Prompt, req.WorkDir, req.AgentOptions, StateKeys{ + AgentStateKey: req.AgentStateKey, + ConversationID: req.ConversationID, + RunID: req.RunID, + }) + if err != nil { + return nil, fmt.Errorf("deepseekharness: build args: %w", err) + } + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: parent, + Binary: cfg.binary, + Args: args, + Dir: buildRes.WorkDir, + Env: append(os.Environ(), buildRes.Env...), + KillTimeout: cfg.killTimeout, + }) + if err != nil { + buildRes.Cleanup() + return nil, fmt.Errorf("deepseekharness: start %q: %w", cfg.binary, err) + } + + s := &Session{ + runID: req.RunID, + cfg: cfg, + proc: proc, + out: out, + cancelCtx: proc.Context(), + cleanup: buildRes.Cleanup, + } + go s.pumpStderr(proc.Stderr) + go s.run(proc.Stdout) + return s, nil +} + +func (s *Session) Cancel(context.Context) error { + s.cancelOnce.Do(func() { + s.proc.Cancel() + }) + return nil +} + +func (s *Session) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { + return agent.ErrUnknownPermission +} + +func (s *Session) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { + return agent.ErrUnknownAsk +} + +func (s *Session) run(stdout io.Reader) { + defer s.cleanup() + defer s.closeOut() + + tr := newTranslator(s.runID) + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + tr.appendLine(sc.Text()) + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + s.cfg.logger.Warn("deepseekharness: scan stdout", "run_id", s.runID, "err", err) + } + + waitErr := s.proc.Wait() + for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { + s.trySend(env) + } +} + +func (s *Session) pumpStderr(stderr io.Reader) { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + s.stderrMu.Lock() + if s.stderr.Len() > 0 { + s.stderr.WriteByte('\n') + } + s.stderr.WriteString(line) + s.stderrMu.Unlock() + s.cfg.logger.Warn("dsh stderr", "run_id", s.runID, "line", line) + } +} + +func (s *Session) stderrString() string { + s.stderrMu.Lock() + defer s.stderrMu.Unlock() + return s.stderr.String() +} + +func (s *Session) trySend(env proto.Envelope) { + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("deepseekharness: terminal send timed out", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go new file mode 100644 index 00000000..c05b1ecc --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go @@ -0,0 +1,277 @@ +package deepseekharness_test + +import ( + "context" + "errors" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `dsh` when +// DSH_TESTHELPER_ROLE is set, bypassing m.Run so the framework's PASS +// line doesn't pollute fake stdout. +const dshHelperEnvKey = "DSH_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(dshHelperEnvKey); role != "" { + runFakeDsh(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runFakeDsh(role string) { + if argsFile := os.Getenv("DSH_TESTHELPER_ARGS_FILE"); argsFile != "" { + _ = os.WriteFile(argsFile, []byte(strings.Join(os.Args, "\n")), 0o600) + } + switch role { + case "success": + _, _ = os.Stdout.WriteString("the final answer\n") + case "nonzero": + _, _ = os.Stderr.WriteString("MODEL_ERROR: upstream refused\n") + os.Exit(1) + case "hang": + time.Sleep(10 * time.Minute) + } +} + +func dshHelperConfig() deepseekharness.SessionConfigForTest { + return deepseekharness.SessionConfigForTest{ + Binary: os.Args[0], + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +func dshHelperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Prompt: prompt, + AgentStateKey: "conv1/agent1/deepseek_harness", + AgentOptions: map[string]any{ + "env": map[string]any{dshHelperEnvKey: role}, + }, + } +} + +func drainDsh(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionSuccessEmitsDeltaAndDone(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_ok", "hello", "success"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainDsh(t, out, 10*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := envTypes(got) + if !slices.Contains(types, proto.TypeDelta) || !slices.Contains(types, proto.TypeDone) { + t.Fatalf("types = %v, want delta+done", types) + } + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env = %q, want done; all=%v", got[len(got)-1].Type, types) + } + if slices.Contains(types, proto.TypeError) { + t.Fatalf("clean exit must not emit an error frame: %v", types) + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "the final answer" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestSessionPassesHeadlessProfileAndPatchToCLI(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + argsFile := t.TempDir() + "/argv" + req := dshHelperReq("run_argv", "hello", "success") + req.AgentOptions["env"].(map[string]any)["DSH_TESTHELPER_ARGS_FILE"] = argsFile + + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), req, out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainDsh(t, out, 10*time.Second); !closed { + t.Fatal("out did not close") + } + + body, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read argv file: %v", err) + } + argv := strings.Split(string(body), "\n") + if !slices.Contains(argv, "--profile") || !slices.Contains(argv, "headless") { + t.Fatalf("argv missing headless profile: %v", argv) + } + if !slices.Contains(argv, "--patch") { + t.Fatalf("argv missing patch overlay: %v", argv) + } + if !slices.Contains(argv, "hello") { + t.Fatalf("argv missing task: %v", argv) + } +} + +func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_err", "hello", "nonzero"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainDsh(t, out, 10*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := envTypes(got) + if !slices.Contains(types, proto.TypeError) { + t.Fatalf("types = %v, want error", types) + } + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "upstream refused") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func TestSessionCancelClosesOutAndEmitsDone(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_cancel", "hello", "hang"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drainDsh(t, out, 10*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env = %q, want done; all=%v", got[len(got)-1].Type, envTypes(got)) + } +} + +func TestSessionCleansUpPatchFileAfterRun(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + argsFile := t.TempDir() + "/argv" + req := dshHelperReq("run_cleanup", "hello", "success") + req.AgentOptions["env"].(map[string]any)["DSH_TESTHELPER_ARGS_FILE"] = argsFile + + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), req, out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainDsh(t, out, 10*time.Second); !closed { + t.Fatal("out did not close") + } + + body, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read argv file: %v", err) + } + argv := strings.Split(string(body), "\n") + patchPath := flagValue(argv, "--patch") + if patchPath == "" { + t.Fatalf("argv missing patch path: %v", argv) + } + if _, err := os.Stat(patchPath); !os.IsNotExist(err) { + t.Fatalf("patch file must be removed once the run ends, stat err = %v", err) + } +} + +func TestSessionRejectsPermissionAndAskSubmissions(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_perm", "hello", "hang"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + if err := sess.SubmitPermission(context.Background(), "perm_nope", proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { + t.Fatalf("SubmitPermission err = %v, want ErrUnknownPermission", err) + } + if err := sess.SubmitPromptForUserChoice(context.Background(), "ask_nope", proto.PromptForUserChoiceDecisionPayload{}); !errors.Is(err, agent.ErrUnknownAsk) { + t.Fatalf("SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_nil", "hello", "success"), nil, dshHelperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 4) + _, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_empty", " ", "success"), out, dshHelperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 4) + cfg := dshHelperConfig() + cfg.Binary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_bad", "hello", "success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/version.go b/apps/parsar-daemon/internal/agent/deepseekharness/version.go new file mode 100644 index 00000000..52c48af7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/version.go @@ -0,0 +1,31 @@ +package deepseekharness + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" +) + +// InstallURL points operators at the DeepSeek Harness repository when +// the daemon can see the adapter but not the CLI binary. +const InstallURL = "https://github.com/deepseek-ai/deepseek-harness" + +const defaultBinary = "dsh" + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary cannot +// be located on PATH. Callers use errors.Is to distinguish an install +// problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("deepseek-harness CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to "dsh". +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "dsh", + DefaultBinary: defaultBinary, + MissingError: ErrCLINotFound, + TrimBinary: true, + StderrFallback: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/version_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/version_test.go new file mode 100644 index 00000000..5b0c94ad --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/version_test.go @@ -0,0 +1,18 @@ +package deepseekharness_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "dsh", + DefaultBinary: "dsh", + MissingError: deepseekharness.ErrCLINotFound, + Check: deepseekharness.CheckCLIAvailable, + WhitespaceDefaults: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/pi/provider_config.go b/apps/parsar-daemon/internal/agent/pi/provider_config.go index 48794e60..232d47fe 100644 --- a/apps/parsar-daemon/internal/agent/pi/provider_config.go +++ b/apps/parsar-daemon/internal/agent/pi/provider_config.go @@ -123,7 +123,7 @@ func resolveAgentDir(agentStateKey, conversationID, runID string) (string, error } base := filepath.Join(root, "runtime", "pi") if key := strings.TrimSpace(agentStateKey); key != "" { - parts := safeStatePathParts(key) + parts := paths.StateKeyParts(key) if len(parts) == 0 { return "", fmt.Errorf("pi: invalid agentStateKey %q", agentStateKey) } @@ -174,33 +174,6 @@ func applyPiRuntimeState(opts map[string]any, agentStateKey, conversationID, run return out, nil } -func safeStatePathParts(key string) []string { - rawParts := strings.Split(key, "/") - parts := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - if safe := safeStatePathPart(part); safe != "" { - parts = append(parts, safe) - } - } - return parts -} - -func safeStatePathPart(part string) string { - var b strings.Builder - for _, r := range strings.TrimSpace(part) { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "." || out == ".." { - return "" - } - return out -} - func withAgentDirEnv(existing any, agentDir string) map[string]any { out := map[string]any{} if m, ok := existing.(map[string]any); ok { diff --git a/apps/parsar-daemon/internal/cli/agent_cli.go b/apps/parsar-daemon/internal/cli/agent_cli.go new file mode 100644 index 00000000..d42441b8 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/agent_cli.go @@ -0,0 +1,211 @@ +// Agent CLI preflight: one table describing every engine the daemon can +// drive, its heartbeat capability descriptor, and how to probe its binary. +// Split out of connect.go so adding an engine touches one table instead of +// appending another copy of the probe/report block. +package cli + +import ( + "context" + "errors" + "fmt" + + "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/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. +type agentCLIDiscovery struct { + ClaudeCode proto.SupportedAgentKind + OpenCode proto.SupportedAgentKind + Codex proto.SupportedAgentKind + Pi proto.SupportedAgentKind + DeepseekHarness proto.SupportedAgentKind +} + +type versionCheck func(context.Context, string) (string, error) + +type agentCLIChecks struct { + ClaudeCode versionCheck + OpenCode versionCheck + Codex versionCheck + Pi versionCheck + DeepseekHarness versionCheck +} + +func defaultAgentCLIChecks() agentCLIChecks { + return agentCLIChecks{ + ClaudeCode: claudecode.CheckCLIAvailable, + OpenCode: opencodeagent.CheckCLIAvailable, + Codex: codex.CheckCLIAvailable, + Pi: pi.CheckCLIAvailable, + DeepseekHarness: deepseekharness.CheckCLIAvailable, + } +} + +// agentCLIProbe is one engine's preflight: where its descriptor lives, how +// to detect the binary, and the operator-facing strings used to report it. +type agentCLIProbe struct { + slot *proto.SupportedAgentKind + check versionCheck + fallback versionCheck + label string + versionCmd string + notFoundErr error + installURL string +} + +func preflightAgentCLIs(rc *runContext) (agentCLIDiscovery, error) { + return discoverAgentCLIs(rc, defaultAgentCLIChecks()) +} + +func discoverAgentCLIs(rc *runContext, checks agentCLIChecks) (agentCLIDiscovery, error) { + out := agentCLIDescriptors() + probes := []agentCLIProbe{ + { + slot: &out.ClaudeCode, + check: checks.ClaudeCode, + fallback: claudecode.CheckCLIAvailable, + label: "Claude Code", + versionCmd: "claude --version", + notFoundErr: claudecode.ErrCLINotFound, + installURL: claudecode.InstallURL, + }, + { + slot: &out.OpenCode, + check: checks.OpenCode, + fallback: opencodeagent.CheckCLIAvailable, + label: "OpenCode", + versionCmd: "opencode --version", + notFoundErr: opencodeagent.ErrCLINotFound, + installURL: opencodeagent.InstallURL, + }, + { + slot: &out.Codex, + check: checks.Codex, + fallback: codex.CheckCLIAvailable, + label: "Codex", + versionCmd: "codex --version", + notFoundErr: codex.ErrCLINotFound, + installURL: codex.InstallURL, + }, + { + slot: &out.Pi, + check: checks.Pi, + fallback: pi.CheckCLIAvailable, + label: "pi", + versionCmd: "pi --version", + notFoundErr: pi.ErrCLINotFound, + installURL: pi.InstallURL, + }, + { + slot: &out.DeepseekHarness, + check: checks.DeepseekHarness, + fallback: deepseekharness.CheckCLIAvailable, + label: "DeepSeek Harness", + versionCmd: "dsh --version", + notFoundErr: deepseekharness.ErrCLINotFound, + installURL: deepseekharness.InstallURL, + }, + } + + available := 0 + for _, probe := range probes { + if runAgentCLIProbe(rc, probe) { + available++ + } + } + if available == 0 { + return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, pi, or DeepSeek Harness)") + } + return out, nil +} + +// runAgentCLIProbe fills the descriptor in place and reports the outcome to +// the operator. Returns whether the CLI is usable. +func runAgentCLIProbe(rc *runContext, probe agentCLIProbe) bool { + check := probe.check + if check == nil { + check = probe.fallback + } + ctx, cancel := context.WithTimeout(context.Background(), cliVersionTimeout) + version, err := check(ctx, "") + cancel() + + switch { + case err == nil: + probe.slot.Available = true + probe.slot.Version = version + fmt.Fprintf(rc.stdout, "%s preflight ok (%s)\n", probe.label, version) + return true + case errors.Is(err, probe.notFoundErr): + fmt.Fprintf(rc.stderr, "parsar-daemon: %s CLI not found on PATH; %s unavailable.\n", probe.label, probe.slot.Kind) + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", probe.installURL) + default: + fmt.Fprintf(rc.stderr, "parsar-daemon: `%s` failed; %s unavailable: %v\n", probe.versionCmd, probe.slot.Kind, err) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", probe.installURL) + } + return false +} + +// agentCLIDescriptors is the capability contract the server reads from the +// heartbeat. Availability and version are filled in by the probes. +func agentCLIDescriptors() agentCLIDiscovery { + return agentCLIDiscovery{ + ClaudeCode: proto.SupportedAgentKind{ + Kind: "claude_code", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + OpenCode: proto.SupportedAgentKind{ + Kind: "opencode", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + }, + }, + Codex: proto.SupportedAgentKind{ + Kind: "codex", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + Pi: proto.SupportedAgentKind{ + Kind: "pi", + Capabilities: proto.AgentKindCapabilities{ + // pi runs --no-approve, so no permission cards; streaming, + // usage, and --session resume are all wired. + Streaming: true, + Usage: true, + Resume: true, + }, + }, + DeepseekHarness: proto.SupportedAgentKind{ + Kind: "deepseek_harness", + // `dsh --profile headless` is the harness's only supported + // automation surface: it prints the final assistant text and + // exits, with no event stream, token accounting, resume flag, + // or approval channel to advertise. + Capabilities: proto.AgentKindCapabilities{}, + }, + } +} + +func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery) { + registry.RegisterKind(agentCLIs.ClaudeCode, claudecode.Factory) + registry.RegisterKind(agentCLIs.OpenCode, opencodeagent.Factory) + registry.RegisterKind(agentCLIs.Codex, codex.Factory) + registry.RegisterKind(agentCLIs.Pi, pi.Factory) + registry.RegisterKind(agentCLIs.DeepseekHarness, deepseekharness.Factory) +} diff --git a/apps/parsar-daemon/internal/cli/agent_cli_test.go b/apps/parsar-daemon/internal/cli/agent_cli_test.go new file mode 100644 index 00000000..c0e23945 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/agent_cli_test.go @@ -0,0 +1,269 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "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/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// missingChecks stubs every engine as absent. Tests override the engines +// they care about — leaving a field nil would probe the host's real CLI and +// make the outcome depend on the developer's machine. +func missingChecks() agentCLIChecks { + return agentCLIChecks{ + ClaudeCode: func(context.Context, string) (string, error) { + return "", claudecode.ErrCLINotFound + }, + OpenCode: func(context.Context, string) (string, error) { + return "", opencodeagent.ErrCLINotFound + }, + Codex: func(context.Context, string) (string, error) { + return "", codex.ErrCLINotFound + }, + Pi: func(context.Context, string) (string, error) { + return "", pi.ErrCLINotFound + }, + DeepseekHarness: func(context.Context, string) (string, error) { + return "", deepseekharness.ErrCLINotFound + }, + } +} + +func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + checks := missingChecks() + checks.OpenCode = func(context.Context, string) (string, error) { + return "opencode 1.4.3", nil + } + got, err := discoverAgentCLIs(rc, checks) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if got.ClaudeCode.Available { + t.Fatalf("ClaudeCode.Available = true, want false: %#v", got.ClaudeCode) + } + if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { + t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) + } + if got.Codex.Available { + t.Fatalf("Codex.Available = true, want false: %#v", got.Codex) + } + if got.Pi.Available { + t.Fatalf("Pi.Available = true, want false: %#v", got.Pi) + } + if got.DeepseekHarness.Available { + t.Fatalf("DeepseekHarness.Available = true, want false: %#v", got.DeepseekHarness) + } + if !got.OpenCode.Capabilities.Streaming || !got.OpenCode.Capabilities.Usage || got.OpenCode.Capabilities.Permissions { + t.Fatalf("OpenCode capabilities = %#v", got.OpenCode.Capabilities) + } + if !strings.Contains(stdout.String(), "OpenCode preflight ok") { + t.Fatalf("stdout missing OpenCode ok line: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "claude_code unavailable") { + t.Fatalf("stderr missing Claude unavailable line: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "deepseek_harness unavailable") { + t.Fatalf("stderr missing DeepSeek Harness unavailable line: %q", stderr.String()) + } +} + +func TestDiscoverAgentCLIsAllowsDeepseekHarnessAlone(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + checks := missingChecks() + checks.DeepseekHarness = func(context.Context, string) (string, error) { + return "dsh 0.1.0", nil + } + got, err := discoverAgentCLIs(rc, checks) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if !got.DeepseekHarness.Available || got.DeepseekHarness.Version != "dsh 0.1.0" { + t.Fatalf("DeepseekHarness descriptor = %#v", got.DeepseekHarness) + } + // The headless surface streams nothing, reports no tokens, and has no + // resume flag or approval channel — advertising any of them would make + // the server wait for frames that never arrive. + if got.DeepseekHarness.Capabilities != (proto.AgentKindCapabilities{}) { + t.Fatalf("DeepseekHarness capabilities = %#v, want none", got.DeepseekHarness.Capabilities) + } + if !strings.Contains(stdout.String(), "DeepSeek Harness preflight ok") { + t.Fatalf("stdout missing DeepSeek Harness ok line: %q", stdout.String()) + } +} + +func TestDiscoverAgentCLIsAllMissingFails(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, missingChecks()) + if err == nil { + t.Fatalf("expected error when all CLIs missing, got descriptors %#v", got) + } + if !strings.Contains(err.Error(), "no supported agent CLI") { + t.Fatalf("unexpected error: %v", err) + } + if got.ClaudeCode.Available || got.OpenCode.Available || got.Codex.Available || got.Pi.Available || got.DeepseekHarness.Available { + t.Fatalf("available descriptors after missing CLIs: %#v", got) + } +} + +func TestDiscoverAgentCLIsAllAvailable(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, agentCLIChecks{ + ClaudeCode: func(context.Context, string) (string, error) { + return "claude 2.0.0", nil + }, + OpenCode: func(context.Context, string) (string, error) { + return "opencode 1.4.3", nil + }, + Codex: func(context.Context, string) (string, error) { + return "codex 0.141.0", nil + }, + Pi: func(context.Context, string) (string, error) { + return "pi 0.1.0", nil + }, + DeepseekHarness: func(context.Context, string) (string, error) { + return "dsh 0.1.0", nil + }, + }) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if !got.ClaudeCode.Available || got.ClaudeCode.Version != "claude 2.0.0" { + t.Fatalf("ClaudeCode descriptor = %#v", got.ClaudeCode) + } + if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { + t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) + } + if !got.Codex.Available || got.Codex.Version != "codex 0.141.0" { + t.Fatalf("Codex descriptor = %#v", got.Codex) + } + if !got.ClaudeCode.Capabilities.Permissions || !got.ClaudeCode.Capabilities.Resume { + t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) + } + if !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { + t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) + } + if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { + t.Fatalf("Pi descriptor = %#v", got.Pi) + } + if !got.Pi.Capabilities.Streaming || !got.Pi.Capabilities.Usage || !got.Pi.Capabilities.Resume || got.Pi.Capabilities.Permissions { + t.Fatalf("Pi capabilities = %#v (want Streaming+Usage+Resume, no Permissions)", got.Pi.Capabilities) + } + if !got.DeepseekHarness.Available || got.DeepseekHarness.Version != "dsh 0.1.0" { + t.Fatalf("DeepseekHarness descriptor = %#v", got.DeepseekHarness) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestDiscoverAgentCLIsReportsBrokenCLIWithVersionCommand(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + checks := missingChecks() + checks.DeepseekHarness = func(context.Context, string) (string, error) { + return "", context.DeadlineExceeded + } + if _, err := discoverAgentCLIs(rc, checks); err == nil { + t.Fatal("expected error when every CLI is unusable") + } + if !strings.Contains(stderr.String(), "`dsh --version` failed") { + t.Fatalf("stderr missing broken-CLI line: %q", stderr.String()) + } +} + +func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { + reg := agent.NewRegistry() + registerAgentKinds(reg, agentCLIDiscovery{ + ClaudeCode: proto.SupportedAgentKind{ + Kind: "claude_code", + Available: true, + Version: "claude 2.0.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + OpenCode: proto.SupportedAgentKind{ + Kind: "opencode", + Available: false, + Version: "missing", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + }, + }, + Codex: proto.SupportedAgentKind{ + Kind: "codex", + Available: true, + Version: "codex 0.141.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + Pi: proto.SupportedAgentKind{ + Kind: "pi", + Available: true, + Version: "pi 0.1.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + Resume: true, + }, + }, + DeepseekHarness: proto.SupportedAgentKind{ + Kind: "deepseek_harness", + Available: true, + Version: "dsh 0.1.0", + }, + }) + + kinds := reg.SupportedAgentKinds() + if len(kinds) != 5 { + t.Fatalf("SupportedAgentKinds len = %d, want 5: %#v", len(kinds), kinds) + } + // Sorted: claude_code, codex, deepseek_harness, opencode, pi. + want := []string{"claude_code", "codex", "deepseek_harness", "opencode", "pi"} + for i, kind := range want { + if kinds[i].Kind != kind { + t.Fatalf("SupportedAgentKinds sort = %#v", kinds) + } + } + if !kinds[0].Available || kinds[0].Version != "claude 2.0.0" || !kinds[0].Capabilities.Permissions { + t.Fatalf("claude descriptor not preserved: %#v", kinds[0]) + } + if !kinds[1].Available || kinds[1].Version != "codex 0.141.0" || !kinds[1].Capabilities.Resume { + t.Fatalf("codex descriptor not preserved: %#v", kinds[1]) + } + if !kinds[2].Available || kinds[2].Version != "dsh 0.1.0" || kinds[2].Capabilities.Resume { + t.Fatalf("deepseek_harness descriptor not preserved: %#v", kinds[2]) + } + if kinds[3].Available || kinds[3].Version != "missing" || !kinds[3].Capabilities.Streaming || !kinds[3].Capabilities.Usage { + t.Fatalf("opencode descriptor not preserved: %#v", kinds[3]) + } + if !kinds[4].Available || kinds[4].Version != "pi 0.1.0" || !kinds[4].Capabilities.Resume || kinds[4].Capabilities.Permissions { + t.Fatalf("pi descriptor not preserved: %#v", kinds[4]) + } + for _, kind := range want { + if _, err := reg.Resolve(kind); err != nil { + t.Fatalf("%s factory not registered: %v", kind, err) + } + } +} diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 54578400..679f48e7 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -12,10 +12,6 @@ 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/apps/parsar-daemon/internal/agent/codex" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" @@ -189,158 +185,6 @@ func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.P return prof, nil } -// agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. -type agentCLIDiscovery struct { - ClaudeCode proto.SupportedAgentKind - OpenCode proto.SupportedAgentKind - Codex proto.SupportedAgentKind - Pi proto.SupportedAgentKind -} - -type agentCLIChecks struct { - ClaudeCode func(context.Context, string) (string, error) - OpenCode func(context.Context, string) (string, error) - Codex func(context.Context, string) (string, error) - Pi func(context.Context, string) (string, error) -} - -func defaultAgentCLIChecks() agentCLIChecks { - return agentCLIChecks{ - ClaudeCode: claudecode.CheckCLIAvailable, - OpenCode: opencodeagent.CheckCLIAvailable, - Codex: codex.CheckCLIAvailable, - Pi: pi.CheckCLIAvailable, - } -} - -func preflightAgentCLIs(rc *runContext) (agentCLIDiscovery, error) { - return discoverAgentCLIs(rc, defaultAgentCLIChecks()) -} - -func discoverAgentCLIs(rc *runContext, checks agentCLIChecks) (agentCLIDiscovery, error) { - if checks.ClaudeCode == nil { - checks.ClaudeCode = claudecode.CheckCLIAvailable - } - if checks.OpenCode == nil { - checks.OpenCode = opencodeagent.CheckCLIAvailable - } - if checks.Codex == nil { - checks.Codex = codex.CheckCLIAvailable - } - if checks.Pi == nil { - checks.Pi = pi.CheckCLIAvailable - } - out := agentCLIDiscovery{ - ClaudeCode: proto.SupportedAgentKind{ - Kind: "claude_code", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - OpenCode: proto.SupportedAgentKind{ - Kind: "opencode", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Usage: true, - }, - }, - Codex: proto.SupportedAgentKind{ - Kind: "codex", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - Pi: proto.SupportedAgentKind{ - Kind: "pi", - Capabilities: proto.AgentKindCapabilities{ - // pi runs --no-approve, so no permission cards; streaming, - // usage, and --session resume are all wired. - Streaming: true, - Usage: true, - Resume: true, - }, - }, - } - - claudeCtx, cancelClaude := context.WithTimeout(context.Background(), cliVersionTimeout) - claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") - cancelClaude() - if claudeErr == nil { - out.ClaudeCode.Available = true - out.ClaudeCode.Version = claudeVersion - fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) - } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) - } - - opencodeCtx, cancelOpenCode := context.WithTimeout(context.Background(), cliVersionTimeout) - opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") - cancelOpenCode() - if opencodeErr == nil { - out.OpenCode.Available = true - out.OpenCode.Version = opencodeVersion - fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) - } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: OpenCode CLI not found on PATH; opencode unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) - } - - codexCtx, cancelCodex := context.WithTimeout(context.Background(), cliVersionTimeout) - codexVersion, codexErr := checks.Codex(codexCtx, "") - cancelCodex() - if codexErr == nil { - out.Codex.Available = true - out.Codex.Version = codexVersion - fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) - } else if errors.Is(codexErr, codex.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: Codex CLI not found on PATH; codex unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) - } - - piCtx, cancelPi := context.WithTimeout(context.Background(), cliVersionTimeout) - piVersion, piErr := checks.Pi(piCtx, "") - cancelPi() - if piErr == nil { - out.Pi.Available = true - out.Pi.Version = piVersion - fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) - } else if errors.Is(piErr, pi.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: pi CLI not found on PATH; pi unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) - } - - if !out.ClaudeCode.Available && !out.OpenCode.Available && !out.Codex.Available && !out.Pi.Available { - return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, or pi)") - } - return out, nil -} - -func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery) { - registry.RegisterKind(agentCLIs.ClaudeCode, claudecode.Factory) - registry.RegisterKind(agentCLIs.OpenCode, opencodeagent.Factory) - registry.RegisterKind(agentCLIs.Codex, codex.Factory) - registry.RegisterKind(agentCLIs.Pi, pi.Factory) -} - // spawnBackground forks the daemon into the background. Parent // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index a2f226eb..218478be 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -1,18 +1,10 @@ package cli import ( - "context" "os" "reflect" "strings" "testing" - - "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/apps/parsar-daemon/internal/agent/codex" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { @@ -66,197 +58,3 @@ func TestLoadInlineConnectEnvHydratesParentProcessFlags(t *testing.T) { t.Fatalf("inlinePair=false after env hydration; serverURL=%q token=%q", serverURL, token) } } - -func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, agentCLIChecks{ - ClaudeCode: func(context.Context, string) (string, error) { - return "", claudecode.ErrCLINotFound - }, - OpenCode: func(context.Context, string) (string, error) { - return "opencode 1.4.3", nil - }, - Codex: func(context.Context, string) (string, error) { - return "", codex.ErrCLINotFound - }, - Pi: func(context.Context, string) (string, error) { - return "", pi.ErrCLINotFound - }, - }) - if err != nil { - t.Fatalf("discoverAgentCLIs: %v", err) - } - if got.ClaudeCode.Available { - t.Fatalf("ClaudeCode.Available = true, want false: %#v", got.ClaudeCode) - } - if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { - t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) - } - if got.Codex.Available { - t.Fatalf("Codex.Available = true, want false: %#v", got.Codex) - } - if got.Pi.Available { - t.Fatalf("Pi.Available = true, want false: %#v", got.Pi) - } - if !got.OpenCode.Capabilities.Streaming || !got.OpenCode.Capabilities.Usage || got.OpenCode.Capabilities.Permissions { - t.Fatalf("OpenCode capabilities = %#v", got.OpenCode.Capabilities) - } - if !strings.Contains(stdout.String(), "OpenCode preflight ok") { - t.Fatalf("stdout missing OpenCode ok line: %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "claude_code unavailable") { - t.Fatalf("stderr missing Claude unavailable line: %q", stderr.String()) - } -} - -func TestDiscoverAgentCLIsBothMissingFails(t *testing.T) { - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, agentCLIChecks{ - ClaudeCode: func(context.Context, string) (string, error) { - return "", claudecode.ErrCLINotFound - }, - OpenCode: func(context.Context, string) (string, error) { - return "", opencodeagent.ErrCLINotFound - }, - Codex: func(context.Context, string) (string, error) { - return "", codex.ErrCLINotFound - }, - Pi: func(context.Context, string) (string, error) { - return "", pi.ErrCLINotFound - }, - }) - if err == nil { - t.Fatalf("expected error when all CLIs missing, got descriptors %#v", got) - } - if !strings.Contains(err.Error(), "no supported agent CLI") { - t.Fatalf("unexpected error: %v", err) - } - if got.ClaudeCode.Available || got.OpenCode.Available || got.Codex.Available || got.Pi.Available { - t.Fatalf("available descriptors after missing CLIs: %#v", got) - } -} - -func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, agentCLIChecks{ - ClaudeCode: func(context.Context, string) (string, error) { - return "claude 2.0.0", nil - }, - OpenCode: func(context.Context, string) (string, error) { - return "opencode 1.4.3", nil - }, - Codex: func(context.Context, string) (string, error) { - return "codex 0.141.0", nil - }, - Pi: func(context.Context, string) (string, error) { - return "pi 0.1.0", nil - }, - }) - if err != nil { - t.Fatalf("discoverAgentCLIs: %v", err) - } - if !got.ClaudeCode.Available || got.ClaudeCode.Version != "claude 2.0.0" { - t.Fatalf("ClaudeCode descriptor = %#v", got.ClaudeCode) - } - if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { - t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) - } - if !got.Codex.Available || got.Codex.Version != "codex 0.141.0" { - t.Fatalf("Codex descriptor = %#v", got.Codex) - } - if !got.ClaudeCode.Capabilities.Permissions || !got.ClaudeCode.Capabilities.Resume { - t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) - } - if !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { - t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) - } - if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { - t.Fatalf("Pi descriptor = %#v", got.Pi) - } - if !got.Pi.Capabilities.Streaming || !got.Pi.Capabilities.Usage || !got.Pi.Capabilities.Resume || got.Pi.Capabilities.Permissions { - t.Fatalf("Pi capabilities = %#v (want Streaming+Usage+Resume, no Permissions)", got.Pi.Capabilities) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { - reg := agent.NewRegistry() - registerAgentKinds(reg, agentCLIDiscovery{ - ClaudeCode: proto.SupportedAgentKind{ - Kind: "claude_code", - Available: true, - Version: "claude 2.0.0", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - OpenCode: proto.SupportedAgentKind{ - Kind: "opencode", - Available: false, - Version: "missing", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Usage: true, - }, - }, - Codex: proto.SupportedAgentKind{ - Kind: "codex", - Available: true, - Version: "codex 0.141.0", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - Pi: proto.SupportedAgentKind{ - Kind: "pi", - Available: true, - Version: "pi 0.1.0", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Usage: true, - Resume: true, - }, - }, - }) - - kinds := reg.SupportedAgentKinds() - if len(kinds) != 4 { - t.Fatalf("SupportedAgentKinds len = %d, want 4: %#v", len(kinds), kinds) - } - // Sorted: claude_code, codex, opencode, pi. - if kinds[0].Kind != "claude_code" || kinds[1].Kind != "codex" || kinds[2].Kind != "opencode" || kinds[3].Kind != "pi" { - t.Fatalf("SupportedAgentKinds sort = %#v", kinds) - } - if !kinds[0].Available || kinds[0].Version != "claude 2.0.0" || !kinds[0].Capabilities.Permissions { - t.Fatalf("claude descriptor not preserved: %#v", kinds[0]) - } - if !kinds[1].Available || kinds[1].Version != "codex 0.141.0" || !kinds[1].Capabilities.Resume { - t.Fatalf("codex descriptor not preserved: %#v", kinds[1]) - } - if kinds[2].Available || kinds[2].Version != "missing" || !kinds[2].Capabilities.Streaming || !kinds[2].Capabilities.Usage { - t.Fatalf("opencode descriptor not preserved: %#v", kinds[2]) - } - if !kinds[3].Available || kinds[3].Version != "pi 0.1.0" || !kinds[3].Capabilities.Resume || kinds[3].Capabilities.Permissions { - t.Fatalf("pi descriptor not preserved: %#v", kinds[3]) - } - if _, err := reg.Resolve("opencode"); err != nil { - t.Fatalf("opencode factory not registered: %v", err) - } - if _, err := reg.Resolve("codex"); err != nil { - t.Fatalf("codex factory not registered: %v", err) - } - if _, err := reg.Resolve("pi"); err != nil { - t.Fatalf("pi factory not registered: %v", err) - } -} diff --git a/apps/parsar-daemon/internal/paths/statekey.go b/apps/parsar-daemon/internal/paths/statekey.go new file mode 100644 index 00000000..21ebc896 --- /dev/null +++ b/apps/parsar-daemon/internal/paths/statekey.go @@ -0,0 +1,37 @@ +package paths + +import "strings" + +// StateKeyParts splits an AgentStateKey into filesystem-safe path parts for +// an adapter state directory under Root(). It is the traversal guard for a +// server-supplied key: every part is reduced to [A-Za-z0-9._-], and "." / +// ".." parts are dropped rather than sanitized into a name that still +// escapes. An empty result means the key carried nothing usable. +func StateKeyParts(key string) []string { + rawParts := strings.Split(key, "/") + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + if safe := SafePathPart(part); safe != "" { + parts = append(parts, safe) + } + } + return parts +} + +// SafePathPart reduces one path component to filesystem-safe characters, +// returning "" for a component that cannot be used as a directory name. +func SafePathPart(part string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(part) { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "." || out == ".." { + return "" + } + return out +} diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 1b3892ea..403deb82 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1783,6 +1783,11 @@ "title": "Pi", "description": "Run the pi coding agent CLI through parsar-daemon." }, + "deepseekHarness": { + "title": "DeepSeek Harness", + "cardHint": "One-shot runs: each prompt starts a fresh session with no memory of earlier turns.", + "description": "Run DeepSeek Harness (dsh) one-shot headless tasks through parsar-daemon. Each prompt starts a fresh harness session and returns its final answer." + }, "opencode": { "title": "OpenCode", "description": "Run OpenCode CLI through parsar-daemon for local or sandbox daemon workflows." diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index c6e813c3..59d4470a 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1783,6 +1783,11 @@ "title": "Pi", "description": "通过 parsar-daemon 运行 pi coding agent CLI。" }, + "deepseekHarness": { + "title": "DeepSeek Harness", + "cardHint": "单次执行:每次提问都会新建会话,不保留之前轮次的上下文。", + "description": "通过 parsar-daemon 以 dsh 单次无界面任务方式运行 DeepSeek Harness。每次提问都会新建一个 harness 会话并返回最终回答。" + }, "opencode": { "title": "OpenCode", "description": "通过 parsar-daemon 运行 OpenCode CLI,适合本机或 sandbox daemon 的 OpenCode 工作流。" diff --git a/apps/web/src/lib/agent-view-model.ts b/apps/web/src/lib/agent-view-model.ts index d4e396d5..80aadc6f 100644 --- a/apps/web/src/lib/agent-view-model.ts +++ b/apps/web/src/lib/agent-view-model.ts @@ -1,6 +1,6 @@ import type { Agent, AgentDetail, CapabilityType, Model } from "./api-types" -export type AgentEngine = "claude_code" | "codex" | "pi" | "opencode" +export type AgentEngine = "claude_code" | "codex" | "pi" | "opencode" | "deepseek_harness" export type CodexCollaborationMode = "default" | "plan" @@ -11,6 +11,7 @@ export type AgentEngineLabelKey = | "agents.engine.codex.title" | "agents.engine.pi.title" | "agents.engine.opencode.title" + | "agents.engine.deepseekHarness.title" type AgentSource = Agent | AgentDetail | null | undefined type UnknownRecord = Record @@ -51,6 +52,9 @@ function normalizeEngine(value: string): AgentEngine | null { case "opencode": case "open_code": return "opencode" + case "deepseek_harness": + case "dsh": + return "deepseek_harness" default: return null } @@ -83,6 +87,8 @@ export function agentEngineLabel(engine: AgentEngine): AgentEngineLabelKey { return "agents.engine.pi.title" case "opencode": return "agents.engine.opencode.title" + case "deepseek_harness": + return "agents.engine.deepseekHarness.title" } } @@ -95,11 +101,13 @@ export function agentEngineSupportsCapability(engine: AgentEngine, capabilityTyp return capabilityType === "mcp" || capabilityType === "system_prompt" case "pi": return capabilityType === "skill" || capabilityType === "system_prompt" + case "deepseek_harness": + return capabilityType === "system_prompt" } } export function agentEnginesSupportingCapability(capabilityType: CapabilityType): AgentEngine[] { - return (["claude_code", "codex", "pi", "opencode"] as const).filter((engine) => + return (["claude_code", "codex", "pi", "opencode", "deepseek_harness"] as const).filter((engine) => agentEngineSupportsCapability(engine, capabilityType), ) } @@ -174,6 +182,26 @@ export function defaultModelOf(agent: AgentSource, models: Model[], unavailableL return found.name || found.model_key || id } +/** Display name for a raw agent_kind reported by a runtime heartbeat. + * Runtimes may advertise kinds this build does not model yet, so an + * unrecognized value falls back to the wire string. */ +export function agentKindDisplayName(kind: string): string { + switch (normalizeEngine(kind)) { + case "claude_code": + return "Claude Code" + case "opencode": + return "OpenCode" + case "codex": + return "Codex" + case "pi": + return "PI Agent" + case "deepseek_harness": + return "DeepSeek Harness" + default: + return kind + } +} + export function agentConnectorLabel(connectorType: string): string { if (connectorType === "agent_daemon") return "Agent Daemon" if (connectorType === "http-agent" || connectorType === "http") return "HTTP Agent" diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index 55bbc727..4dc8423c 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -1,7 +1,7 @@ import { Fragment, forwardRef, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from "react" import { useTranslation } from "react-i18next" import { useQueryClient } from "@tanstack/react-query" -import { ArrowUpRight, Bot, Check, ChevronDown, Cloud, Cpu, Eye, EyeOff, Laptop, Network, Search, Server, Sparkles } from "lucide-react" +import { ArrowUpRight, Bot, Check, ChevronDown, Cloud, Cpu, Eye, EyeOff, Laptop, Network, Search, Server, Sparkles, Waves } from "lucide-react" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" @@ -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, type AgentEngine, type CodexCollaborationMode } from "../../lib/agent-view-model" import { modelProtocols, modelSupportedEndpointTypes, @@ -46,7 +46,6 @@ import type { const DEFAULT_WORK_DIR = "/workspace" type ExecutionMode = "sandbox" | "local_device" | "external" -type AgentEngine = "claude_code" | "opencode" | "codex" | "pi" type SandboxSize = "standard" | "xl" type RuntimeChoice = AgentRuntime type WizardStep = 1 | 2 @@ -70,6 +69,7 @@ function agentEngineFromAgent(a?: Agent | null): AgentEngine { if (v === "opencode") return "opencode" if (v === "codex") return "codex" if (v === "pi") return "pi" + if (v === "deepseek_harness") return "deepseek_harness" return "claude_code" } @@ -83,6 +83,7 @@ function engineSupportsProtocol(engine: AgentEngine, protocol: WireProtocol | nu case "codex": return protocol === "openai" case "pi": + case "deepseek_harness": return protocol === "anthropic" || protocol === "openai" || protocol === "google" case "opencode": return true @@ -98,6 +99,7 @@ function engineSupportsModel(engine: AgentEngine, model: Model): boolean { case "codex": return endpointTypes.includes("openai") || endpointTypes.includes("openai-response") case "pi": + case "deepseek_harness": return ( endpointTypes.includes("anthropic") || endpointTypes.includes("openai") || @@ -686,7 +688,12 @@ export function CreateAgentDialog({ const hasConnector = true const connector = mode === "edit" && agent ? agent.connector_type : connectorForExecutionMode(executionMode) const hasModel = activeModels.length > 0 - const requiresModel = connector !== "agent_daemon" || agentEngine === "claude_code" || agentEngine === "codex" || agentEngine === "pi" + const requiresModel = + connector !== "agent_daemon" || + agentEngine === "claude_code" || + agentEngine === "codex" || + agentEngine === "pi" || + agentEngine === "deepseek_harness" const selectedModelUnavailable = mode === "edit" && requiresModel && selectedModelID !== "" && selectedModel === null const hasRequiredModel = !requiresModel || (selectedModel !== null && !incompatibleModelIDs.has(selectedModel.id)) // Create opens model binding on a pending "shared" pick because secrets may @@ -1093,6 +1100,13 @@ export function CreateAgentDialog({ selected={agentEngine === "pi"} onSelect={() => setAgentEngine("pi")} /> + } + title={t("agents.engine.deepseekHarness.title")} + description={t("agents.engine.deepseekHarness.cardHint")} + selected={agentEngine === "deepseek_harness"} + onSelect={() => setAgentEngine("deepseek_harness")} + /> } title={t("agents.engine.opencode.title")} diff --git a/apps/web/src/pages/admin/RuntimePage.tsx b/apps/web/src/pages/admin/RuntimePage.tsx index f9004ba7..1b7eb118 100644 --- a/apps/web/src/pages/admin/RuntimePage.tsx +++ b/apps/web/src/pages/admin/RuntimePage.tsx @@ -30,6 +30,7 @@ import { TableRow, } from "../../components/ui/table" import { useAdminView } from "../../lib/admin-router" +import { agentKindDisplayName } from "../../lib/agent-view-model" import { ApiError } from "../../lib/api-client" import { useRuntimeStatus, @@ -837,24 +838,10 @@ function runtimeConfigText(runtime: Runtime, key: string): string { function formatRuntimeAgentKinds(runtime: Runtime): string { const labels = supportedAgentKinds(runtime) .filter((kind) => kind.available) - .map((kind) => formatAgentKindLabel(kind.kind)) + .map((kind) => agentKindDisplayName(kind.kind)) return labels.length > 0 ? labels.join(" · ") : "—" } -function formatAgentKindLabel(kind: string): string { - switch (kind) { - case "claude_code": - return "Claude Code" - case "opencode": - return "OpenCode" - case "codex": - return "Codex" - case "pi": - return "PI Agent" - default: - return kind - } -} function shortID(id: string): string { return id.length > 12 ? id.slice(0, 12) : id diff --git a/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx b/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx index e99cf558..96b3dfc1 100644 --- a/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx +++ b/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx @@ -32,6 +32,7 @@ import { type Runtime, type SupportedAgentKind, } from "../../../lib/api-runtimes" +import { agentKindDisplayName } from "../../../lib/agent-view-model" import { useWorkspaceId } from "../../../lib/workspace" export function LocalDeviceRuntimesPanel() { @@ -229,7 +230,7 @@ function AgentKindBadges({ runtime }: { runtime: Runtime }) { className={kind.available ? "" : "opacity-70"} title={formatAgentKindTitle(kind, t)} > - {formatAgentKindLabel(kind.kind)} + {agentKindDisplayName(kind.kind)} ))} @@ -243,24 +244,10 @@ function AgentKindBadges({ runtime }: { runtime: Runtime }) { ) } -function formatAgentKindLabel(kind: string): string { - switch (kind) { - case "claude_code": - return "Claude Code" - case "opencode": - return "OpenCode" - case "codex": - return "Codex" - case "pi": - return "PI Agent" - default: - return kind - } -} function formatAgentKindTitle(kind: SupportedAgentKind, t: TFunction<"admin">): string { const parts = [ - formatAgentKindLabel(kind.kind), + agentKindDisplayName(kind.kind), kind.available ? t("runtime.agentDaemon.agentKind.available", { defaultValue: "available" }) : t("runtime.agentDaemon.agentKind.unavailable", { defaultValue: "unavailable" }), @@ -270,7 +257,7 @@ function formatAgentKindTitle(kind: SupportedAgentKind, t: TFunction<"admin">): } function formatAgentKindSnapshot(kind: SupportedAgentKind, t: TFunction<"admin">): string { - const label = formatAgentKindLabel(kind.kind) + const label = agentKindDisplayName(kind.kind) if (!kind.available) { return t("runtime.agentDaemon.agentKind.notDetected", { label, defaultValue: "{{label}} not detected" }) } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 181d5230..34627702 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -198,6 +198,13 @@ definitions: prompt. The connector decides how to fold it into its own prompt schema. type: string + triggerMessageID: + description: |- + TriggerMessageID is the persisted message id behind + TriggerMessageContent. Empty for callers that synthesize a prompt + without a stored message. Connectors use it to tell the current + task apart from the conversation's stored transcript. + type: string workspaceID: type: string type: object diff --git a/docs/spec-memory-module.md b/docs/spec-memory-module.md index 650405e1..96675f8f 100644 --- a/docs/spec-memory-module.md +++ b/docs/spec-memory-module.md @@ -331,7 +331,7 @@ PARSAR_RUNTIME_ID= PARSAR_WORKSPACE_ID= PARSAR_USER_ID= PARSAR_PROJECT_ID= # may be empty -PARSAR_CONNECTOR=claude|opencode|codex +PARSAR_CONNECTOR=claude|opencode|codex|pi|deepseek-harness PARSAR_PROJECT_AGENT_ID= PARSAR_CONVERSATION_ID= ``` diff --git a/infra/sandbox/Dockerfile b/infra/sandbox/Dockerfile index a34dded1..95f51599 100644 --- a/infra/sandbox/Dockerfile +++ b/infra/sandbox/Dockerfile @@ -24,8 +24,8 @@ # one does). # # Contents: -# - Claude Code CLI + Codex CLI + Pi CLI (installed by -# infra/sandbox/scripts/install-agents.sh) +# - Claude Code CLI + Codex CLI + Pi CLI + DeepSeek Harness CLI +# (installed by infra/sandbox/scripts/install-agents.sh) # - parsar-daemon + parsar CLI (spec/memory hook injection), both # compiled from source in the builder stage below — no dependency on # a published parsar-daemon release or the parsar:local server image @@ -42,6 +42,7 @@ # --build-arg CLAUDE_CODE_VERSION= pin claude code (default: latest) # --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) # --build-arg PI_VERSION= pin pi CLI (default: 0.80.6) +# --build-arg DSH_VERSION= pin DeepSeek Harness CLI (default: 0.1.0-rc.7) ############################################################################### # Stage 1: go-builder — compile parsar-daemon + parsar CLI for TARGETARCH. @@ -97,14 +98,16 @@ RUN apt-get update -y \ curl ca-certificates jq git ripgrep \ && rm -rf /var/lib/apt/lists/* -# --- Agent CLIs (Node + Claude Code + Codex + Pi) --- +# --- Agent CLIs (Node + Claude Code + Codex + Pi + DeepSeek Harness) --- # See infra/sandbox/scripts/install-agents.sh for the install logic and # version-pin build args. ARG CLAUDE_CODE_VERSION="" ARG CODEX_VERSION="0.141.0" ARG PI_VERSION="0.80.6" +ARG DSH_VERSION="0.1.0-rc.7" COPY infra/sandbox/scripts/install-agents.sh /tmp/install-agents.sh RUN CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" CODEX_VERSION="$CODEX_VERSION" PI_VERSION="$PI_VERSION" \ + DSH_VERSION="$DSH_VERSION" \ /tmp/install-agents.sh "$TARGETARCH" \ && rm -f /tmp/install-agents.sh diff --git a/infra/sandbox/scripts/install-agents.sh b/infra/sandbox/scripts/install-agents.sh index 39bae008..b9e9f95e 100755 --- a/infra/sandbox/scripts/install-agents.sh +++ b/infra/sandbox/scripts/install-agents.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Installs every agent CLI a Parsar sandbox image needs: Node 22 (for npm- -# based installs), Claude Code, Codex, and Pi. Used by +# based installs), Claude Code, Codex, Pi, and DeepSeek Harness. Used by # infra/sandbox/Dockerfile (both the local-docker and e2b.app build # targets, selected by --build-arg BASE_IMAGE). Edit here, not inline in # the Dockerfile. @@ -10,6 +10,7 @@ # CLAUDE_CODE_VERSION default: latest # CODEX_VERSION default: 0.141.0 # PI_VERSION default: 0.80.6 +# DSH_VERSION default: 0.1.0-rc.7 # # All installs are FAIL-LOUD: `set -e` + a `--version` sanity check after # each one. A silently missing CLI would only surface at run time when a @@ -21,6 +22,7 @@ TARGETARCH="${1:?install-agents.sh: TARGETARCH required (amd64|arm64)}" CLAUDE_CODE_VERSION="${CLAUDE_CODE_VERSION:-}" CODEX_VERSION="${CODEX_VERSION:-0.141.0}" PI_VERSION="${PI_VERSION:-0.80.6}" +DSH_VERSION="${DSH_VERSION:-0.1.0-rc.7}" case "$TARGETARCH" in amd64) CLAUDE_ARCH=linux-x64 CODEX_ARCH=x86_64-unknown-linux-musl ;; @@ -87,3 +89,11 @@ rm -rf /tmp/codex* echo "install-agents: installing pi ${PI_VERSION}" npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION}" pi --version + +# --- DeepSeek Harness CLI (via npm) --- +# Developer preview with documented compatibility-breaking changes between +# releases, so this pin is load-bearing: `latest` would silently change the +# headless grammar the daemon adapter drives. +echo "install-agents: installing dsh ${DSH_VERSION}" +npm install -g "@deepseek-ai/dsh@${DSH_VERSION}" +dsh --version diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index f518823a..35c8dc02 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -438,19 +438,22 @@ func main() { agentDaemonSandbox := buildAgentDaemonSandboxProvider(envLookup, cfg, dbStore, agentDaemonRegistry, agentDaemonBinder, agentDaemonPodID) agentDaemonRemote := connagentdaemon.HTTPRemoteStreamer{Token: agentDaemonInternalToken} agentDaemonCfg := connagentdaemon.Config{ - Registry: agentDaemonRegistry, - Binder: agentDaemonBinder, - Sandbox: agentDaemonSandbox, - OwnerResolver: dbStore, - OwnerPodID: agentDaemonPodID, - Remote: agentDaemonRemote, - RemoteSubmit: agentDaemonRemote, - SubmitSlots: dbStore, - ModelResolver: dbStore, - ExecutionRecorder: dbStore, - RunStatusReader: dbStore, - Capabilities: dbStore, - MasterKey: cfg.Secret.MasterKey, + Registry: agentDaemonRegistry, + Binder: agentDaemonBinder, + Sandbox: agentDaemonSandbox, + OwnerResolver: dbStore, + OwnerPodID: agentDaemonPodID, + Remote: agentDaemonRemote, + RemoteSubmit: agentDaemonRemote, + SubmitSlots: dbStore, + ModelResolver: dbStore, + // Transcript injection for engines that cannot resume their + // own session (opencode, deepseek_harness). + ConversationHistory: dbStore, + ExecutionRecorder: dbStore, + RunStatusReader: dbStore, + Capabilities: dbStore, + MasterKey: cfg.Secret.MasterKey, // Auto-mounted fetch_chat_history tool: the endpoint URL the // sandbox calls back into, plus the per-conversation token signer. // Nil signer (empty master key) disables the injection. diff --git a/server/internal/capability/render/deepseekharness.go b/server/internal/capability/render/deepseekharness.go new file mode 100644 index 00000000..ca8a6551 --- /dev/null +++ b/server/internal/capability/render/deepseekharness.go @@ -0,0 +1,37 @@ +package render + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +// deepseekHarnessRenderer serializes capability specs for the DeepSeek +// Harness runtime (`dsh --profile headless`). That surface takes a task +// string and a config overlay only: the daemon adapter folds a rendered +// system prompt into the task text, while skills, managed MCP servers and +// plugins have no seam there and return ErrUnsupported, which the +// agentdaemon connector treats as a soft degrade (skip + disabled-capability +// notice). +type deepseekHarnessRenderer struct{} + +func (deepseekHarnessRenderer) Target() Target { return TargetDeepseekHarness } + +func (deepseekHarnessRenderer) Supports(kind canonical.Kind) bool { + return kind == canonical.KindSystemPrompt +} + +func (deepseekHarnessRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { + if err := spec.Validate(); err != nil { + return Output{}, fmt.Errorf("deepseek_harness render: invalid spec: %w", err) + } + switch spec.Kind { + case canonical.KindSystemPrompt: + return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindSkill, canonical.KindMCP, canonical.KindPlugin: + return Output{}, ErrUnsupported + default: + return Output{}, fmt.Errorf("deepseek_harness render: unknown kind %q", spec.Kind) + } +} diff --git a/server/internal/capability/render/renderer.go b/server/internal/capability/render/renderer.go index ec4c8094..37164bfd 100644 --- a/server/internal/capability/render/renderer.go +++ b/server/internal/capability/render/renderer.go @@ -26,10 +26,11 @@ import ( type Target string const ( - TargetOpenCode Target = "opencode" - TargetClaudeCode Target = "claudecode" - TargetCodex Target = "codex" - TargetPi Target = "pi" + TargetOpenCode Target = "opencode" + TargetClaudeCode Target = "claudecode" + TargetCodex Target = "codex" + TargetPi Target = "pi" + TargetDeepseekHarness Target = "deepseekharness" ) // Output is what a Renderer returns. Content is the scaffold-specific JSON @@ -62,6 +63,8 @@ func TargetForAgentKind(agentKind string) Target { return TargetCodex case "pi": return TargetPi + case "deepseek_harness": + return TargetDeepseekHarness default: return TargetClaudeCode } @@ -83,6 +86,8 @@ func For(target Target) (Renderer, error) { return codexRenderer{}, nil case TargetPi: return piRenderer{}, nil + case TargetDeepseekHarness: + return deepseekHarnessRenderer{}, nil default: return nil, fmt.Errorf("render: unknown target %q", target) } diff --git a/server/internal/capability/render/renderer_test.go b/server/internal/capability/render/renderer_test.go index 87f617a1..c34a63f0 100644 --- a/server/internal/capability/render/renderer_test.go +++ b/server/internal/capability/render/renderer_test.go @@ -59,7 +59,7 @@ func remoteMCPFixture() canonical.Spec { // TestFor_KnownTargets catches "added a Target without wiring For()". func TestFor_KnownTargets(t *testing.T) { - for _, target := range []Target{TargetOpenCode, TargetClaudeCode, TargetCodex, TargetPi} { + for _, target := range []Target{TargetOpenCode, TargetClaudeCode, TargetCodex, TargetPi, TargetDeepseekHarness} { r, err := For(target) if err != nil { t.Fatalf("For(%q) error: %v", target, err) @@ -90,6 +90,9 @@ func TestSupports(t *testing.T) { {TargetOpenCode, canonical.KindSkill, false}, {TargetPi, canonical.KindMCP, false}, {TargetPi, canonical.KindSkill, true}, + {TargetDeepseekHarness, canonical.KindMCP, false}, + {TargetDeepseekHarness, canonical.KindSkill, false}, + {TargetDeepseekHarness, canonical.KindSystemPrompt, true}, } for _, tc := range cases { t.Run(string(tc.target)+"/"+string(tc.kind), func(t *testing.T) { diff --git a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go index 3e233c9f..ad543a08 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go @@ -25,6 +25,7 @@ func TestTargetForAgentKind(t *testing.T) { {"opencode", render.TargetOpenCode}, {"codex", render.TargetCodex}, {"pi", render.TargetPi}, + {"deepseek_harness", render.TargetDeepseekHarness}, {"", render.TargetClaudeCode}, {" claude_code ", render.TargetClaudeCode}, {"unknown_engine", render.TargetClaudeCode}, diff --git a/server/internal/connector/agentdaemon/connector.go b/server/internal/connector/agentdaemon/connector.go index 14af6f9e..83672ed9 100644 --- a/server/internal/connector/agentdaemon/connector.go +++ b/server/internal/connector/agentdaemon/connector.go @@ -111,6 +111,11 @@ type Config struct { // the master key). Nil disables the tool injection. IMHistoryTokenSigner func(conversationID string) string + // ConversationHistory enables the server-side transcript injection for + // engines that advertise Capabilities.Resume=false (opencode, + // deepseek_harness). Nil leaves those engines stateless across turns. + ConversationHistory ConversationHistoryReader + // ExecutionRecorder persists the per-run execution snapshot. Nil // keeps tests on the pre-snapshot behavior. ExecutionRecorder ExecutionSnapshotRecorder @@ -159,26 +164,27 @@ type Config struct { // "agent_daemon". One instance lives for the lifetime of the server // process; concurrency is delegated to gateway.Registry + binding.Binder. type Connector struct { - registry *gateway.Registry - binder binding.Binder - sandbox SandboxProvider - ownerResolver DeviceOwnerResolver - ownerPodID string - remote RemoteStreamer - remoteSubmit RemoteSubmitter - submitSlots SubmitSlotResolver - modelResolver ModelResolver - executionRecorder ExecutionSnapshotRecorder - runStatus AgentRunStatusReader - secrets *secrets.Service - capabilities CapabilityRuntimeStore - specMemory SpecMemoryInjector - oss OSSPresigner - systemMessages CapabilitySystemMessageStore - sandboxBindings SandboxBindingReader - imHistoryEndpoint string - imHistoryToken func(conversationID string) string - log *slog.Logger + registry *gateway.Registry + binder binding.Binder + sandbox SandboxProvider + ownerResolver DeviceOwnerResolver + ownerPodID string + remote RemoteStreamer + remoteSubmit RemoteSubmitter + submitSlots SubmitSlotResolver + modelResolver ModelResolver + conversationHistory ConversationHistoryReader + executionRecorder ExecutionSnapshotRecorder + runStatus AgentRunStatusReader + secrets *secrets.Service + capabilities CapabilityRuntimeStore + specMemory SpecMemoryInjector + oss OSSPresigner + systemMessages CapabilitySystemMessageStore + sandboxBindings SandboxBindingReader + imHistoryEndpoint string + imHistoryToken func(conversationID string) string + log *slog.Logger } // ExecutionSnapshotRecorder is satisfied by *store.Store. @@ -242,26 +248,27 @@ func New(cfg Config) *Connector { } } return &Connector{ - registry: cfg.Registry, - binder: cfg.Binder, - sandbox: cfg.Sandbox, - ownerResolver: cfg.OwnerResolver, - ownerPodID: cfg.OwnerPodID, - remote: cfg.Remote, - remoteSubmit: cfg.RemoteSubmit, - submitSlots: cfg.SubmitSlots, - modelResolver: cfg.ModelResolver, - executionRecorder: cfg.ExecutionRecorder, - runStatus: cfg.RunStatusReader, - secrets: cfg.Secrets, - capabilities: cfg.Capabilities, - specMemory: cfg.SpecMemory, - oss: cfg.OSS, - systemMessages: cfg.SystemMessages, - sandboxBindings: cfg.SandboxBindingReader, - imHistoryEndpoint: cfg.IMHistoryEndpoint, - imHistoryToken: cfg.IMHistoryTokenSigner, - log: cfg.Log, + registry: cfg.Registry, + binder: cfg.Binder, + sandbox: cfg.Sandbox, + ownerResolver: cfg.OwnerResolver, + ownerPodID: cfg.OwnerPodID, + remote: cfg.Remote, + remoteSubmit: cfg.RemoteSubmit, + submitSlots: cfg.SubmitSlots, + modelResolver: cfg.ModelResolver, + conversationHistory: cfg.ConversationHistory, + executionRecorder: cfg.ExecutionRecorder, + runStatus: cfg.RunStatusReader, + secrets: cfg.Secrets, + capabilities: cfg.Capabilities, + specMemory: cfg.SpecMemory, + oss: cfg.OSS, + systemMessages: cfg.SystemMessages, + sandboxBindings: cfg.SandboxBindingReader, + imHistoryEndpoint: cfg.IMHistoryEndpoint, + imHistoryToken: cfg.IMHistoryTokenSigner, + log: cfg.Log, } } @@ -454,6 +461,10 @@ func (c *Connector) streamPrompt(ctx context.Context, in connector.PromptInput, } kindInfo, _, _ := sess.AgentKindStatus(agentKind) c.recordExecutionSnapshot(ctx, in, bind, agentKind, kindInfo) + // Runs here rather than in buildAgentOptions: the resume capability is + // a property of the device that will execute the run, and the heartbeat + // descriptor only exists once its session is resolved. + c.applyConversationHistoryInjection(ctx, agentOptions, in, kindInfo) upstream, err := sess.Subscribe(in.RunID) if err != nil { diff --git a/server/internal/connector/agentdaemon/history_injection.go b/server/internal/connector/agentdaemon/history_injection.go new file mode 100644 index 00000000..4cd210e2 --- /dev/null +++ b/server/internal/connector/agentdaemon/history_injection.go @@ -0,0 +1,182 @@ +// Server-side conversation history for engines that cannot resume. +// +// claude_code, codex and pi keep their own conversation state and get an +// upstream session id back through agent_engine_sessions, so the daemon +// replays nothing for them. opencode and deepseek_harness advertise +// Capabilities.Resume=false: every prompt is a fresh engine session, so +// without this injection turn two has no idea what turn one said. +// +// The transcript is folded into the system-prompt slot, which every adapter +// already forwards (as --append-system-prompt, or prepended to the task for +// the engines with no system-prompt flag). It is deliberately a bounded tail +// rather than the whole conversation: these engines have no prompt-cache +// reuse, so every injected byte is paid for on every turn. +package agentdaemon + +import ( + "context" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +const ( + // historyTurnLimit bounds how many stored turns are read and rendered. + historyTurnLimit = 12 + + // historyTotalBudgetBytes caps the rendered block. Oldest turns are + // dropped first once the budget is exhausted. + historyTotalBudgetBytes = 6000 + + // historyMessageBudgetBytes caps one turn so a single pasted log cannot + // consume the whole block. + historyMessageBudgetBytes = 800 +) + +const historyHeader = `## Earlier turns in this conversation + +You start every turn without memory of previous ones, so the recent +exchange is reproduced below for context. It is history, not a new +request: do not answer it again, and do not repeat work already done.` + +// ConversationHistoryReader is the narrow read surface the injection needs. +// Satisfied by *store.Store. +type ConversationHistoryReader interface { + ListRecentConversationHistory(ctx context.Context, conversationID string, limit int32) ([]store.ConversationHistoryMessage, error) +} + +// applyConversationHistoryInjection appends the recent transcript to +// opts["system_prompt"] when the bound engine cannot resume its own session. +// +// The gate is the device's live heartbeat descriptor rather than a +// server-side list of engine names, so an engine that gains resume support +// stops getting a duplicate transcript the moment it advertises it. +// +// Fail-soft: a read error is logged and swallowed. Losing context degrades +// an answer; failing the prompt loses the turn. +func (c *Connector) applyConversationHistoryInjection( + ctx context.Context, + opts map[string]any, + in connector.PromptInput, + info store.AgentDaemonSupportedAgentKind, +) { + if c.conversationHistory == nil || opts == nil { + return + } + if info.Capabilities.Resume { + return + } + // An explicit override owns the whole system prompt, mirroring + // applySpecMemoryInjection and applyIMHistoryPromptInjection. + if stringFromMap(opts, "override_system_prompt") != "" { + return + } + if strings.TrimSpace(in.ConversationID) == "" { + return + } + + messages, err := c.conversationHistory.ListRecentConversationHistory(ctx, in.ConversationID, historyTurnLimit) + if err != nil { + c.log.Warn("agent_daemon: conversation history read failed; proceeding without transcript", + "run_id", in.RunID, "conversation_id", in.ConversationID, "err", err.Error()) + return + } + block := renderConversationHistory(messages, in.TriggerMessageID, in.TriggerMessageContent) + if block == "" { + return + } + base := stringFromMap(opts, "system_prompt") + if base == "" { + opts["system_prompt"] = block + } else { + opts["system_prompt"] = base + "\n\n" + block + } + c.log.Info("agent_daemon: conversation history injected", + "run_id", in.RunID, + "agent_kind", info.Kind, + "turn_count", len(messages), + "block_bytes", len(block)) +} + +// renderConversationHistory renders stored turns oldest-first, excluding the +// message that triggered this run. Returns "" when nothing is left to say. +func renderConversationHistory(messages []store.ConversationHistoryMessage, triggerMessageID, triggerContent string) string { + lines := make([]string, 0, len(messages)) + for _, msg := range messages { + if isTriggerMessage(msg, triggerMessageID, triggerContent) { + continue + } + content := strings.TrimSpace(msg.Content) + if content == "" { + continue + } + lines = append(lines, historySpeaker(msg.SenderType)+": "+truncateHistoryText(content, historyMessageBudgetBytes)) + } + if len(lines) == 0 { + return "" + } + // Drop from the oldest end until the block fits; the newest turns are + // the ones the next answer depends on. + budget := historyTotalBudgetBytes - len(historyHeader) + for len(lines) > 1 && historyBlockSize(lines) > budget { + lines = lines[1:] + } + if historyBlockSize(lines) > budget { + lines[0] = truncateHistoryText(lines[0], budget) + } + return historyHeader + "\n\n" + strings.Join(lines, "\n\n") +} + +func historyBlockSize(lines []string) int { + total := 0 + for _, line := range lines { + total += len(line) + 2 + } + return total +} + +// isTriggerMessage reports whether a stored turn is the task this run is +// already carrying. The id is authoritative; the content comparison only +// covers callers that synthesize a prompt without a stored message id, and +// tolerates the gateway's quoted-chain prefix, which rides on the dispatched +// content but not on the stored row. +func isTriggerMessage(msg store.ConversationHistoryMessage, triggerMessageID, triggerContent string) bool { + if id := strings.TrimSpace(triggerMessageID); id != "" { + return msg.ID == id + } + stored := strings.TrimSpace(msg.Content) + trigger := strings.TrimSpace(triggerContent) + if stored == "" || trigger == "" { + return false + } + return stored == trigger || strings.HasSuffix(trigger, stored) +} + +func historySpeaker(senderType string) string { + switch strings.TrimSpace(senderType) { + case "agent": + return "Assistant" + default: + // user + external (unregistered IM sender) are both humans here. + return "User" + } +} + +func truncateHistoryText(text string, budget int) string { + if budget <= 0 || len(text) <= budget { + return text + } + const marker = "… [truncated]" + if budget <= len(marker) { + return text[:budget] + } + cut := budget - len(marker) + // Trim a partial UTF-8 sequence rather than emitting a broken rune. + for cut > 0 && !utf8Boundary(text[cut]) { + cut-- + } + return text[:cut] + marker +} + +func utf8Boundary(b byte) bool { return b&0xC0 != 0x80 } diff --git a/server/internal/connector/agentdaemon/history_injection_test.go b/server/internal/connector/agentdaemon/history_injection_test.go new file mode 100644 index 00000000..4370f528 --- /dev/null +++ b/server/internal/connector/agentdaemon/history_injection_test.go @@ -0,0 +1,248 @@ +package agentdaemon + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type fakeHistoryReader struct { + messages []store.ConversationHistoryMessage + err error + calls int + gotLimit int32 + gotConv string +} + +func (f *fakeHistoryReader) ListRecentConversationHistory(_ context.Context, conversationID string, limit int32) ([]store.ConversationHistoryMessage, error) { + f.calls++ + f.gotConv = conversationID + f.gotLimit = limit + return f.messages, f.err +} + +func historyMessages() []store.ConversationHistoryMessage { + base := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + return []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "user", Content: "add a health endpoint", CreatedAt: base}, + {ID: "m2", SenderType: "agent", Content: "added /healthz in api.go", CreatedAt: base.Add(time.Minute)}, + {ID: "m3", SenderType: "user", Content: "now add a readiness probe", CreatedAt: base.Add(2 * time.Minute)}, + } +} + +func historyInput() connector.PromptInput { + return connector.PromptInput{ + RunID: "run-1", + ConversationID: "conv-1", + AgentID: "agt-1", + TriggerMessageID: "m3", + TriggerMessageContent: "now add a readiness probe", + } +} + +func noResumeKind() store.AgentDaemonSupportedAgentKind { + return store.AgentDaemonSupportedAgentKind{ + Kind: "deepseek_harness", + Available: true, + } +} + +func resumeKind() store.AgentDaemonSupportedAgentKind { + info := store.AgentDaemonSupportedAgentKind{Kind: "claude_code", Available: true} + info.Capabilities.Resume = true + return info +} + +// The transcript exists only for engines that start every turn from zero. +// An engine that resumes its own session would receive the same history +// twice — once from its session, once from us. +func TestApplyConversationHistoryInjection_ResumeCapableEngineSkipped(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), resumeKind()) + + if reader.calls != 0 { + t.Fatalf("resume-capable engine must not even read history; calls=%d", reader.calls) + } + if _, ok := opts["system_prompt"]; ok { + t.Fatalf("system_prompt must stay absent: %#v", opts) + } +} + +func TestApplyConversationHistoryInjection_NoResumeEngineGetsTranscript(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{"system_prompt": "be terse"} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if reader.calls != 1 || reader.gotConv != "conv-1" { + t.Fatalf("reader calls=%d conv=%q", reader.calls, reader.gotConv) + } + if reader.gotLimit != historyTurnLimit { + t.Fatalf("limit = %d, want %d", reader.gotLimit, historyTurnLimit) + } + prompt, _ := opts["system_prompt"].(string) + if !strings.HasPrefix(prompt, "be terse\n\n") { + t.Fatalf("existing system prompt must be preserved first: %q", prompt) + } + if !strings.Contains(prompt, "User: add a health endpoint") { + t.Fatalf("missing user turn: %q", prompt) + } + if !strings.Contains(prompt, "Assistant: added /healthz in api.go") { + t.Fatalf("missing assistant turn: %q", prompt) + } + // The current task is already the prompt; echoing it as history would + // invite the engine to answer it twice. + if strings.Contains(prompt, "now add a readiness probe") { + t.Fatalf("trigger message must be excluded: %q", prompt) + } +} + +func TestApplyConversationHistoryInjection_OverrideSystemPromptWins(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{"override_system_prompt": "you are root"} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if reader.calls != 0 { + t.Fatalf("override must short-circuit before the read; calls=%d", reader.calls) + } + if _, ok := opts["system_prompt"]; ok { + t.Fatalf("override must not gain a system_prompt: %#v", opts) + } +} + +// A history read failure costs context; failing the prompt costs the turn. +func TestApplyConversationHistoryInjection_ReadErrorIsSwallowed(t *testing.T) { + reader := &fakeHistoryReader{err: errors.New("db down")} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{"system_prompt": "base"} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if got := opts["system_prompt"]; got != "base" { + t.Fatalf("system_prompt = %v, want untouched", got) + } +} + +func TestApplyConversationHistoryInjection_FirstTurnAddsNothing(t *testing.T) { + // Only the trigger message exists yet. + reader := &fakeHistoryReader{messages: []store.ConversationHistoryMessage{ + {ID: "m3", SenderType: "user", Content: "now add a readiness probe"}, + }} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if _, ok := opts["system_prompt"]; ok { + t.Fatalf("a first turn has no history to inject: %#v", opts) + } +} + +func TestApplyConversationHistoryInjection_NilReaderIsNoOp(t *testing.T) { + c := &Connector{log: discardLogger()} + opts := map[string]any{} + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + if len(opts) != 0 { + t.Fatalf("unwired reader must change nothing: %#v", opts) + } +} + +func TestRenderConversationHistory_OldestFirstAndBounded(t *testing.T) { + long := strings.Repeat("x", historyMessageBudgetBytes*2) + messages := []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "user", Content: "first"}, + {ID: "m2", SenderType: "external", Content: "im guest asks"}, + {ID: "m3", SenderType: "agent", Content: long}, + {ID: "m4", SenderType: "user", Content: " "}, + } + block := renderConversationHistory(messages, "", "unrelated trigger") + + firstIdx := strings.Index(block, "first") + guestIdx := strings.Index(block, "im guest asks") + if firstIdx == -1 || guestIdx == -1 || firstIdx > guestIdx { + t.Fatalf("turns must render oldest-first: %q", block) + } + // An unregistered IM sender is still a human on the other side. + if !strings.Contains(block, "User: im guest asks") { + t.Fatalf("external sender must render as User: %q", block) + } + if !strings.Contains(block, "… [truncated]") { + t.Fatalf("oversized turn must be truncated: %q", block[:200]) + } + if strings.Contains(block, "User: ") { + t.Fatalf("blank turns must be dropped: %q", block) + } + if len(block) > historyTotalBudgetBytes { + t.Fatalf("block is %d bytes, over the %d budget", len(block), historyTotalBudgetBytes) + } +} + +func TestRenderConversationHistory_DropsOldestUntilItFits(t *testing.T) { + chunk := strings.Repeat("y", historyMessageBudgetBytes) + messages := make([]store.ConversationHistoryMessage, 0, historyTurnLimit) + for i := 0; i < historyTurnLimit; i++ { + messages = append(messages, store.ConversationHistoryMessage{ + ID: string(rune('a' + i)), + SenderType: "user", + Content: chunk, + }) + } + // Mark the newest turn so we can prove it survived the trim. + messages[len(messages)-1].Content = "NEWEST " + chunk + + block := renderConversationHistory(messages, "", "") + if len(block) > historyTotalBudgetBytes { + t.Fatalf("block is %d bytes, over the %d budget", len(block), historyTotalBudgetBytes) + } + if !strings.Contains(block, "NEWEST") { + t.Fatalf("the newest turn must survive the trim: %q", block[:200]) + } +} + +// Without a stored message id (synthesized prompts), the content fallback +// still has to recognise the task — including the gateway's quoted-chain +// prefix, which rides on the dispatched content but not on the stored row. +func TestRenderConversationHistory_TriggerFallbackHandlesQuotedPrefix(t *testing.T) { + messages := []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "agent", Content: "earlier answer"}, + {ID: "m2", SenderType: "user", Content: "please retry"}, + } + block := renderConversationHistory(messages, "", "[Quoted message] ...\n\nplease retry") + if strings.Contains(block, "please retry") { + t.Fatalf("quoted-prefixed trigger must still be excluded: %q", block) + } + if !strings.Contains(block, "earlier answer") { + t.Fatalf("older turns must remain: %q", block) + } +} + +func TestRenderConversationHistory_EmptyInputRendersNothing(t *testing.T) { + if got := renderConversationHistory(nil, "", ""); got != "" { + t.Fatalf("expected empty render, got %q", got) + } +} + +func TestTruncateHistoryTextKeepsValidUTF8(t *testing.T) { + text := strings.Repeat("汉", 40) + got := truncateHistoryText(text, 30) + if len(got) > 30 { + t.Fatalf("truncated text is %d bytes, over budget", len(got)) + } + if !strings.HasSuffix(got, "… [truncated]") { + t.Fatalf("expected truncation marker, got %q", got) + } + if strings.ContainsRune(got, '\uFFFD') { + t.Fatalf("truncation broke a rune: %q", got) + } +} diff --git a/server/internal/connector/agentdaemon/model_injection.go b/server/internal/connector/agentdaemon/model_injection.go index bb86dd11..e0b0b84c 100644 --- a/server/internal/connector/agentdaemon/model_injection.go +++ b/server/internal/connector/agentdaemon/model_injection.go @@ -286,6 +286,18 @@ func (c *Connector) injectManagedModel(ctx context.Context, in connector.PromptI "provider_slug", mr.ProviderType, "pi_model", stringFromMap(opts, "model")) return nil + case "deepseek_harness": + if err := injectDeepseekHarnessManagedModel(opts, modelID, mr, apiKey); err != nil { + return err + } + c.log.Info("agent_daemon: injectManagedModel ok", + "run_id", in.RunID, + "agent_kind", agentKind, + "model_id", modelID, + "model_key", mr.ModelKey, + "provider_slug", mr.ProviderType, + "dsh_model", stringFromMap(opts, "model")) + return nil default: return fmt.Errorf("%w: %q", ErrUnsupportedAgentKind, agentKind) } diff --git a/server/internal/connector/agentdaemon/model_injection_deepseek.go b/server/internal/connector/agentdaemon/model_injection_deepseek.go new file mode 100644 index 00000000..4c34d2af --- /dev/null +++ b/server/internal/connector/agentdaemon/model_injection_deepseek.go @@ -0,0 +1,77 @@ +package agentdaemon + +import ( + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +// deepseekHarnessAPIKeyEnv is the env var the daemon sets to the decrypted +// secret and that the materialised dsh patch overlay references through the +// route's apiKeyEnv field. Carrying the key by env-var name keeps it off the +// dsh child's argv, where `ps` would leak it. +const deepseekHarnessAPIKeyEnv = "PARSAR_DSH_API_KEY" + +// injectDeepseekHarnessManagedModel stamps the Parsar-managed model into an +// agent_kind="deepseek_harness" prompt_request. +// +// DeepSeek Harness resolves its model through the `agent-default-model` row, +// which must name a live llm route. Its shipped DeepSeek route hard-codes the +// upstream endpoint, so a Parsar gateway model has to arrive as a declared +// route on the harness's generic `llm-pi-ai` adapter instead. That adapter is +// backed by the same @earendil-works/pi-ai library the pi CLI uses, so the +// wire-protocol mapping is shared with injectPiManagedModel. +// +// What lands in agent_options: +// +// dsh_provider: +// base_url — mr.BaseURL (required; a declared route has no default) +// api — pi-ai wire protocol (anthropic-messages / +// openai-completions / google-generative-ai) +// api_key_env — deepseekHarnessAPIKeyEnv, referenced by the route +// model — mr.ModelKey, the route's single catalog entry +// name — mr.ModelName (display, optional) +// headers — flattened mr.ProviderConfig.headers (e.g. X-Sub-Module) +// model — mr.ModelKey, pinned on agent-default-model +// env[deepseekHarnessAPIKeyEnv] — the decrypted secret +// +// All guards run before any opts mutation so a rejection leaves opts clean. +func injectDeepseekHarnessManagedModel(opts map[string]any, modelID string, mr store.ModelRuntime, apiKey string) error { + api := piAPIProtocol(mr) + if api == "" { + return fmt.Errorf("%w: model_id=%s provider_type=%q adapter=%q", + ErrManagedModelUnsupported, modelID, mr.ProviderType, mr.Adapter) + } + modelKey := strings.TrimSpace(mr.ModelKey) + if modelKey == "" { + return fmt.Errorf("%w: model_id=%s deepseek_harness requires a model_key", + ErrManagedModelConfigInvalid, modelID) + } + baseURL := modelEndpointBaseURL(mr, piAPIEndpointType(mr)) + if baseURL == "" { + return fmt.Errorf("%w: model_id=%s base_url is required for deepseek_harness provider injection", + ErrManagedModelConfigInvalid, modelID) + } + + provider := map[string]any{ + "base_url": baseURL, + "api": api, + "api_key_env": deepseekHarnessAPIKeyEnv, + "model": modelKey, + } + if name := strings.TrimSpace(mr.ModelName); name != "" { + provider["name"] = name + } + if headers := flattenStringMap(mr.ProviderConfig, "headers"); len(headers) > 0 { + provider["headers"] = headers + } + + opts["model"] = modelKey + opts["dsh_provider"] = provider + + env := copyStringAnyMap(opts["env"]) + env[deepseekHarnessAPIKeyEnv] = apiKey + opts["env"] = env + return nil +} diff --git a/server/internal/connector/agentdaemon/model_injection_deepseek_test.go b/server/internal/connector/agentdaemon/model_injection_deepseek_test.go new file mode 100644 index 00000000..fa25e4e1 --- /dev/null +++ b/server/internal/connector/agentdaemon/model_injection_deepseek_test.go @@ -0,0 +1,213 @@ +package agentdaemon + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/binding" + "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +// TestInjectDeepseekHarnessManagedModel_HappyPath pins the contract the dsh +// adapter materialises into its `--patch` overlay: a declared llm-pi-ai route +// carrying base_url + wire protocol + headers, the model key pinned for +// agent-default-model, and the secret delivered only through opts["env"] +// under the name the route references. +func TestInjectDeepseekHarnessManagedModel_HappyPath(t *testing.T) { + opts := map[string]any{ + "env": map[string]any{"OTHER_FLAG": "kept"}, + } + mr := store.ModelRuntime{ + ModelID: "model-ds", + ModelKey: "deepseek-v4", + ModelName: "DeepSeek V4", + ProviderType: "openai-compatible", + Adapter: "@ai-sdk/openai-compatible", + BaseURL: "https://gateway.example.com/v1", + ProviderConfig: map[string]any{ + "headers": map[string]any{"X-Sub-Module": "parsar"}, + }, + } + if err := injectDeepseekHarnessManagedModel(opts, mr.ModelID, mr, "sk-dsh"); err != nil { + t.Fatalf("injectDeepseekHarnessManagedModel: %v", err) + } + if got := opts["model"]; got != "deepseek-v4" { + t.Fatalf("opts[model] = %v, want deepseek-v4", got) + } + provider, ok := opts["dsh_provider"].(map[string]any) + if !ok { + t.Fatalf("opts[dsh_provider] has type %T, want map[string]any", opts["dsh_provider"]) + } + if got := provider["base_url"]; got != "https://gateway.example.com/v1" { + t.Fatalf("dsh_provider.base_url = %v, want the platform base_url", got) + } + if got := provider["api"]; got != "openai-completions" { + t.Fatalf("dsh_provider.api = %v, want openai-completions", got) + } + if got := provider["api_key_env"]; got != "PARSAR_DSH_API_KEY" { + t.Fatalf("dsh_provider.api_key_env = %v, want PARSAR_DSH_API_KEY", got) + } + if got := provider["model"]; got != "deepseek-v4" { + t.Fatalf("dsh_provider.model = %v, want deepseek-v4", got) + } + if got := provider["name"]; got != "DeepSeek V4" { + t.Fatalf("dsh_provider.name = %v, want DeepSeek V4", got) + } + headers, ok := provider["headers"].(map[string]string) + if !ok { + t.Fatalf("dsh_provider.headers has type %T, want map[string]string", provider["headers"]) + } + if got := headers["X-Sub-Module"]; got != "parsar" { + t.Fatalf("dsh_provider.headers[X-Sub-Module] = %q", got) + } + // The key rides the environment only: the overlay file the daemon + // writes references it by name, so it never lands on dsh's argv. + if _, ok := provider["api_key"]; ok { + t.Fatalf("dsh_provider must not carry the raw key: %+v", provider) + } + env, ok := opts["env"].(map[string]any) + if !ok { + t.Fatalf("opts[env] has type %T", opts["env"]) + } + if got := env["PARSAR_DSH_API_KEY"]; got != "sk-dsh" { + t.Fatalf("env[PARSAR_DSH_API_KEY] = %v, want sk-dsh", got) + } + if got := env["OTHER_FLAG"]; got != "kept" { + t.Fatalf("existing env must survive the merge: %+v", env) + } +} + +// TestInjectDeepseekHarnessManagedModel_ProviderMapping pins the +// provider_type / endpoint-type → pi-ai wire protocol mapping the harness's +// llm-pi-ai adapter shares with the pi CLI. +func TestInjectDeepseekHarnessManagedModel_ProviderMapping(t *testing.T) { + cases := []struct { + name string + mr store.ModelRuntime + wantAPI string + }{ + { + name: "anthropic", + mr: store.ModelRuntime{ModelKey: "claude-opus-4-7", ProviderType: "anthropic", BaseURL: "https://x.example/anthropic"}, + wantAPI: "anthropic-messages", + }, + { + name: "openai", + mr: store.ModelRuntime{ModelKey: "gpt-4o", ProviderType: "openai", BaseURL: "https://x.example/v1"}, + wantAPI: "openai-completions", + }, + { + name: "google", + mr: store.ModelRuntime{ModelKey: "gemini-2.5-pro", ProviderType: "google", BaseURL: "https://x.example"}, + wantAPI: "google-generative-ai", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := map[string]any{} + if err := injectDeepseekHarnessManagedModel(opts, "model-x", tc.mr, "sk-x"); err != nil { + t.Fatalf("inject: %v", err) + } + provider, ok := opts["dsh_provider"].(map[string]any) + if !ok { + t.Fatalf("opts[dsh_provider] has type %T", opts["dsh_provider"]) + } + if got := provider["api"]; got != tc.wantAPI { + t.Fatalf("dsh_provider.api = %v, want %v", got, tc.wantAPI) + } + }) + } +} + +// A route the harness has to declare itself needs base_url, api and a model +// id, so each missing piece fails at the server boundary and leaves opts +// clean rather than shipping an overlay dsh refuses at boot. +func TestInjectDeepseekHarnessManagedModel_RejectsIncompleteRuntime(t *testing.T) { + cases := []struct { + name string + mr store.ModelRuntime + wantErr error + }{ + { + name: "unmapped provider", + mr: store.ModelRuntime{ModelKey: "cmd-r", ProviderType: "cohere", BaseURL: "https://x.example"}, + wantErr: ErrManagedModelUnsupported, + }, + { + name: "missing model key", + mr: store.ModelRuntime{ProviderType: "openai", BaseURL: "https://x.example/v1"}, + wantErr: ErrManagedModelConfigInvalid, + }, + { + name: "missing base url", + mr: store.ModelRuntime{ModelKey: "gpt-4o", ProviderType: "openai"}, + wantErr: ErrManagedModelConfigInvalid, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := map[string]any{} + err := injectDeepseekHarnessManagedModel(opts, "model-x", tc.mr, "sk-x") + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + if len(opts) != 0 { + t.Fatalf("rejection must leave opts clean: %+v", opts) + } + }) + } +} + +// TestInjectManagedModel_DeepseekHarnessSwitchWired drives the full +// c.injectManagedModel path so a missing `case "deepseek_harness"` (which +// would fall through to ErrUnsupportedAgentKind and break every run) is +// caught here. +func TestInjectManagedModel_DeepseekHarnessSwitchWired(t *testing.T) { + svc, err := secrets.New("test-master-key") + if err != nil { + t.Fatal(err) + } + enc, err := svc.Encrypt(map[string]any{"api_key": "sk-dsh-platform"}) + if err != nil { + t.Fatal(err) + } + resolver := fakeModelResolver{ + runtime: store.ModelRuntime{ + ModelID: "model-ds", + ModelKey: "deepseek-v4", + ProviderType: "openai-compatible", + Adapter: "@ai-sdk/openai-compatible", + BaseURL: "https://gateway.example.com/v1", + SecretID: "secret-ds", + }, + secret: store.SecretPayload{SecretRead: store.SecretRead{Status: "active"}, EncryptedPayload: enc}, + } + c := New(Config{ + Registry: gateway.NewRegistry(), + Binder: binding.NewInMemoryBinder(), + ModelResolver: &resolver, + Secrets: svc, + }) + + in := basicInput() + in.WorkspaceID = "ws-1" + in.AgentConfig = map[string]any{"agent_kind": "deepseek_harness", "model_id": "model-ds"} + opts := renderStaticAgentOptions(in) + + if err := c.injectManagedModel(context.Background(), in, opts, "deepseek_harness"); err != nil { + t.Fatalf("injectManagedModel: %v", err) + } + if got := opts["model"]; got != "deepseek-v4" { + t.Fatalf("opts[model] = %v, want deepseek-v4", got) + } + if _, ok := opts["dsh_provider"].(map[string]any); !ok { + t.Fatalf("opts[dsh_provider] must be set, got %T", opts["dsh_provider"]) + } + env, _ := opts["env"].(map[string]any) + if got := env["PARSAR_DSH_API_KEY"]; got != "sk-dsh-platform" { + t.Fatalf("env[PARSAR_DSH_API_KEY] = %v, want the decrypted key", got) + } +} diff --git a/server/internal/connector/agentdaemon/sandbox_seed.go b/server/internal/connector/agentdaemon/sandbox_seed.go index cd2e3afe..ad95a121 100644 --- a/server/internal/connector/agentdaemon/sandbox_seed.go +++ b/server/internal/connector/agentdaemon/sandbox_seed.go @@ -28,10 +28,11 @@ import ( type SandboxConnector string const ( - SandboxConnectorClaude SandboxConnector = "claude" - SandboxConnectorOpenCode SandboxConnector = "opencode" - SandboxConnectorCodex SandboxConnector = "codex" - SandboxConnectorPi SandboxConnector = "pi" + SandboxConnectorClaude SandboxConnector = "claude" + SandboxConnectorOpenCode SandboxConnector = "opencode" + SandboxConnectorCodex SandboxConnector = "codex" + SandboxConnectorPi SandboxConnector = "pi" + SandboxConnectorDeepseekHarness SandboxConnector = "deepseek-harness" ) // In-image absolute paths to the hook scripts baked by @@ -116,6 +117,8 @@ func ConnectorForAgentKind(agentKind string) SandboxConnector { return SandboxConnectorOpenCode case "pi": return SandboxConnectorPi + case "deepseek_harness": + return SandboxConnectorDeepseekHarness default: // claude_code, "", and anything unknown → Claude return SandboxConnectorClaude @@ -151,6 +154,11 @@ func seedPlatformConfig(ctx context.Context, client E2BClient, sb e2b.Sandbox, c // available in the image; daemon discovers and registers it // via heartbeat. return nil + case SandboxConnectorDeepseekHarness: + // dsh has no per-turn hook surface, and the daemon adapter + // prepends the spec/memory bundle to the task text instead, so + // nothing has to be seeded into the sandbox filesystem. + return nil default: return fmt.Errorf("sandbox_seed: unknown connector %q", conn) } diff --git a/server/internal/connector/agentdaemon/sandbox_seed_test.go b/server/internal/connector/agentdaemon/sandbox_seed_test.go index 150de20b..7fd723d7 100644 --- a/server/internal/connector/agentdaemon/sandbox_seed_test.go +++ b/server/internal/connector/agentdaemon/sandbox_seed_test.go @@ -79,11 +79,12 @@ func TestRenderClaudeSettings(t *testing.T) { // scripts would either error out or pick the wrong inject contract. func TestConnectorTagFor(t *testing.T) { cases := map[SandboxConnector]string{ - "": "claude", - SandboxConnectorClaude: "claude", - SandboxConnectorOpenCode: "opencode", - SandboxConnectorCodex: "codex", - SandboxConnectorPi: "pi", + "": "claude", + SandboxConnectorClaude: "claude", + SandboxConnectorOpenCode: "opencode", + SandboxConnectorCodex: "codex", + SandboxConnectorPi: "pi", + SandboxConnectorDeepseekHarness: "deepseek-harness", } for in, want := range cases { if got := connectorTagFor(in); got != want { @@ -100,13 +101,14 @@ func TestConnectorTagFor(t *testing.T) { // (e.g. "opencode" → "open_code") would have to touch this test too. func TestConnectorForAgentKind(t *testing.T) { cases := map[string]SandboxConnector{ - "": SandboxConnectorClaude, - "claude_code": SandboxConnectorClaude, - "codex": SandboxConnectorCodex, - "opencode": SandboxConnectorOpenCode, - "pi": SandboxConnectorPi, - " pi ": SandboxConnectorPi, // TrimSpace applied - "bogus": SandboxConnectorClaude, + "": SandboxConnectorClaude, + "claude_code": SandboxConnectorClaude, + "codex": SandboxConnectorCodex, + "opencode": SandboxConnectorOpenCode, + "pi": SandboxConnectorPi, + "deepseek_harness": SandboxConnectorDeepseekHarness, + " pi ": SandboxConnectorPi, // TrimSpace applied + "bogus": SandboxConnectorClaude, } for in, want := range cases { if got := ConnectorForAgentKind(in); got != want { @@ -133,6 +135,7 @@ func TestSeedPlatformConfig_DispatchTable(t *testing.T) { {"opencode noop until template exists", SandboxConnectorOpenCode, 0, false}, {"codex noop until template exists", SandboxConnectorCodex, 0, false}, {"pi noop until template exists", SandboxConnectorPi, 0, false}, + {"deepseek harness needs no seed", SandboxConnectorDeepseekHarness, 0, false}, {"unknown connector errors", SandboxConnector("totally-bogus"), 0, true}, } for _, tc := range cases { diff --git a/server/internal/connector/types.go b/server/internal/connector/types.go index 14e998df..2fc4e393 100644 --- a/server/internal/connector/types.go +++ b/server/internal/connector/types.go @@ -52,6 +52,12 @@ type PromptInput struct { AgentName string AgentSlug string + // TriggerMessageID is the persisted message id behind + // TriggerMessageContent. Empty for callers that synthesize a prompt + // without a stored message. Connectors use it to tell the current + // task apart from the conversation's stored transcript. + TriggerMessageID string + // TriggerMessageContent is the user-facing message that drives this // prompt. The connector decides how to fold it into its own prompt // schema. @@ -192,7 +198,7 @@ type PermissionRequest struct { // PermissionDecision is the human verdict for a PermissionRequest, // submitted via AgentConnector.SubmitPermission. type PermissionDecision struct { - RequestID string + RequestID string // DeliveryID is the caller's stable idempotency base. The agent-daemon // connector adds a unique suffix for each wire attempt before awaiting ack. DeliveryID string @@ -285,7 +291,7 @@ type PromptForUserChoiceQuestionAnswer struct { // - Cancelled=true marks a non-answer (timeout, /cancel) so the // daemon can emit a "stop, don't retry" tool_result. type PromptForUserChoiceDecision struct { - RequestID string + RequestID string // DeliveryID follows PermissionDecision's stable-base semantics. DeliveryID string DeviceID string diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index 3d891dd7..7d427755 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -710,6 +710,7 @@ select -- the config->>'runtime' connector override is dead. r.connector_type as connector_type, r.status, + coalesce(r.trigger_message_id::text, ''::text)::text as trigger_message_id, coalesce(m.content, ''::text)::text as trigger_message_content, coalesce(m.metadata, '{}'::jsonb)::jsonb as trigger_message_metadata, a.config::jsonb as agent_config, @@ -960,6 +961,28 @@ where m.conversation_id = @conversation_id::uuid order by m.created_at asc, m.id asc limit @item_limit; +-- name: ListRecentConversationMessages :many +-- Newest-first slice of the human/agent chat turns in one conversation. +-- Feeds the server-side history injection for daemon engines that cannot +-- resume their own session; ordered desc + limit so a long conversation +-- does not stream every row into the prompt path. +select + m.id::text, + m.sender_type, + coalesce(m.sender_id::text, ''::text)::text as m_sender_id, + m.content, + m.created_at +from messages m +join conversations c on c.id = m.conversation_id +where m.conversation_id = @conversation_id::uuid + and m.workspace_id = c.workspace_id + and m.deleted_at is null + and c.deleted_at is null + and m.kind = 'message' + and m.sender_type in ('user', 'agent', 'external') +order by m.created_at desc, m.id desc +limit @item_limit; + -- name: ListConversationAgentRuns :many select r.id::text, diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index 6f73a0ac..eafc03ab 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -4641,6 +4641,7 @@ select -- the config->>'runtime' connector override is dead. r.connector_type as connector_type, r.status, + coalesce(r.trigger_message_id::text, ''::text)::text as trigger_message_id, coalesce(m.content, ''::text)::text as trigger_message_content, coalesce(m.metadata, '{}'::jsonb)::jsonb as trigger_message_metadata, a.config::jsonb as agent_config, @@ -4681,6 +4682,7 @@ type GetAgentRunInvocationRow struct { RequestedByID string `json:"requested_by_id"` ConnectorType string `json:"connector_type"` Status string `json:"status"` + TriggerMessageID string `json:"trigger_message_id"` TriggerMessageContent string `json:"trigger_message_content"` TriggerMessageMetadata []byte `json:"trigger_message_metadata"` AgentConfig []byte `json:"agent_config"` @@ -4701,6 +4703,7 @@ func (q *Queries) GetAgentRunInvocation(ctx context.Context, id pgtype.UUID) (Ge &i.RequestedByID, &i.ConnectorType, &i.Status, + &i.TriggerMessageID, &i.TriggerMessageContent, &i.TriggerMessageMetadata, &i.AgentConfig, @@ -8732,6 +8735,68 @@ func (q *Queries) ListPendingWorkspaceInvitationsByInviter(ctx context.Context, return items, nil } +const listRecentConversationMessages = `-- name: ListRecentConversationMessages :many +select + m.id::text, + m.sender_type, + coalesce(m.sender_id::text, ''::text)::text as m_sender_id, + m.content, + m.created_at +from messages m +join conversations c on c.id = m.conversation_id +where m.conversation_id = $1::uuid + and m.workspace_id = c.workspace_id + and m.deleted_at is null + and c.deleted_at is null + and m.kind = 'message' + and m.sender_type in ('user', 'agent', 'external') +order by m.created_at desc, m.id desc +limit $2 +` + +type ListRecentConversationMessagesParams struct { + ConversationID pgtype.UUID `json:"conversation_id"` + ItemLimit int32 `json:"item_limit"` +} + +type ListRecentConversationMessagesRow struct { + MID string `json:"m_id"` + SenderType string `json:"sender_type"` + MSenderID string `json:"m_sender_id"` + Content string `json:"content"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +// Newest-first slice of the human/agent chat turns in one conversation. +// Feeds the server-side history injection for daemon engines that cannot +// resume their own session; ordered desc + limit so a long conversation +// does not stream every row into the prompt path. +func (q *Queries) ListRecentConversationMessages(ctx context.Context, arg ListRecentConversationMessagesParams) ([]ListRecentConversationMessagesRow, error) { + rows, err := q.db.Query(ctx, listRecentConversationMessages, arg.ConversationID, arg.ItemLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRecentConversationMessagesRow{} + for rows.Next() { + var i ListRecentConversationMessagesRow + if err := rows.Scan( + &i.MID, + &i.SenderType, + &i.MSenderID, + &i.Content, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSandboxPoolEntriesDueForAutoRenew = `-- name: ListSandboxPoolEntriesDueForAutoRenew :many select sandbox_id, diff --git a/server/internal/dev/run_stream.go b/server/internal/dev/run_stream.go index 5e02d368..1a446b68 100644 --- a/server/internal/dev/run_stream.go +++ b/server/internal/dev/run_stream.go @@ -364,6 +364,7 @@ func dispatchConversationRun(ctx context.Context, runtimeStore RuntimeStore, cfg AgentName: invocation.AgentName, AgentSlug: invocation.AgentSlug, ConversationInitiatorID: userConversationInitiatorID(invocation), + TriggerMessageID: invocation.TriggerMessageID, TriggerMessageContent: invocation.TriggerMessageContent, TriggerAttachments: invocation.TriggerAttachments, AgentConfig: invocation.AgentConfig, diff --git a/server/internal/store/conversation_history.go b/server/internal/store/conversation_history.go new file mode 100644 index 00000000..c5b6f9e3 --- /dev/null +++ b/server/internal/store/conversation_history.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + "slices" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/db/sqlc" +) + +// ConversationHistoryMessage is one human/agent chat turn, trimmed to the +// fields a prompt-side transcript needs. +type ConversationHistoryMessage struct { + ID string `json:"id"` + SenderType string `json:"sender_type"` + SenderID string `json:"sender_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// ListRecentConversationHistory returns the newest `limit` chat turns of a +// conversation in oldest-first order. The query selects newest-first so a +// long conversation only reads the tail; the slice is reversed here because +// every consumer renders the transcript in reading order. +func (s *Store) ListRecentConversationHistory(ctx context.Context, conversationID string, limit int32) ([]ConversationHistoryMessage, error) { + if limit <= 0 { + return nil, nil + } + conversationUUID, err := uuid(conversationID) + if err != nil { + return nil, err + } + rows, err := sqlc.New(s.db).ListRecentConversationMessages(ctx, sqlc.ListRecentConversationMessagesParams{ + ConversationID: conversationUUID, + ItemLimit: limit, + }) + if err != nil { + return nil, err + } + out := make([]ConversationHistoryMessage, 0, len(rows)) + for _, row := range rows { + out = append(out, ConversationHistoryMessage{ + ID: row.MID, + SenderType: row.SenderType, + SenderID: row.MSenderID, + Content: row.Content, + CreatedAt: row.CreatedAt.Time, + }) + } + slices.Reverse(out) + return out, nil +} diff --git a/server/internal/store/conversation_history_test.go b/server/internal/store/conversation_history_test.go new file mode 100644 index 00000000..f57e7f3a --- /dev/null +++ b/server/internal/store/conversation_history_test.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "testing" +) + +// The prompt path reads this on every turn for engines that cannot resume, +// so the contract is narrow: newest turns only, oldest-first, human and +// agent chat turns only. +func TestListRecentConversationHistory(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + store := New(db) + ids := mustSeedDevFixture(t, ctx, store) + + send := func(content string) string { + t.Helper() + result, err := store.SendUserMessageToConversation(ctx, SendUserMessageToConversationInput{ + ConversationID: ids.ConversationID, + UserID: ids.UserID, + Content: content, + MentionedAgentIDs: []string{ids.ProductAgentID}, + }) + if err != nil { + t.Fatalf("send user message %q: %v", content, err) + } + if len(result.RunIDs) == 0 { + t.Fatalf("expected a run for %q", content) + } + return result.RunIDs[0] + } + + runID := send("@product-agent first question") + if _, err := store.SendAssistantMessageFromRun(ctx, SendAssistantMessageFromRunInput{ + RunID: runID, + Source: "agent", + Content: "first answer", + }); err != nil { + t.Fatalf("send assistant message: %v", err) + } + secondRunID := send("@product-agent second question") + + // A runtime_error notice is a system message, not a conversation turn: + // replaying it as history would teach the agent to answer Parsar's own + // plumbing messages. + if _, err := store.CreateRuntimeErrorSystemMessage(ctx, CreateRuntimeErrorSystemMessageInput{ + WorkspaceID: ids.WorkspaceID, + AgentID: ids.ProductAgentID, + RunID: secondRunID, + ConversationID: ids.ConversationID, + SubKind: "capability_credential_missing", + CapabilityID: "cap-1", + CapabilityName: "MCP · github", + CredentialKind: "github_token", + }); err != nil { + t.Fatalf("create runtime error system message: %v", err) + } + + history, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 10) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history) != 3 { + t.Fatalf("history length = %d, want 3 (2 user + 1 agent): %+v", len(history), history) + } + wantContents := []string{"@product-agent first question", "first answer", "@product-agent second question"} + for i, want := range wantContents { + if history[i].Content != want { + t.Fatalf("history[%d].Content = %q, want %q (full: %+v)", i, history[i].Content, want, history) + } + } + if history[0].SenderType != "user" || history[1].SenderType != "agent" { + t.Fatalf("sender types = %q/%q, want user/agent", history[0].SenderType, history[1].SenderType) + } + if history[0].ID == "" || history[0].CreatedAt.IsZero() { + t.Fatalf("history rows must carry id + created_at: %+v", history[0]) + } + if history[0].CreatedAt.After(history[2].CreatedAt) { + t.Fatalf("rows must be oldest-first: %+v", history) + } + + // A limit keeps the tail, not the head: the newest turns are the ones + // the next answer depends on. + tail, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 2) + if err != nil { + t.Fatalf("list history with limit: %v", err) + } + if len(tail) != 2 { + t.Fatalf("limited history length = %d, want 2", len(tail)) + } + if tail[0].Content != "first answer" || tail[1].Content != "@product-agent second question" { + t.Fatalf("limit must keep the newest turns oldest-first, got %+v", tail) + } + + if zero, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 0); err != nil || zero != nil { + t.Fatalf("non-positive limit must read nothing: rows=%+v err=%v", zero, err) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 8cfe74ea..11b1b0a4 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -384,16 +384,20 @@ type DeleteAgentResult struct { type HTTPAgentRunInvocation = AgentRunInvocation type AgentRunInvocation struct { - RunID string `json:"run_id"` - WorkspaceID string `json:"workspace_id"` - ConversationID string `json:"conversation_id"` - AgentID string `json:"agent_id"` - AgentName string `json:"agent_name"` - AgentSlug string `json:"agent_slug"` - RequestedByType string `json:"requested_by_type"` - RequestedByID string `json:"requested_by_id"` - ConnectorType string `json:"connector_type"` - Status string `json:"status"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + ConversationID string `json:"conversation_id"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` + AgentSlug string `json:"agent_slug"` + RequestedByType string `json:"requested_by_type"` + RequestedByID string `json:"requested_by_id"` + ConnectorType string `json:"connector_type"` + Status string `json:"status"` + // TriggerMessageID identifies the persisted message that started this + // run, so a prompt-side transcript can exclude it instead of echoing + // the task back to the engine. + TriggerMessageID string `json:"trigger_message_id,omitempty"` TriggerMessageContent string `json:"trigger_message_content"` // TriggerAttachments carries non-text payloads alongside // TriggerMessageContent. Connectors that don't forward attachments @@ -2067,6 +2071,7 @@ func (s *Store) GetAgentRunInvocation(ctx context.Context, runID string) (AgentR RequestedByID: row.RequestedByID, ConnectorType: row.ConnectorType, Status: row.Status, + TriggerMessageID: row.TriggerMessageID, TriggerMessageContent: applyTriggerMessagePrefix(triggerMetadata, row.TriggerMessageContent), TriggerAttachments: DecodeMessageAttachments(triggerMetadata), AgentConfig: mergeRuntimeIntoAgentConfig(decodeJSONMap(row.AgentConfig), row.RuntimeID), From 9414ce9c32d8ff58c75dfac8290ea7b293205990 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 20 Aug 2026 00:07:25 +0800 Subject: [PATCH 2/6] build: allow redirecting the image builders' module proxy and bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both image builds hardcoded docker.io for their builder stages and took Go's default module proxy, so a host that cannot reach proxy.golang.org or docker.io could not build either image at all — `go mod download` and the `FROM golang:`/`FROM node:` metadata lookups fail before any layer runs. Adds GOPROXY plus full-reference GO_IMAGE / NODE_IMAGE build args, the same escape hatch BASE_IMAGE and RUNTIME_BASE already provide for the runtime stages. Every default is byte-identical to the previous behaviour, so CI and release builds are unchanged: docker build -f infra/sandbox/Dockerfile \ --build-arg GOPROXY=https://goproxy.cn,direct \ --build-arg GO_IMAGE=/library/golang:1.25-bookworm \ --build-arg BASE_IMAGE=/library/ubuntu:22.04 . --- Dockerfile | 13 +++++++++++-- infra/sandbox/Dockerfile | 12 +++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 028a6031..0ecbfe40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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. diff --git a/infra/sandbox/Dockerfile b/infra/sandbox/Dockerfile index 95f51599..2da59681 100644 --- a/infra/sandbox/Dockerfile +++ b/infra/sandbox/Dockerfile @@ -39,6 +39,7 @@ # Build args: # --build-arg BASE_IMAGE= ubuntu:22.04 (local) or e2bdev/base:latest (e2b) # --build-arg GO_VERSION= builder Go image (default: matches repo go.work) +# --build-arg GO_IMAGE= full builder reference (default: golang:$GO_VERSION) # --build-arg CLAUDE_CODE_VERSION= pin claude code (default: latest) # --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) # --build-arg PI_VERSION= pin pi CLI (default: 0.80.6) @@ -51,9 +52,18 @@ ############################################################################### ARG GO_VERSION=1.25-bookworm ARG BASE_IMAGE=ubuntu:22.04 +# Full builder reference so a host that cannot reach docker.io can point it +# at a mirror (--build-arg GO_IMAGE=/library/golang:1.25-bookworm), +# mirroring what BASE_IMAGE already allows for the runtime stage. +ARG GO_IMAGE=golang:${GO_VERSION} -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-builder 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. From cc830813e677e8f3cc9578ac3c59c14b5d553b7f Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 20 Aug 2026 10:48:19 +0800 Subject: [PATCH 3/6] feat(runtime): drive DeepSeek Harness through a resident /api server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless surface cannot continue a conversation and emits no events, so the sandbox path now drives dsh through its /api gateway instead: one resident `dsh --profile parsar-api` per state key, bound to a loopback port inside the container. That gateway streams token-level deltas and tool events, reports token usage, and continues a conversation by prompting its session id — warm from memory, or loaded from the session log after a restart. Verified against a live rc.7 server, including cold resume across a process kill. The local-device path is deliberately unchanged. dsh's web server has no authentication of any kind: it gates requests on "the peer is on loopback" and nothing else. That is only a boundary where the loopback namespace is itself a boundary, so on a developer's own machine the adapter keeps the one-shot headless surface and the server keeps injecting prior turns. Rather than hardcode the supervision, this adds apps/parsar-daemon/internal/enginehost: an engine-agnostic layer owning per-key process reuse, loopback port assignment, readiness gating, idle reclamation and the HTTP/WebSocket transports. Adding the next server-backed engine means filling in a ServerSpec. The capability descriptor is now computed from the run location, because the server keys its conversation-history injection off Resume: reporting resume on the headless surface would drop continuity, and reporting none on the gateway would duplicate history the engine already has. --- CONTRIBUTING.md | 58 ++- .../agent/deepseekharness/apiclient.go | 183 +++++++ .../internal/agent/deepseekharness/events.go | 214 ++++++++ .../agent/deepseekharness/events_test.go | 100 ++++ .../agent/deepseekharness/fakegateway_test.go | 214 ++++++++ .../agent/deepseekharness/patch_config.go | 30 +- .../agent/deepseekharness/server_session.go | 478 ++++++++++++++++++ .../deepseekharness/server_session_test.go | 457 +++++++++++++++++ .../agent/deepseekharness/serverhost.go | 147 ++++++ .../agent/deepseekharness/serverhost_test.go | 200 ++++++++ .../agent/deepseekharness/serverprofile.go | 184 +++++++ .../deepseekharness/serverprofile_test.go | 225 +++++++++ .../internal/agent/deepseekharness/session.go | 27 + apps/parsar-daemon/internal/cli/agent_cli.go | 39 +- .../internal/cli/agent_cli_test.go | 29 ++ apps/parsar-daemon/internal/cli/connect.go | 5 + .../internal/enginehost/client.go | 106 ++++ .../internal/enginehost/client_test.go | 126 +++++ .../internal/enginehost/downlink.go | 121 +++++ .../internal/enginehost/instance.go | 203 ++++++++ .../internal/enginehost/linetail.go | 44 ++ .../parsar-daemon/internal/enginehost/spec.go | 135 +++++ .../internal/enginehost/supervisor.go | 262 ++++++++++ .../internal/enginehost/supervisor_test.go | 409 +++++++++++++++ 24 files changed, 3978 insertions(+), 18 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/events.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/events_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/server_session.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go create mode 100644 apps/parsar-daemon/internal/enginehost/client.go create mode 100644 apps/parsar-daemon/internal/enginehost/client_test.go create mode 100644 apps/parsar-daemon/internal/enginehost/downlink.go create mode 100644 apps/parsar-daemon/internal/enginehost/instance.go create mode 100644 apps/parsar-daemon/internal/enginehost/linetail.go create mode 100644 apps/parsar-daemon/internal/enginehost/spec.go create mode 100644 apps/parsar-daemon/internal/enginehost/supervisor.go create mode 100644 apps/parsar-daemon/internal/enginehost/supervisor_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 596836aa..bf5ba197 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -168,9 +168,17 @@ description and keep ownership on the side listed here. 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 — - `deepseek_harness` today) advertises none of them and must not synthesize a - `done` session id, a fake usage total, or an auto-approved permission. + (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 @@ -183,6 +191,44 @@ description and keep ownership on the side listed here. 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. +- `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. +- Resident servers must not outlive the daemon. Wire the supervisor's + `Shutdown` into daemon teardown. + ### Human interaction lifecycle - `agent_interactions` is the canonical durable record for permission prompts @@ -270,6 +316,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. ### API, DB, and generated surfaces diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go b/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go new file mode 100644 index 00000000..69822a59 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go @@ -0,0 +1,183 @@ +package deepseekharness + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" + "github.com/google/uuid" +) + +// The dsh /api gateway is a single-envelope RPC surface: every method is +// a POST to /api/ carrying a client-request envelope, and every +// answer is a server-response envelope whose result is an explicit +// ok/error union rather than an HTTP status. A 200 with ok=false is the +// normal way a method reports a rejected request, so the union has to be +// unwrapped before anything else. +const ( + clientRequestType = "client-request" + serverResponseType = "server-response" + + methodSessionCreate = "session.create" + methodSessionPrompt = "session.prompt" + methodSessionList = "session.list" + methodSessionCancel = "session.cancel" + + // promptModeQueue appends the turn behind anything already running. + // The alternative, "steer", interrupts the current turn — wrong for a + // daemon that submits one turn and waits for it. + promptModeQueue = "queue" +) + +type clientRequest struct { + Type string `json:"type"` + RPCID string `json:"rpcId"` + Method string `json:"method"` + Payload any `json:"payload"` +} + +type serverResponse struct { + Type string `json:"type"` + RPCID string `json:"rpcId"` + Result struct { + OK bool `json:"ok"` + Value json.RawMessage `json:"value"` + Error *rpcError `json:"error"` + } `json:"result"` +} + +// rpcError is the gateway's failure shape. Code is a stable machine +// string ("bad-request", "not-found", ...); Details carries the zod issue +// list for a schema rejection, which is the difference between a +// debuggable error and a shrug. +type rpcError struct { + Code string `json:"code"` + Message string `json:"message"` + Details json.RawMessage `json:"details"` +} + +func (e *rpcError) Error() string { + if e == nil { + return "deepseekharness: unknown api error" + } + msg := fmt.Sprintf("deepseekharness: dsh api %s: %s", e.Code, e.Message) + if len(e.Details) > 0 { + msg += ": " + truncate(string(e.Details), 400) + } + return msg +} + +// apiClient speaks the dsh gateway envelope over an enginehost transport. +type apiClient struct { + transport *enginehost.Client +} + +func newAPIClient(transport *enginehost.Client) *apiClient { + return &apiClient{transport: transport} +} + +// call sends one method and decodes the ok branch into out. +func (c *apiClient) call(ctx context.Context, method string, payload, out any) error { + req := clientRequest{ + Type: clientRequestType, + RPCID: uuid.NewString(), + Method: method, + Payload: payload, + } + var resp serverResponse + if err := c.transport.PostJSON(ctx, apiPathPrefix+"/"+method, req, &resp); err != nil { + return err + } + if resp.Type != serverResponseType { + return fmt.Errorf("deepseekharness: unexpected api envelope %q for %s", resp.Type, method) + } + if !resp.Result.OK { + if resp.Result.Error != nil { + return fmt.Errorf("%s: %w", method, resp.Result.Error) + } + return fmt.Errorf("deepseekharness: %s failed without an error body", method) + } + if out == nil { + return nil + } + if err := json.Unmarshal(resp.Result.Value, out); err != nil { + return fmt.Errorf("deepseekharness: decode %s value: %w", method, err) + } + return nil +} + +type sessionCreateValue struct { + SessionID string `json:"sessionId"` +} + +// CreateSession opens a fresh dsh session rooted at cwd. +func (c *apiClient) CreateSession(ctx context.Context, cwd string) (string, error) { + payload := map[string]any{} + if cwd = strings.TrimSpace(cwd); cwd != "" { + payload["cwd"] = cwd + } + var value sessionCreateValue + if err := c.call(ctx, methodSessionCreate, payload, &value); err != nil { + return "", err + } + if value.SessionID == "" { + return "", fmt.Errorf("deepseekharness: %s returned no session id", methodSessionCreate) + } + return value.SessionID, nil +} + +type promptContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Data string `json:"data,omitempty"` + Name string `json:"name,omitempty"` +} + +// Prompt submits one turn. It returns as soon as the gateway accepts the +// turn — the turn's own events arrive on the downlink, not here. +// +// There is no separate resume method: prompting a session id the server +// does not hold in memory makes it load that session's log from disk and +// resume the agent. Warm continuation and cold resume are the same call. +func (c *apiClient) Prompt(ctx context.Context, sessionID string, content []promptContentPart) error { + payload := map[string]any{ + "sessionId": sessionID, + "mode": promptModeQueue, + "content": content, + } + return c.call(ctx, methodSessionPrompt, payload, nil) +} + +type sessionListItem struct { + SessionID string `json:"sessionId"` + UpdatedAt int64 `json:"updatedAt"` + Running bool `json:"running"` + Blank bool `json:"blank"` + CWD string `json:"cwd"` +} + +type sessionListValue struct { + Items []sessionListItem `json:"items"` +} + +// ListSessions enumerates the sessions the server can serve. Used as the +// readiness probe: it is the cheapest method that proves the gateway, the +// carrier and the session store are all composed, which a bare TCP +// connect does not. +func (c *apiClient) ListSessions(ctx context.Context) ([]sessionListItem, error) { + var value sessionListValue + if err := c.call(ctx, methodSessionList, map[string]any{}, &value); err != nil { + return nil, err + } + return value.Items, nil +} + +// Cancel aborts the session's running turn. It targets the session, not +// the process: the resident server keeps serving other conversations, so +// cancelling a run must not take the engine down with it. +func (c *apiClient) Cancel(ctx context.Context, sessionID string) error { + return c.call(ctx, methodSessionCancel, map[string]any{"sessionId": sessionID}, nil) +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/events.go b/apps/parsar-daemon/internal/agent/deepseekharness/events.go new file mode 100644 index 00000000..8fba5c94 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/events.go @@ -0,0 +1,214 @@ +package deepseekharness + +import ( + "encoding/json" + "strings" +) + +// The downlink frame shape. Every event arrives as a server-request +// envelope whose method names the frame family; the family Parsar cares +// about is session/event, and its real vocabulary is one level deeper, in +// payload.event.type. Decoding stops at the boundaries this adapter maps +// so an unrecognised dsh event is ignored rather than failing a turn. +const ( + frameMethodSessionEvent = "session/event" + frameMethodSessionSubscribed = "session/subscribed" +) + +// Durable session event types this adapter maps. dsh logs far more than +// this; the rest carry no Parsar-visible meaning. +const ( + eventTurnStart = "turn/start" + eventTurnEnd = "turn/end" + eventAssistantChunk = "assistant/chunk" + eventAssistantMessage = "assistant/message" + eventToolCall = "tool/call" + eventToolResult = "tool/result" + eventApprovalAsked = "approval/asked" + eventAgentError = "agent/error" +) + +// Streaming chunk types inside assistant/chunk. +const ( + chunkTextDelta = "text-delta" + chunkReasoningDelta = "reasoning-delta" + chunkUsage = "usage" +) + +// downlinkFrame is the outer envelope on /api/events.mux. +type downlinkFrame struct { + Type string `json:"type"` + Method string `json:"method"` + Payload json.RawMessage `json:"payload"` +} + +// sessionEventPayload is the session/event body. SessionID is what makes +// the mux usable: one connection carries every session the server holds, +// so a consumer must filter rather than assume. +type sessionEventPayload struct { + SessionID string `json:"sessionId"` + Event struct { + Type string `json:"type"` + Seq uint64 `json:"seq"` + Time int64 `json:"time"` + Data json.RawMessage `json:"data"` + } `json:"event"` +} + +type sessionScopedPayload struct { + SessionID string `json:"sessionId"` +} + +// assistantChunkData is one streamed piece of the assistant's answer. +// Text and reasoning are separate block types on separate indices, so a +// turn interleaves them and the consumer must keep them apart. +type assistantChunkData struct { + Turn int `json:"turn"` + Step int `json:"step"` + Chunk struct { + Type string `json:"type"` + Index int `json:"index"` + Text string `json:"text"` + Usage struct { + InputTokens int32 `json:"inputTokens"` + OutputTokens int32 `json:"outputTokens"` + CacheReadTokens int32 `json:"cacheReadTokens"` + } `json:"usage"` + } `json:"chunk"` +} + +// assistantMessageData is the assembled message for one step. It is the +// authoritative text: the deltas are a preview, and a step that was +// retried upstream can produce a message that does not equal the +// concatenated deltas. +type assistantMessageData struct { + Turn int `json:"turn"` + Step int `json:"step"` + Message struct { + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message"` +} + +// toolCallData is a tool invocation. Arguments is a JSON *string*, not an +// object — dsh logs the model's raw argument text so a malformed call is +// still auditable. +type toolCallData struct { + CallID string `json:"callId"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// toolResultData is the tool's answer. The call id is nested under the +// message source, not at the top level, so a result can be matched to its +// call without a positional assumption. +type toolResultData struct { + Message struct { + Source struct { + CallID string `json:"callId"` + } `json:"source"` + Content []struct { + Type string `json:"type"` + ToolCallID string `json:"toolCallId"` + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"content"` + } `json:"message"` +} + +// turnEndData closes a turn. Reason.Kind is "completed" for a turn that +// finished normally; anything else means the turn stopped early and the +// run should not be reported as a success. +type turnEndData struct { + Turn int `json:"turn"` + Reason struct { + Kind string `json:"kind"` + Message string `json:"message"` + } `json:"reason"` +} + +// textFromToolResult flattens a tool result's nested content into one +// string plus its error flag, which is all the Parsar tool_call frame +// carries. +func (d toolResultData) textFromToolResult() (string, bool) { + var sb strings.Builder + isError := false + for _, part := range d.Message.Content { + if part.Type != "tool-result" { + continue + } + if part.IsError { + isError = true + } + for _, inner := range part.Content { + if inner.Type != "text" || inner.Text == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte('\n') + } + sb.WriteString(inner.Text) + } + } + return sb.String(), isError +} + +// assistantText concatenates the message's text blocks. Reasoning blocks +// are excluded: they are surfaced as thinking frames while streaming and +// must not leak into the turn's answer. +func (d assistantMessageData) assistantText() string { + var sb strings.Builder + for _, part := range d.Message.Content { + if part.Type != "text" || part.Text == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte('\n') + } + sb.WriteString(part.Text) + } + return sb.String() +} + +// decodeFrame parses one downlink frame. A frame that is not a +// session/event for the given session yields ok=false, which the caller +// treats as "ignore", not "error": the mux carries other sessions' events +// and other frame families by design. +func decodeFrame(raw []byte, sessionID string) (sessionEventPayload, bool) { + var frame downlinkFrame + if err := json.Unmarshal(raw, &frame); err != nil { + return sessionEventPayload{}, false + } + if frame.Method != frameMethodSessionEvent { + return sessionEventPayload{}, false + } + var payload sessionEventPayload + if err := json.Unmarshal(frame.Payload, &payload); err != nil { + return sessionEventPayload{}, false + } + if payload.SessionID != sessionID { + return sessionEventPayload{}, false + } + return payload, true +} + +// argsFromJSONString parses a tool call's raw argument text. A model can +// emit invalid JSON, and that must not fail the turn — the raw text is +// preserved under a "raw" key so the operator still sees what was asked. +func argsFromJSONString(arguments string) map[string]any { + trimmed := strings.TrimSpace(arguments) + if trimmed == "" { + return nil + } + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { + return parsed + } + return map[string]any{"raw": trimmed} +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/events_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/events_test.go new file mode 100644 index 00000000..e1bf3f1b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/events_test.go @@ -0,0 +1,100 @@ +package deepseekharness + +import ( + "encoding/json" + "testing" +) + +func TestDecodeFrameKeepsOnlyThisSessionsEvents(t *testing.T) { + frame := func(method, sessionID string) []byte { + body, err := json.Marshal(map[string]any{ + "type": "server-request", + "rpcId": "r1", + "method": method, + "payload": map[string]any{ + "type": method, + "sessionId": sessionID, + "event": map[string]any{"type": eventTurnStart, "seq": 1, "data": map[string]any{"turn": 1}}, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return body + } + + if _, ok := decodeFrame(frame(frameMethodSessionEvent, "s1"), "s1"); !ok { + t.Error("a matching session/event frame should be accepted") + } + if _, ok := decodeFrame(frame(frameMethodSessionEvent, "s2"), "s1"); ok { + t.Error("another session's event must be ignored") + } + // The mux carries other frame families (projections, queue snapshots, + // subscription acks). None of them are durable events. + if _, ok := decodeFrame(frame("session/projection", "s1"), "s1"); ok { + t.Error("a projection frame must be ignored") + } + if _, ok := decodeFrame(frame(frameMethodSessionSubscribed, "s1"), "s1"); ok { + t.Error("a subscription ack must be ignored") + } + // Malformed input is ignored rather than failing a turn. + if _, ok := decodeFrame([]byte("not json"), "s1"); ok { + t.Error("unparseable frames must be ignored") + } + if _, ok := decodeFrame([]byte(`{"method":"session/event","payload":"not an object"}`), "s1"); ok { + t.Error("a frame with an undecodable payload must be ignored") + } +} + +func TestArgsFromJSONStringPreservesUnparseableArguments(t *testing.T) { + got := argsFromJSONString(`{"file_path":"a.txt","limit":10}`) + if got["file_path"] != "a.txt" { + t.Errorf("parsed args = %#v", got) + } + + // A model can emit invalid JSON. Losing it would leave the operator + // with a tool call and no idea what was asked. + broken := argsFromJSONString(` {"file_path": `) + if broken["raw"] != `{"file_path":` { + t.Errorf("unparseable args = %#v, want the raw text preserved", broken) + } + + if got := argsFromJSONString(" "); got != nil { + t.Errorf("empty args = %#v, want nil", got) + } +} + +func TestToolResultTextFlattensNestedContent(t *testing.T) { + var data toolResultData + raw := `{"message":{"source":{"kind":"tool","callId":"c1"},"content":[ + {"type":"tool-result","toolCallId":"c1","isError":true,"content":[ + {"type":"text","text":"line one"}, + {"type":"text","text":"line two"}]}]}}` + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + text, isError := data.textFromToolResult() + if text != "line one\nline two" { + t.Errorf("text = %q", text) + } + if !isError { + t.Error("isError was not carried through") + } + if data.Message.Source.CallID != "c1" { + t.Errorf("call id = %q", data.Message.Source.CallID) + } +} + +func TestAssistantTextExcludesReasoning(t *testing.T) { + var data assistantMessageData + raw := `{"turn":1,"step":1,"message":{"role":"assistant","content":[ + {"type":"reasoning","text":"internal deliberation"}, + {"type":"text","text":"the answer"}, + {"type":"tool-call","text":""}]}}` + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := data.assistantText(); got != "the answer" { + t.Errorf("assistantText = %q, want just the text block", got) + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go new file mode 100644 index 00000000..688cc210 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go @@ -0,0 +1,214 @@ +package deepseekharness + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// fakeGateway is a stand-in for the dsh /api surface: the same envelope, +// the same method paths, and the same WebSocket downlink. Tests drive it +// instead of a real dsh so the adapter's wire handling is exercised +// without an engine install, while the shapes it emits are the ones +// captured from a live rc.7 server. +type fakeGateway struct { + srv *httptest.Server + + mu sync.Mutex + created []string + prompts []promptCall + cancels []string + listErr *rpcError + promptErr *rpcError + + // nextSessionID is handed out by session.create. + nextSessionID string + + conns chan *websocket.Conn + upgrade websocket.Upgrader +} + +type promptCall struct { + SessionID string + Mode string + Content []promptContentPart +} + +func newFakeGateway(t *testing.T) *fakeGateway { + t.Helper() + g := &fakeGateway{ + nextSessionID: "session-fake-0001", + conns: make(chan *websocket.Conn, 4), + } + mux := http.NewServeMux() + mux.HandleFunc(eventsMuxPath, g.handleDownlink) + mux.HandleFunc(apiPathPrefix+"/", g.handleUnary) + g.srv = httptest.NewServer(mux) + t.Cleanup(g.srv.Close) + return g +} + +func (g *fakeGateway) handleDownlink(w http.ResponseWriter, r *http.Request) { + conn, err := g.upgrade.Upgrade(w, r, nil) + if err != nil { + return + } + g.conns <- conn +} + +// conn waits for the adapter to attach its downlink. The adapter must +// attach before prompting, so a test that never sees a connection has +// found an ordering regression. +func (g *fakeGateway) conn(t *testing.T) *websocket.Conn { + t.Helper() + select { + case c := <-g.conns: + return c + case <-time.After(5 * time.Second): + t.Fatal("adapter never attached the event downlink") + return nil + } +} + +func (g *fakeGateway) handleUnary(w http.ResponseWriter, r *http.Request) { + method := strings.TrimPrefix(r.URL.Path, apiPathPrefix+"/") + var req clientRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + if req.Type != clientRequestType { + http.Error(w, "bad envelope", http.StatusBadRequest) + return + } + + raw, _ := json.Marshal(req.Payload) + g.mu.Lock() + var ( + value any + fail *rpcError + ) + switch method { + case methodSessionList: + fail = g.listErr + value = sessionListValue{Items: []sessionListItem{}} + case methodSessionCreate: + g.created = append(g.created, string(raw)) + value = sessionCreateValue{SessionID: g.nextSessionID} + case methodSessionPrompt: + var payload struct { + SessionID string `json:"sessionId"` + Mode string `json:"mode"` + Content []promptContentPart `json:"content"` + } + _ = json.Unmarshal(raw, &payload) + g.prompts = append(g.prompts, promptCall{SessionID: payload.SessionID, Mode: payload.Mode, Content: payload.Content}) + fail = g.promptErr + value = map[string]any{"accepted": true} + case methodSessionCancel: + var payload struct { + SessionID string `json:"sessionId"` + } + _ = json.Unmarshal(raw, &payload) + g.cancels = append(g.cancels, payload.SessionID) + value = map[string]any{"accepted": true} + default: + fail = &rpcError{Code: "not-found", Message: "unknown method " + method} + } + g.mu.Unlock() + + resp := map[string]any{"type": serverResponseType, "rpcId": req.RPCID} + if fail != nil { + resp["result"] = map[string]any{"ok": false, "error": fail} + } else { + resp["result"] = map[string]any{"ok": true, "value": value} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (g *fakeGateway) promptCalls() []promptCall { + g.mu.Lock() + defer g.mu.Unlock() + return append([]promptCall{}, g.prompts...) +} + +func (g *fakeGateway) createCalls() []string { + g.mu.Lock() + defer g.mu.Unlock() + return append([]string{}, g.created...) +} + +func (g *fakeGateway) cancelCalls() []string { + g.mu.Lock() + defer g.mu.Unlock() + return append([]string{}, g.cancels...) +} + +// emit pushes one session/event frame in the exact envelope a live dsh +// server sends: a server-request whose method names the frame family and +// whose payload nests the durable event. +func emitEvent(t *testing.T, conn *websocket.Conn, sessionID, eventType string, seq uint64, data any) { + t.Helper() + payload := map[string]any{ + "type": frameMethodSessionEvent, + "sessionId": sessionID, + "event": map[string]any{ + "type": eventType, + "seq": seq, + "time": time.Now().UnixMilli(), + "data": data, + }, + } + frame := map[string]any{ + "type": "server-request", + "rpcId": "rpc-" + eventType, + "method": frameMethodSessionEvent, + "payload": payload, + } + body, err := json.Marshal(frame) + if err != nil { + t.Fatalf("marshal frame: %v", err) + } + if err := conn.WriteMessage(websocket.TextMessage, body); err != nil { + t.Fatalf("write frame %s: %v", eventType, err) + } +} + +// textDelta / reasoningDelta / usageChunk build assistant/chunk data. +func textDelta(index int, text string) map[string]any { + return map[string]any{"turn": 1, "step": 1, "chunk": map[string]any{"type": chunkTextDelta, "index": index, "text": text}} +} + +func reasoningDelta(index int, text string) map[string]any { + return map[string]any{"turn": 1, "step": 1, "chunk": map[string]any{"type": chunkReasoningDelta, "index": index, "text": text}} +} + +func usageChunk(in, out, cached int) map[string]any { + return map[string]any{"turn": 1, "step": 1, "chunk": map[string]any{ + "type": chunkUsage, + "usage": map[string]any{"inputTokens": in, "outputTokens": out, "cacheReadTokens": cached}, + }} +} + +func assistantMessage(blocks ...map[string]any) map[string]any { + return map[string]any{"turn": 1, "step": 1, "message": map[string]any{"role": "assistant", "content": blocks}} +} + +func textBlock(text string) map[string]any { + return map[string]any{"type": "text", "text": text} +} + +func reasoningBlock(text string) map[string]any { + return map[string]any{"type": "reasoning", "text": text} +} + +func turnEnd(kind string) map[string]any { + return map[string]any{"turn": 1, "reason": map[string]any{"kind": kind}} +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go index 6fe95e10..40ac400f 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go @@ -110,10 +110,27 @@ type permissionPreset struct { Description string `yaml:"description,omitempty"` } -// renderPatch builds the `--patch` overlay for one prompt. The overlay is -// the last layer dsh applies, and a patch replaces the addressed row's -// whole config rather than merging into it. +// renderPatch builds the `--patch` overlay for one headless prompt. The +// overlay is the last layer dsh applies, and a patch replaces the +// addressed row's whole config rather than merging into it. func renderPatch(cfg providerConfig, hasProvider bool, model, provider string) ([]byte, error) { + rows, err := overrideRows(cfg, hasProvider, model, provider) + if err != nil { + return nil, err + } + body, err := yaml.Marshal(rows) + if err != nil { + return nil, fmt.Errorf("deepseekharness: marshal patch overlay: %w", err) + } + return body, nil +} + +// overrideRows is the permission / model / route triple both dsh surfaces +// need. The headless path ships it as a `--patch` overlay; the resident +// server path embeds it in the generated profile's own patch layer. It +// lives in one function so the two surfaces cannot drift on the +// unattended permission pairing or the managed-model route. +func overrideRows(cfg providerConfig, hasProvider bool, model, provider string) ([]patchRow, error) { // A daemon run has no human answerer for dsh's approval seam, so the // shipped `ask` policy would stall every tool call that asks. Writes // still stay inside the run's workspace. @@ -166,12 +183,7 @@ func renderPatch(cfg providerConfig, hasProvider bool, model, provider string) ( Model: model, }}) } - - body, err := yaml.Marshal(rows) - if err != nil { - return nil, fmt.Errorf("deepseekharness: marshal patch overlay: %w", err) - } - return body, nil + return rows, nil } func validateProvider(cfg providerConfig) error { diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go b/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go new file mode 100644 index 00000000..e9f62532 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go @@ -0,0 +1,478 @@ +package deepseekharness + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// serverSession drives one prompt against the resident dsh /api server. +// +// Why this exists next to the headless session: the headless surface runs +// a fresh dsh per prompt and therefore cannot continue a conversation, +// which is why the server injects prior turns for it. The resident server +// keeps dsh's own session log, so a turn continues by prompting the same +// session id — warm from memory, or loaded from disk after a restart. It +// also streams, which headless does not. +// +// Frame ownership follows the agent.Session contract: this session writes +// upstream frames on out and closes out exactly once, after the terminal +// done frame. The engine process is NOT owned here — it belongs to the +// enginehost lease, which is released when the run ends. +type serverSession struct { + runID string + sessionID string + // isNewSession records whether this run opened the dsh session. It + // decides nothing about the turn itself, but a failed first turn must + // not be reported as a resumable session id. + isNewSession bool + + cfg sessionConfig + api *apiClient + down *enginehost.Downlink + out chan<- proto.Envelope + + // The engine is reached through these three rather than through the + // lease itself, so a test can drive a real gateway over httptest + // without launching a dsh process. release is called exactly once, + // when the run ends; diagnostics explains a mid-turn engine death. + engineExited <-chan struct{} + release func() + diagnostics func() string + + seq atomic.Uint64 + + // answer is the assembled reply. It is built from assistant/message + // events, not from the streamed deltas, because a retried step + // re-streams and would otherwise be counted twice. + answer strings.Builder + usage proto.Usage + + cancelOnce sync.Once + closeOutOnce sync.Once + cancelled atomic.Bool +} + +var _ agent.Session = (*serverSession)(nil) + +// newServerSession acquires the resident engine, attaches to its event +// downlink, submits the turn, and starts pumping frames. +// +// Order matters: the downlink is attached BEFORE the prompt is submitted. +// dsh starts emitting as soon as it accepts the turn, and a downlink +// opened afterwards would miss the opening frames of a fast turn. +func newServerSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (agent.Session, error) { + if out == nil { + return nil, errors.New("deepseekharness: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.binary == "" { + cfg.binary = defaultBinary + } + + launch, err := buildServerLaunch(req) + if err != nil { + return nil, err + } + launch.Binary = cfg.binary + + lease, err := serverSupervisor.Acquire(parent, launch.spec()) + if err != nil { + return nil, fmt.Errorf("deepseekharness: start resident dsh server: %w", err) + } + + s := &serverSession{ + runID: req.RunID, + cfg: cfg, + api: newAPIClient(enginehost.NewClient(lease.BaseURL(), 0)), + out: out, + engineExited: lease.Exited(), + release: lease.Release, + diagnostics: lease.Diagnostics, + } + + if err := s.attachAndPrompt(parent, req, launch.WorkDir); err != nil { + lease.Release() + return nil, err + } + go s.pump() + return s, nil +} + +func (s *serverSession) attachAndPrompt(ctx context.Context, req proto.PromptRequestPayload, workDir string) error { + down, err := s.api.transport.Dial(ctx, eventsMuxPath) + if err != nil { + return fmt.Errorf("deepseekharness: attach event downlink: %w (engine output: %s)", err, s.diagnostics()) + } + s.down = down + + s.sessionID = strings.TrimSpace(req.AgentSessionID) + if s.sessionID == "" { + // A fresh session is rooted at the run's workspace. Resumed + // sessions carry their own cwd in the log, so cwd is not resent. + id, err := s.api.CreateSession(ctx, workDir) + if err != nil { + down.Close() + return err + } + s.sessionID = id + s.isNewSession = true + } + + content, err := promptContent(req) + if err != nil { + down.Close() + return err + } + if err := s.api.Prompt(ctx, s.sessionID, content); err != nil { + down.Close() + return err + } + return nil +} + +// promptContent renders the turn's text and image attachments into the +// gateway's content-part shape. dsh accepts a narrower set of media types +// than Parsar carries, so an attachment it cannot represent is dropped +// with a warning rather than failing the turn. +func promptContent(req proto.PromptRequestPayload) ([]promptContentPart, error) { + text := strings.TrimSpace(req.Prompt) + if text == "" { + return nil, errors.New("deepseekharness: empty prompt") + } + if system := systemPreamble(req.AgentOptions); system != "" { + // The gateway has no system-prompt seam, so an injected system + // prompt rides at the head of the turn text, as on the headless + // path. + text = system + "\n\n" + text + } + parts := []promptContentPart{{Type: "text", Text: text}} + for _, att := range req.Attachments { + if !isSupportedImageMedia(att.MIME) { + continue + } + parts = append(parts, promptContentPart{ + Type: "image", + MediaType: att.MIME, + Data: att.DataBase64, + }) + } + return parts, nil +} + +// supportedImageMedia is the gateway's accepted raster set. A media type +// outside it is rejected by the request schema, which would fail the whole +// turn over an attachment. +var supportedImageMedia = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "image/gif": true, +} + +func isSupportedImageMedia(mime string) bool { + return supportedImageMedia[strings.ToLower(strings.TrimSpace(mime))] +} + +func systemPreamble(opts map[string]any) string { + if override := stringOpt(opts, "override_system_prompt"); override != "" { + return override + } + return stringOpt(opts, "system_prompt") +} + +// pump reads the downlink until the turn ends, translating events into +// upstream frames, then emits the terminal frames and closes out. +func (s *serverSession) pump() { + defer s.closeOut() + defer s.release() + defer s.down.Close() + + var ( + turnEnded bool + failure string + ) + frames := s.down.Frames() +loop: + for { + select { + case raw, ok := <-frames: + if !ok { + if err := s.down.Err(); err != nil && failure == "" { + failure = fmt.Sprintf("deepseek-harness: event stream ended: %v", err) + } + break loop + } + event, matched := decodeFrame(raw, s.sessionID) + if !matched { + continue + } + done, reason := s.handleEvent(event) + if reason != "" && failure == "" { + failure = reason + } + if done { + turnEnded = true + break loop + } + case <-s.engineExited: + // The engine died mid-turn. Its remaining frames will never + // arrive, so the run has to fail rather than wait. + if failure == "" { + failure = fmt.Sprintf("deepseek-harness: engine exited mid-turn: %s", s.diagnostics()) + } + break loop + } + } + + // Cancellation outranks whatever the stream reported: Cancel closes the + // downlink, so a cancelled run always also sees the stream end, and + // reporting that instead would hide the actual cause. + if s.cancelled.Load() { + failure = "deepseek-harness: cancelled" + } + if !turnEnded && failure == "" { + failure = "deepseek-harness: event stream closed before the turn ended" + } + s.emitTerminal(failure) +} + +// handleEvent translates one durable session event. It returns done=true +// on the frame that ends the turn, and a non-empty reason when the turn +// failed. +func (s *serverSession) handleEvent(event sessionEventPayload) (bool, string) { + switch event.Event.Type { + case eventAssistantChunk: + s.handleChunk(event.Event.Data) + case eventAssistantMessage: + var data assistantMessageData + if err := json.Unmarshal(event.Event.Data, &data); err != nil { + return false, "" + } + if text := data.assistantText(); text != "" { + if s.answer.Len() > 0 { + s.answer.WriteByte('\n') + } + s.answer.WriteString(text) + } + case eventToolCall: + var data toolCallData + if err := json.Unmarshal(event.Event.Data, &data); err != nil { + return false, "" + } + s.send(proto.TypeToolCall, proto.ToolCallPayload{ + ID: data.CallID, + Name: data.Name, + Stage: "before", + Args: argsFromJSONString(data.Arguments), + }) + case eventToolResult: + var data toolResultData + if err := json.Unmarshal(event.Event.Data, &data); err != nil { + return false, "" + } + text, isError := data.textFromToolResult() + s.send(proto.TypeToolCall, proto.ToolCallPayload{ + ID: data.Message.Source.CallID, + Stage: "after", + Result: map[string]any{"output": text, "is_error": isError}, + }) + case eventApprovalAsked: + // Reaching this event means the composed permission preset was not + // the unattended one: with approval "never" dsh rejects such + // actions itself instead of asking. There is no human on this + // path, so the run fails loudly rather than hanging. + return true, "deepseek-harness: engine asked for approval, but this run has no approver" + case eventAgentError: + return false, "deepseek-harness: " + truncate(strings.TrimSpace(string(event.Event.Data)), 400) + case eventTurnEnd: + var data turnEndData + if err := json.Unmarshal(event.Event.Data, &data); err != nil { + return true, "" + } + if data.Reason.Kind != "completed" { + reason := data.Reason.Kind + if data.Reason.Message != "" { + reason += ": " + data.Reason.Message + } + return true, "deepseek-harness: turn ended without completing (" + reason + ")" + } + return true, "" + } + return false, "" +} + +// handleChunk forwards streamed text and reasoning, and records usage. +// Text and reasoning go to different Parsar frame types so a renderer can +// keep the model's thinking out of the answer. +func (s *serverSession) handleChunk(raw json.RawMessage) { + var data assistantChunkData + if err := json.Unmarshal(raw, &data); err != nil { + return + } + switch data.Chunk.Type { + case chunkTextDelta: + if data.Chunk.Text == "" { + return + } + s.send(proto.TypeDelta, proto.DeltaPayload{Delta: data.Chunk.Text, Sequence: s.seq.Add(1)}) + case chunkReasoningDelta: + if data.Chunk.Text == "" { + return + } + s.send(proto.TypeThinking, proto.ThinkingPayload{Text: data.Chunk.Text, Sequence: s.seq.Add(1)}) + case chunkUsage: + // Usage arrives per model request, so a multi-step turn reports + // several. They are summed: the run's cost is the whole turn's. + s.usage.Provider = usageProvider + s.usage.InputTokens += data.Chunk.Usage.InputTokens + s.usage.OutputTokens += data.Chunk.Usage.OutputTokens + if data.Chunk.Usage.CacheReadTokens > 0 { + if s.usage.Raw == nil { + s.usage.Raw = map[string]any{} + } + prior, _ := s.usage.Raw["cache_read_tokens"].(int32) + s.usage.Raw["cache_read_tokens"] = prior + data.Chunk.Usage.CacheReadTokens + } + s.send(proto.TypeUsage, proto.UsagePayload{Usage: s.usage}) + } +} + +// emitTerminal writes the error frame (when the turn failed) and the done +// frame. The session id is persisted only on success: handing back an id +// whose first turn failed would make the next prompt resume a session +// that may not exist on disk. +func (s *serverSession) emitTerminal(failure string) { + content := strings.TrimSpace(s.answer.String()) + if failure != "" { + s.send(proto.TypeError, proto.ErrorPayload{Error: failure}) + } + metadata := map[string]any{"connector_path": "dsh_apiproxy"} + if failure == "" || !s.isNewSession { + // A resumed session id stays valid even after a failed turn: it + // already exists on disk, and forgetting it would strand the + // conversation on a fresh session. + metadata[proto.DoneMetaAgentSessionID] = s.sessionID + metadata[proto.DoneMetaAgentSessionType] = "dsh_session" + } + usage := s.usage + if usage.Provider == "" { + usage.Provider = usageProvider + } + s.send(proto.TypeDone, proto.DonePayload{ + Content: content, + Transcript: content, + Usage: usage, + Metadata: metadata, + }) +} + +func (s *serverSession) send(typ string, payload any) { + env, err := proto.NewEnvelope(typ, s.runID, payload) + if err != nil { + s.cfg.logger.Warn("deepseekharness: encode frame", "run_id", s.runID, "type", typ, "err", err) + return + } + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("deepseekharness: frame send timed out", "run_id", s.runID, "type", typ) + } +} + +func (s *serverSession) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } + +// Cancel aborts the running turn. It cancels the dsh session, never the +// engine process: the resident server is shared, so killing it would +// abort other conversations' turns too. +func (s *serverSession) Cancel(ctx context.Context) error { + s.cancelOnce.Do(func() { + s.cancelled.Store(true) + cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := s.api.Cancel(cancelCtx, s.sessionID); err != nil { + s.cfg.logger.Warn("deepseekharness: cancel session", "run_id", s.runID, "err", err) + } + // The downlink is closed so pump unblocks even if the engine never + // logs a turn/end for the cancelled turn. + s.down.Close() + }) + return nil +} + +// SubmitPermission has no counterpart on this path: the profile pins the +// unattended permission preset, so dsh rejects escalation itself and never +// asks. Returning the sentinel keeps the router's race handling intact. +func (s *serverSession) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { + return agent.ErrUnknownPermission +} + +func (s *serverSession) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { + return agent.ErrUnknownAsk +} + +// buildServerLaunch derives the resident server's identity and launch +// inputs from one prompt request. It reuses the headless path's home +// resolution and provider normalisation so the two surfaces agree on +// where a conversation's dsh state lives. +func buildServerLaunch(req proto.PromptRequestPayload) (serverLaunch, error) { + workDir, err := resolveWorkDir(req.WorkDir) + if err != nil { + return serverLaunch{}, err + } + provider, hasProvider, err := normaliseProvider(req.AgentOptions["dsh_provider"]) + if err != nil { + return serverLaunch{}, err + } + if hasProvider { + if err := validateProvider(provider); err != nil { + return serverLaunch{}, err + } + } + home, err := resolveHome(req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return serverLaunch{}, err + } + + envOpt, err := envMap(req.AgentOptions["env"]) + if err != nil { + return serverLaunch{}, err + } + // Same pinning as the headless path and for the same reason: these + // three must not be redirected by agent_options. + envOpt[dshHomeEnvVar] = home + envOpt[dshPermissionModeEnvVar] = sandboxPermissionMode + envOpt[dshTelemetryDisabledEnvVar] = "1" + extra, err := buildEnv(envOpt) + if err != nil { + return serverLaunch{}, err + } + + stateKey := strings.TrimSpace(req.AgentStateKey) + if stateKey == "" { + stateKey = strings.TrimSpace(req.ConversationID) + } + return serverLaunch{ + Home: home, + WorkDir: workDir, + Provider: provider, + HasProvider: hasProvider, + Model: stringOpt(req.AgentOptions, "model"), + ProviderID: stringOpt(req.AgentOptions, "provider"), + Env: append(os.Environ(), extra...), + StateKey: stateKey, + }, nil +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go new file mode 100644 index 00000000..e710fe5e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go @@ -0,0 +1,457 @@ +package deepseekharness + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/gorilla/websocket" +) + +func quietConfig() sessionConfig { + cfg := defaultConfig() + cfg.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + return cfg +} + +// harness wires a serverSession to a fakeGateway without the supervisor, +// standing in for the lease the real Factory holds. +type harness struct { + gateway *fakeGateway + session *serverSession + out chan proto.Envelope + exited chan struct{} + conn *websocket.Conn + + released bool +} + +func newHarness(t *testing.T, req proto.PromptRequestPayload) *harness { + t.Helper() + gateway := newFakeGateway(t) + out := make(chan proto.Envelope, 128) + h := &harness{gateway: gateway, out: out, exited: make(chan struct{})} + + s := &serverSession{ + runID: req.RunID, + cfg: quietConfig(), + api: newAPIClient(enginehost.NewClient(gateway.srv.URL, 10*time.Second)), + out: out, + engineExited: h.exited, + release: func() { h.released = true }, + diagnostics: func() string { return "fake engine output" }, + } + h.session = s + + if err := s.attachAndPrompt(context.Background(), req, "/tmp/fake-workspace"); err != nil { + t.Fatalf("attachAndPrompt: %v", err) + } + h.conn = gateway.conn(t) + go s.pump() + return h +} + +// collect drains out until it closes, which the session does exactly once +// after its terminal done frame. +func (h *harness) collect(t *testing.T) []proto.Envelope { + t.Helper() + var got []proto.Envelope + deadline := time.After(15 * time.Second) + for { + select { + case env, ok := <-h.out: + if !ok { + return got + } + got = append(got, env) + case <-deadline: + t.Fatalf("session never closed its out channel; collected %d frames", len(got)) + return got + } + } +} + +func decodeEnv[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} + +func framesOfType(envs []proto.Envelope, typ string) []proto.Envelope { + var out []proto.Envelope + for _, env := range envs { + if env.Type == typ { + out = append(out, env) + } + } + return out +} + +func baseRequest() proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + AgentKind: "deepseek_harness", + ConversationID: "conv-1", + RunID: "run-1", + Prompt: "hello", + AgentOptions: map[string]any{}, + } +} + +func TestServerSessionCreatesASessionAndQueuesTheTurn(t *testing.T) { + h := newHarness(t, baseRequest()) + + creates := h.gateway.createCalls() + if len(creates) != 1 { + t.Fatalf("expected one session.create, got %d", len(creates)) + } + if !strings.Contains(creates[0], "/tmp/fake-workspace") { + t.Errorf("session.create did not carry the workspace cwd: %s", creates[0]) + } + + prompts := h.gateway.promptCalls() + if len(prompts) != 1 { + t.Fatalf("expected one session.prompt, got %d", len(prompts)) + } + // mode is required by the gateway schema; omitting it is a hard + // bad-request, so it is asserted rather than assumed. + if prompts[0].Mode != promptModeQueue { + t.Errorf("prompt mode = %q, want %q", prompts[0].Mode, promptModeQueue) + } + if prompts[0].SessionID != h.gateway.nextSessionID { + t.Errorf("prompt session = %q, want the created %q", prompts[0].SessionID, h.gateway.nextSessionID) + } + if len(prompts[0].Content) != 1 || prompts[0].Content[0].Text != "hello" { + t.Errorf("prompt content = %+v", prompts[0].Content) + } + + emitEvent(t, h.conn, h.gateway.nextSessionID, eventTurnEnd, 9, turnEnd("completed")) + h.collect(t) +} + +func TestServerSessionResumesTheGivenSessionWithoutCreating(t *testing.T) { + req := baseRequest() + req.AgentSessionID = "session-prior-42" + h := newHarness(t, req) + + if creates := h.gateway.createCalls(); len(creates) != 0 { + t.Fatalf("a resumed turn must not create a session, got %v", creates) + } + prompts := h.gateway.promptCalls() + if len(prompts) != 1 || prompts[0].SessionID != "session-prior-42" { + t.Fatalf("prompt did not target the prior session: %+v", prompts) + } + + emitEvent(t, h.conn, "session-prior-42", eventTurnEnd, 3, turnEnd("completed")) + envs := h.collect(t) + done := decodeEnv[proto.DonePayload](t, envs[len(envs)-1]) + if done.Metadata[proto.DoneMetaAgentSessionID] != "session-prior-42" { + t.Errorf("done metadata = %#v, want the resumed session id", done.Metadata) + } +} + +func TestServerSessionStreamsTextThinkingToolsAndUsage(t *testing.T) { + h := newHarness(t, baseRequest()) + sid := h.gateway.nextSessionID + + emitEvent(t, h.conn, sid, eventAssistantChunk, 1, reasoningDelta(0, "let me think")) + emitEvent(t, h.conn, sid, eventAssistantChunk, 2, textDelta(1, "Hel")) + emitEvent(t, h.conn, sid, eventAssistantChunk, 3, textDelta(1, "lo")) + emitEvent(t, h.conn, sid, eventToolCall, 4, map[string]any{ + "turn": 1, "step": 1, "callId": "call-7", "name": "read", + "arguments": `{"file_path":"note.txt"}`, + }) + emitEvent(t, h.conn, sid, eventToolResult, 5, map[string]any{ + "turn": 1, "step": 1, + "message": map[string]any{ + "role": "user", + "source": map[string]any{"kind": "tool", "callId": "call-7"}, + "content": []map[string]any{{ + "type": "tool-result", "toolCallId": "call-7", "isError": false, + "content": []map[string]any{{"type": "text", "text": "file body"}}, + }}, + }, + }) + emitEvent(t, h.conn, sid, eventAssistantChunk, 6, usageChunk(100, 20, 64)) + emitEvent(t, h.conn, sid, eventAssistantChunk, 7, usageChunk(50, 5, 0)) + emitEvent(t, h.conn, sid, eventAssistantMessage, 8, + assistantMessage(reasoningBlock("let me think"), textBlock("Hello"))) + emitEvent(t, h.conn, sid, eventTurnEnd, 9, turnEnd("completed")) + + envs := h.collect(t) + + deltas := framesOfType(envs, proto.TypeDelta) + var text strings.Builder + for _, env := range deltas { + text.WriteString(decodeEnv[proto.DeltaPayload](t, env).Delta) + } + if text.String() != "Hello" { + t.Errorf("streamed text = %q, want Hello", text.String()) + } + + thinking := framesOfType(envs, proto.TypeThinking) + if len(thinking) != 1 || decodeEnv[proto.ThinkingPayload](t, thinking[0]).Text != "let me think" { + t.Errorf("thinking frames = %d, want the reasoning delta", len(thinking)) + } + + tools := framesOfType(envs, proto.TypeToolCall) + if len(tools) != 2 { + t.Fatalf("tool frames = %d, want a before and an after", len(tools)) + } + before := decodeEnv[proto.ToolCallPayload](t, tools[0]) + if before.Stage != "before" || before.Name != "read" || before.ID != "call-7" { + t.Errorf("before frame = %+v", before) + } + if before.Args["file_path"] != "note.txt" { + t.Errorf("tool args not parsed from the JSON string: %+v", before.Args) + } + after := decodeEnv[proto.ToolCallPayload](t, tools[1]) + if after.Stage != "after" || after.ID != "call-7" { + t.Errorf("after frame = %+v", after) + } + if after.Result["output"] != "file body" || after.Result["is_error"] != false { + t.Errorf("after result = %+v", after.Result) + } + + // Usage arrives per model request; a multi-step turn must report the + // whole turn's cost, not the last request's. + usage := framesOfType(envs, proto.TypeUsage) + if len(usage) != 2 { + t.Fatalf("usage frames = %d, want one per usage chunk", len(usage)) + } + last := decodeEnv[proto.UsagePayload](t, usage[1]) + if last.InputTokens != 150 || last.OutputTokens != 25 { + t.Errorf("usage = %+v, want summed 150/25", last.Usage) + } + + done := decodeEnv[proto.DonePayload](t, envs[len(envs)-1]) + if envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("last frame = %s, want done", envs[len(envs)-1].Type) + } + // The answer comes from assistant/message, and reasoning must not leak + // into it. + if done.Content != "Hello" { + t.Errorf("done content = %q, want Hello", done.Content) + } + if strings.Contains(done.Content, "let me think") { + t.Errorf("reasoning leaked into the answer: %q", done.Content) + } + if done.Usage.InputTokens != 150 { + t.Errorf("done usage = %+v", done.Usage) + } + if done.Metadata[proto.DoneMetaAgentSessionID] != sid { + t.Errorf("done metadata = %#v", done.Metadata) + } + if done.Metadata[proto.DoneMetaAgentSessionType] != "dsh_session" { + t.Errorf("done metadata session type = %#v", done.Metadata) + } + if !h.released { + t.Error("the engine lease was not released when the run ended") + } +} + +func TestServerSessionIgnoresOtherSessionsOnTheMux(t *testing.T) { + h := newHarness(t, baseRequest()) + sid := h.gateway.nextSessionID + + // The downlink is multiplexed: another conversation's turn shares the + // connection and must not bleed into this run. + emitEvent(t, h.conn, "session-someone-else", eventAssistantChunk, 1, textDelta(0, "NOT MINE")) + emitEvent(t, h.conn, "session-someone-else", eventTurnEnd, 2, turnEnd("completed")) + emitEvent(t, h.conn, sid, eventAssistantChunk, 3, textDelta(0, "MINE")) + emitEvent(t, h.conn, sid, eventTurnEnd, 4, turnEnd("completed")) + + envs := h.collect(t) + for _, env := range framesOfType(envs, proto.TypeDelta) { + if strings.Contains(decodeEnv[proto.DeltaPayload](t, env).Delta, "NOT MINE") { + t.Fatal("another session's delta reached this run") + } + } + if len(framesOfType(envs, proto.TypeDelta)) != 1 { + t.Errorf("delta frames = %d, want only this session's", len(framesOfType(envs, proto.TypeDelta))) + } +} + +func TestServerSessionFailsAnIncompleteTurn(t *testing.T) { + h := newHarness(t, baseRequest()) + emitEvent(t, h.conn, h.gateway.nextSessionID, eventTurnEnd, 5, + map[string]any{"turn": 1, "reason": map[string]any{"kind": "aborted", "message": "context limit"}}) + + envs := h.collect(t) + errs := framesOfType(envs, proto.TypeError) + if len(errs) != 1 { + t.Fatalf("error frames = %d, want 1", len(errs)) + } + msg := decodeEnv[proto.ErrorPayload](t, errs[0]).Error + if !strings.Contains(msg, "aborted") || !strings.Contains(msg, "context limit") { + t.Errorf("error message lost the reason: %q", msg) + } + // A brand-new session whose first turn failed must not be handed back + // as resumable. + done := decodeEnv[proto.DonePayload](t, envs[len(envs)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Errorf("a failed first turn must not persist a session id: %#v", done.Metadata) + } +} + +func TestServerSessionKeepsAResumedSessionIDAfterAFailedTurn(t *testing.T) { + req := baseRequest() + req.AgentSessionID = "session-prior-9" + h := newHarness(t, req) + emitEvent(t, h.conn, "session-prior-9", eventTurnEnd, 5, + map[string]any{"turn": 1, "reason": map[string]any{"kind": "aborted"}}) + + envs := h.collect(t) + done := decodeEnv[proto.DonePayload](t, envs[len(envs)-1]) + // The session already exists on disk; forgetting it would strand the + // conversation on a fresh one. + if done.Metadata[proto.DoneMetaAgentSessionID] != "session-prior-9" { + t.Errorf("resumed session id was dropped after a failure: %#v", done.Metadata) + } +} + +func TestServerSessionFailsWhenApprovalIsAsked(t *testing.T) { + h := newHarness(t, baseRequest()) + emitEvent(t, h.conn, h.gateway.nextSessionID, eventApprovalAsked, 4, + map[string]any{"requestId": "ap-1", "tool": "bash"}) + + envs := h.collect(t) + errs := framesOfType(envs, proto.TypeError) + if len(errs) != 1 { + t.Fatalf("an approval ask with no approver must fail the run, got %d error frames", len(errs)) + } + if msg := decodeEnv[proto.ErrorPayload](t, errs[0]).Error; !strings.Contains(msg, "approv") { + t.Errorf("error message = %q", msg) + } +} + +func TestServerSessionFailsWhenTheEngineDiesMidTurn(t *testing.T) { + h := newHarness(t, baseRequest()) + emitEvent(t, h.conn, h.gateway.nextSessionID, eventAssistantChunk, 1, textDelta(0, "par")) + close(h.exited) + + envs := h.collect(t) + errs := framesOfType(envs, proto.TypeError) + if len(errs) != 1 { + t.Fatalf("error frames = %d, want 1", len(errs)) + } + msg := decodeEnv[proto.ErrorPayload](t, errs[0]).Error + if !strings.Contains(msg, "exited mid-turn") || !strings.Contains(msg, "fake engine output") { + t.Errorf("error should name the death and carry diagnostics, got %q", msg) + } + if envs[len(envs)-1].Type != proto.TypeDone { + t.Error("a failed run still has to end with done") + } +} + +func TestServerSessionFailsWhenTheStreamEndsEarly(t *testing.T) { + h := newHarness(t, baseRequest()) + // A server-side close with no turn/end: the turn's outcome is unknown, + // which must not be reported as success. + _ = h.conn.Close() + + envs := h.collect(t) + if len(framesOfType(envs, proto.TypeError)) != 1 { + t.Fatalf("expected one error frame, got %d", len(framesOfType(envs, proto.TypeError))) + } +} + +func TestServerSessionCancelTargetsTheSessionNotTheProcess(t *testing.T) { + h := newHarness(t, baseRequest()) + if err := h.session.Cancel(context.Background()); err != nil { + t.Fatalf("Cancel: %v", err) + } + // Idempotent: the router may cancel a run that already finished. + if err := h.session.Cancel(context.Background()); err != nil { + t.Fatalf("second Cancel: %v", err) + } + + cancels := h.gateway.cancelCalls() + if len(cancels) != 1 || cancels[0] != h.gateway.nextSessionID { + t.Fatalf("session.cancel calls = %v, want exactly one for this session", cancels) + } + + envs := h.collect(t) + errs := framesOfType(envs, proto.TypeError) + if len(errs) != 1 || !strings.Contains(decodeEnv[proto.ErrorPayload](t, errs[0]).Error, "cancelled") { + t.Errorf("cancelled run should report cancellation, got %d error frames", len(errs)) + } + select { + case <-h.exited: + t.Error("cancelling a run must not take the shared engine down") + default: + } +} + +func TestServerSessionSurfacesAPromptRejection(t *testing.T) { + gateway := newFakeGateway(t) + gateway.promptErr = &rpcError{Code: "bad-request", Message: "invalid payload for session.prompt"} + out := make(chan proto.Envelope, 8) + s := &serverSession{ + runID: "run-x", + cfg: quietConfig(), + api: newAPIClient(enginehost.NewClient(gateway.srv.URL, 5*time.Second)), + out: out, + engineExited: make(chan struct{}), + release: func() {}, + diagnostics: func() string { return "" }, + } + err := s.attachAndPrompt(context.Background(), baseRequest(), "/tmp/x") + if err == nil { + t.Fatal("expected the gateway rejection to fail the start") + } + // The gateway reports schema rejections as a 200 with ok=false, so the + // union has to be unwrapped or this error would be swallowed. + if !strings.Contains(err.Error(), "bad-request") || !strings.Contains(err.Error(), "invalid payload") { + t.Errorf("error lost the gateway's reason: %v", err) + } +} + +func TestPromptContentCarriesSystemPromptAndSupportedImages(t *testing.T) { + req := baseRequest() + req.Prompt = "do the thing" + req.AgentOptions = map[string]any{"system_prompt": "be terse"} + req.Attachments = []proto.PromptAttachment{ + {Kind: "image", MIME: "image/png", DataBase64: "AAA"}, + {Kind: "image", MIME: "image/tiff", DataBase64: "BBB"}, + } + + parts, err := promptContent(req) + if err != nil { + t.Fatalf("promptContent: %v", err) + } + if parts[0].Type != "text" || !strings.HasPrefix(parts[0].Text, "be terse") || + !strings.Contains(parts[0].Text, "do the thing") { + t.Errorf("text part = %+v", parts[0]) + } + // image/tiff is outside the gateway's accepted raster set; forwarding + // it would fail the whole turn on a schema error. + if len(parts) != 2 { + t.Fatalf("parts = %d, want text plus the png only", len(parts)) + } + if parts[1].MediaType != "image/png" || parts[1].Data != "AAA" { + t.Errorf("image part = %+v", parts[1]) + } + + req.AgentOptions = map[string]any{"system_prompt": "be terse", "override_system_prompt": "override wins"} + parts, err = promptContent(req) + if err != nil { + t.Fatalf("promptContent: %v", err) + } + if !strings.HasPrefix(parts[0].Text, "override wins") { + t.Errorf("override_system_prompt did not win: %q", parts[0].Text) + } + + req.Prompt = " " + if _, err := promptContent(req); err == nil { + t.Error("an empty prompt must be rejected") + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go new file mode 100644 index 00000000..e0313d40 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go @@ -0,0 +1,147 @@ +package deepseekharness + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" +) + +// serverSupervisor is process-wide because the resident servers are: a +// state key's engine has to be shared by every prompt of that key, and +// agent.Factory is a plain function with nowhere to hang per-daemon +// state. Shutdown is wired to daemon teardown by the dispatch layer. +var serverSupervisor = enginehost.NewSupervisor(nil) + +// ShutdownServers stops every resident dsh server. Called on daemon +// teardown so an engine does not outlive the process that started it. +func ShutdownServers() { serverSupervisor.Shutdown() } + +// readyProbeTimeout bounds one readiness attempt. A cold dsh boot +// compiles its plugin tree, so the overall gate is generous while each +// individual probe stays short. +const readyProbeTimeout = 10 * time.Second + +// serverLaunch is everything one resident server's identity and launch +// depend on. +type serverLaunch struct { + Home string + WorkDir string + Binary string + Provider providerConfig + HasProvider bool + Model string + ProviderID string + Env []string + StateKey string +} + +// spec turns a launch into an enginehost.ServerSpec. +func (l serverLaunch) spec() enginehost.ServerSpec { + return enginehost.ServerSpec{ + Key: l.key(), + Binary: l.Binary, + Dir: l.WorkDir, + Args: func(int) []string { + // The port reaches dsh through the generated profile, not the + // command line: dsh has no port flag, the webserver row owns + // it. Prepare writes that row before this argv is used. + return []string{"--profile", serverProfileName} + }, + Env: func(int) []string { return l.Env }, + Prepare: func(_ context.Context, port int) error { + if err := os.MkdirAll(l.Home, 0o700); err != nil { + return fmt.Errorf("deepseekharness: mkdir dsh home %s: %w", l.Home, err) + } + return writeServerProfile(serverProfileSpec{ + Home: l.Home, + Port: port, + Provider: l.Provider, + HasProvider: l.HasProvider, + Model: l.Model, + ProviderID: l.ProviderID, + }) + }, + Ready: probeReady, + IdleTimeout: serverIdleTimeout(), + Logger: nil, + } +} + +// key is the reuse identity. It is the state key plus a fingerprint of +// everything baked into the generated profile at launch. +// +// The fingerprint matters: the resident server reads its model route, +// credentials env and workspace once, at boot. If a later prompt of the +// same conversation selects a different model or a rotated key, reusing +// the running server would silently run the turn on the old route. Making +// those inputs part of the key means such a prompt gets its own server +// instead, and the stale one is reclaimed when it goes idle. +func (l serverLaunch) key() string { + h := sha256.New() + for _, part := range []string{ + l.Home, l.WorkDir, l.Binary, + l.Provider.BaseURL, l.Provider.API, l.Provider.APIKeyEnv, l.Provider.Model, + l.Model, l.ProviderID, + } { + h.Write([]byte(part)) + h.Write([]byte{0}) + } + for _, k := range sortedKeys(l.Provider.Headers) { + h.Write([]byte(k + "=" + l.Provider.Headers[k])) + h.Write([]byte{0}) + } + // The env is hashed, never recorded: it carries the API key value. + env := append([]string{}, l.Env...) + sort.Strings(env) + for _, e := range env { + h.Write([]byte(e)) + h.Write([]byte{0}) + } + name := strings.TrimSpace(l.StateKey) + if name == "" { + name = "unkeyed" + } + return name + ":" + hex.EncodeToString(h.Sum(nil)[:8]) +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// probeReady is the ServerSpec readiness gate. session.list is used +// rather than a TCP connect because a bound port proves only that the +// web server row came up: the gateway, its carrier and the session store +// are separate rows, and a profile missing any of them answers 404 on a +// listening socket. +func probeReady(ctx context.Context, baseURL string) error { + client := newAPIClient(enginehost.NewClient(baseURL, readyProbeTimeout)) + _, err := client.ListSessions(ctx) + return err +} + +// serverIdleTimeout is how long a conversation's engine stays warm after +// its last prompt. Overridable so an operator can trade memory for +// first-turn latency without a rebuild. +func serverIdleTimeout() time.Duration { + raw := strings.TrimSpace(os.Getenv("PARSAR_DSH_SERVER_IDLE")) + if raw == "" { + return enginehost.DefaultIdleTimeout + } + d, err := time.ParseDuration(raw) + if err != nil || d == 0 { + return enginehost.DefaultIdleTimeout + } + return d +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go new file mode 100644 index 00000000..7caab9a1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go @@ -0,0 +1,200 @@ +package deepseekharness + +import ( + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func sampleLaunch() serverLaunch { + return serverLaunch{ + Home: "/state/home", + WorkDir: "/work", + Binary: "dsh", + StateKey: "conv:agent:engine", + Provider: providerConfig{ + BaseURL: "https://api.example/v1", + API: "openai-completions", + APIKeyEnv: "PARSAR_DSH_API_KEY", + Model: "deepseek/deepseek-v4-flash", + }, + HasProvider: true, + Env: []string{"PARSAR_DSH_API_KEY=secret-1", "DSH_HOME=/state/home"}, + } +} + +func TestServerKeyIsStableForIdenticalLaunches(t *testing.T) { + a := sampleLaunch().key() + b := sampleLaunch().key() + if a != b { + t.Fatalf("identical launches produced different keys: %q vs %q", a, b) + } + if !strings.HasPrefix(a, "conv:agent:engine:") { + t.Errorf("key should be readable and start with the state key, got %q", a) + } +} + +func TestServerKeyChangesWhenTheBakedRouteChanges(t *testing.T) { + base := sampleLaunch().key() + + cases := map[string]func(*serverLaunch){ + // Each of these is read once, at boot, into the generated profile. + // Reusing a server across such a change would silently run the turn + // on the old route. + "model": func(l *serverLaunch) { l.Provider.Model = "deepseek/other" }, + "base url": func(l *serverLaunch) { l.Provider.BaseURL = "https://elsewhere/v1" }, + "api shape": func(l *serverLaunch) { l.Provider.API = "anthropic-messages" }, + "key env": func(l *serverLaunch) { l.Provider.APIKeyEnv = "OTHER_KEY" }, + "headers": func(l *serverLaunch) { l.Provider.Headers = map[string]string{"x-tenant": "b"} }, + // A rotated credential has to restart the server: the running one + // captured the old value in its environment. + "rotated key": func(l *serverLaunch) { l.Env = []string{"PARSAR_DSH_API_KEY=secret-2", "DSH_HOME=/state/home"} }, + "home": func(l *serverLaunch) { l.Home = "/state/other" }, + "workdir": func(l *serverLaunch) { l.WorkDir = "/other" }, + "binary": func(l *serverLaunch) { l.Binary = "/opt/dsh" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + launch := sampleLaunch() + mutate(&launch) + if got := launch.key(); got == base { + t.Errorf("changing the %s did not change the reuse key", name) + } + }) + } +} + +func TestServerKeyIsIndependentOfEnvOrdering(t *testing.T) { + a := sampleLaunch() + b := sampleLaunch() + b.Env = []string{"DSH_HOME=/state/home", "PARSAR_DSH_API_KEY=secret-1"} + if a.key() != b.key() { + t.Error("env ordering must not fork the reuse key") + } +} + +func TestServerKeyNeverLeaksTheCredential(t *testing.T) { + key := sampleLaunch().key() + if strings.Contains(key, "secret-1") { + t.Fatalf("the reuse key exposed the API key: %q", key) + } +} + +func TestServerSpecPointsAtTheGeneratedProfile(t *testing.T) { + spec := sampleLaunch().spec() + args := spec.Args(51234) + if len(args) != 2 || args[0] != "--profile" || args[1] != serverProfileName { + t.Fatalf("args = %v, want the generated profile", args) + } + // dsh has no port flag: the port reaches it through the profile that + // Prepare writes, so the argv must not carry one. + for _, arg := range args { + if strings.Contains(arg, "51234") { + t.Errorf("argv carries the port, but dsh takes it from config: %v", args) + } + } + if spec.Dir != "/work" { + t.Errorf("spec dir = %q", spec.Dir) + } + if spec.Ready == nil || spec.Prepare == nil || spec.Env == nil { + t.Error("spec must supply a readiness probe, a prepare step and an environment") + } +} + +func TestSpecPrepareWritesABootableProfile(t *testing.T) { + home := t.TempDir() + launch := sampleLaunch() + launch.Home = home + spec := launch.spec() + + if err := spec.Prepare(t.Context(), 41000); err != nil { + t.Fatalf("Prepare: %v", err) + } + body, err := renderServerPatch(serverProfileSpec{ + Home: home, Port: 41000, Provider: launch.Provider, HasProvider: true, + }) + if err != nil { + t.Fatalf("renderServerPatch: %v", err) + } + inserts, _ := rowsFromPatch(t, body) + if got := inserts["@deepseek-ai/dsh-host-webserver"]["port"]; got != 41000 { + t.Errorf("Prepare did not bake the assigned port: %v", got) + } +} + +func TestBuildServerLaunchPinsStateAndTelemetryEnv(t *testing.T) { + req := proto.PromptRequestPayload{ + RunID: "run-1", + ConversationID: "conv-1", + AgentStateKey: "conv-1/agent-1/deepseek_harness", + Prompt: "hi", + AgentOptions: map[string]any{ + // An adapter must not let agent_options redirect the state root, + // widen the file-effect boundary, or re-enable telemetry. + "env": map[string]any{ + dshHomeEnvVar: "/tmp/attacker", + dshPermissionModeEnvVar: "danger-full-access", + dshTelemetryDisabledEnvVar: "", + "HARMLESS": "ok", + }, + }, + } + launch, err := buildServerLaunch(req) + if err != nil { + t.Fatalf("buildServerLaunch: %v", err) + } + + found := map[string]string{} + for _, entry := range launch.Env { + k, v, ok := strings.Cut(entry, "=") + if ok { + found[k] = v + } + } + if found[dshHomeEnvVar] == "/tmp/attacker" { + t.Error("agent_options was able to redirect DSH_HOME") + } + if found[dshHomeEnvVar] != launch.Home { + t.Errorf("DSH_HOME = %q, want the resolved home %q", found[dshHomeEnvVar], launch.Home) + } + if found[dshPermissionModeEnvVar] != sandboxPermissionMode { + t.Errorf("permission mode = %q, want %q", found[dshPermissionModeEnvVar], sandboxPermissionMode) + } + if found[dshTelemetryDisabledEnvVar] != "1" { + t.Errorf("telemetry opt-out = %q, want 1", found[dshTelemetryDisabledEnvVar]) + } + if found["HARMLESS"] != "ok" { + t.Error("an unrelated env entry was dropped") + } + if launch.StateKey != "conv-1/agent-1/deepseek_harness" { + t.Errorf("state key = %q", launch.StateKey) + } +} + +func TestBuildServerLaunchRejectsAnIncompleteManagedRoute(t *testing.T) { + req := proto.PromptRequestPayload{ + RunID: "run-1", + Prompt: "hi", + AgentOptions: map[string]any{ + // dsh refuses the whole profile at boot when a non-shipped route + // is missing a field, so this has to fail here with a readable + // message rather than as an engine boot timeout. + "dsh_provider": map[string]any{"base_url": "https://x/v1", "api": "openai-completions"}, + }, + } + if _, err := buildServerLaunch(req); err == nil { + t.Fatal("an incomplete dsh_provider must be rejected before launch") + } +} + +func TestBuildServerLaunchFallsBackToConversationIDForTheKey(t *testing.T) { + req := proto.PromptRequestPayload{RunID: "run-1", ConversationID: "conv-9", Prompt: "hi"} + launch, err := buildServerLaunch(req) + if err != nil { + t.Fatalf("buildServerLaunch: %v", err) + } + if launch.StateKey != "conv-9" { + t.Errorf("state key = %q, want the conversation id fallback", launch.StateKey) + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go new file mode 100644 index 00000000..5056490c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go @@ -0,0 +1,184 @@ +package deepseekharness + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" + "gopkg.in/yaml.v3" +) + +// The resident-server profile. dsh ships profiles for a desktop app and +// for one-shot headless runs; neither fits a daemon that wants the /api +// gateway and nothing else, so Parsar generates its own. +// +// The row set below is minimal in the literal sense: each row was added +// because booting without it failed, and the profile is a plain +// dsh-base bundle plus these rows — no browser roster, no frontend +// assets, no telemetry surface. +const ( + // serverProfileName is the generated profile's directory name under + // $DSH_HOME/profiles. + serverProfileName = "parsar-api" + + // baseBundle is the shipped plugin bundle the profile builds on. + baseBundle = "@deepseek-ai/dsh-base" + + // apiPathPrefix is where the carrier mounts the gateway. + apiPathPrefix = "/api" + + // eventsMuxPath is the multiplexed session event downlink. It is a + // WebSocket upgrade, not an SSE stream: a plain GET answers 426. + eventsMuxPath = apiPathPrefix + "/events.mux" +) + +// insertRow is a profile patch entry that adds plugin rows rather than +// replacing an existing row's config. +type insertRow struct { + Insert []pluginRow `yaml:"insert"` +} + +type pluginRow struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Config any `yaml:"config,omitempty"` +} + +type storageJSONConfig struct { + // Root is a plain absolute path. dsh's own profiles write this as a + // `!!js dshHomePath(...)` expression; Parsar knows the home it just + // created, so a literal path keeps the generated file free of dsh's + // script tag. + Root string `yaml:"root"` +} + +type storageDomainConfig struct { + Backend string `yaml:"backend"` +} + +type apiGatewayConfig struct { + // NativeOpen stays false: the daemon has no desktop session to open + // a path into, and leaving it on would let a turn ask the host to + // launch an application. + NativeOpen bool `yaml:"nativeOpen"` +} + +type webServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type clientConnectionConfig struct { + // TrustedHosts stays empty so the gateway's trust fence accepts only + // loopback callers. Adding an entry here would expose an + // unauthenticated agent runtime to that host. + TrustedHosts []string `yaml:"trustedHosts"` +} + +// serverProfileSpec is what the profile needs to be materialised. +type serverProfileSpec struct { + Home string + Port int + Provider providerConfig + // HasProvider mirrors normaliseProvider's second return: false means + // no Parsar-managed route, so dsh resolves its own credentials. + HasProvider bool + Model string + ProviderID string +} + +// writeServerProfile materialises $DSH_HOME/profiles/ +// for one launch of the resident server. +// +// Writing into the profile directory (rather than $DSH_HOME's own +// cordis.patch.yml) is deliberate: dsh watches the home layer for live +// edits, so a home-level patch would be re-applied to an already-running +// server. The profile layer is read once at boot, and only the +// supervisor writes it — one launch per state key at a time. +func writeServerProfile(spec serverProfileSpec) error { + dir := filepath.Join(spec.Home, "profiles", serverProfileName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("deepseekharness: mkdir server profile %s: %w", dir, err) + } + + manifest, err := json.MarshalIndent(map[string]any{ + "name": "dsh-profile-" + serverProfileName, + "private": true, + "dsh": map[string]any{"profile": map[string]any{"bundles": []string{baseBundle}}}, + }, "", " ") + if err != nil { + return fmt.Errorf("deepseekharness: marshal server profile manifest: %w", err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), append(manifest, '\n'), 0o600); err != nil { + return fmt.Errorf("deepseekharness: write server profile manifest: %w", err) + } + + // The profile root is an empty entry list on purpose: the tree is + // composed from the bundle plus cordis.patch.yml, which is the only + // file Parsar has to reason about. + root := "# Generated by parsar-daemon. The tree is composed from the bundle in\n" + + "# package.json plus cordis.patch.yml; edit neither by hand.\n[]\n" + if err := os.WriteFile(filepath.Join(dir, "cordis.yml"), []byte(root), 0o600); err != nil { + return fmt.Errorf("deepseekharness: write server profile root: %w", err) + } + + body, err := renderServerPatch(spec) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "cordis.patch.yml"), body, 0o600); err != nil { + return fmt.Errorf("deepseekharness: write server profile patch: %w", err) + } + return nil +} + +// renderServerPatch builds the profile's patch layer: the shared override +// rows, then the plugin rows dsh-base leaves out. +func renderServerPatch(spec serverProfileSpec) ([]byte, error) { + overrides, err := overrideRows(spec.Provider, spec.HasProvider, spec.Model, spec.ProviderID) + if err != nil { + return nil, err + } + + rows := make([]any, 0, len(overrides)+1) + for _, row := range overrides { + rows = append(rows, row) + } + rows = append(rows, insertRow{Insert: []pluginRow{ + // Storage: the session log's backing store. storage-domain needs + // an explicit backend or the tree fails config validation. + {ID: "storage", Name: "@deepseek-ai/dsh-storage"}, + {ID: "storage-json", Name: "@deepseek-ai/dsh-storage-json", + Config: storageJSONConfig{Root: filepath.Join(spec.Home, "storages")}}, + {ID: "storage-domain", Name: "@deepseek-ai/dsh-storage-domain", + Config: storageDomainConfig{Backend: "json"}}, + + // The gateway resolves sessions through the workspace registry, + // and its directory picker must be the non-interactive variant: + // the daemon has no human to answer a native dialog. + {ID: "workspace", Name: "@deepseek-ai/dsh-workspace"}, + {ID: "directory-picker", Name: "@deepseek-ai/dsh-host-directory-picker-auto"}, + + // The gateway itself. It registers no HTTP routes — it only + // provides ctx.apiProxy. + {ID: "api-gateway", Name: "@deepseek-ai/dsh-host-apiproxy", + Config: apiGatewayConfig{NativeOpen: false}}, + + // The physical carrier. Without this row the gateway exists but + // every /api path answers 404, because the transport is what + // mounts the prefix and the events.mux upgrade. + {ID: "webserver", Name: "@deepseek-ai/dsh-host-webserver", + Config: webServerConfig{Host: enginehost.LoopbackHost, Port: spec.Port}}, + {ID: "client-connection", Name: "@deepseek-ai/dsh-client-connection", + Config: clientConnectionConfig{TrustedHosts: []string{}}}, + }}) + + body, err := yaml.Marshal(rows) + if err != nil { + return nil, fmt.Errorf("deepseekharness: marshal server profile patch: %w", err) + } + header := "# Generated by parsar-daemon for the resident dsh /api server.\n" + return append([]byte(header), body...), nil +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go new file mode 100644 index 00000000..1ce51179 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go @@ -0,0 +1,225 @@ +package deepseekharness + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func testProfileSpec(home string, port int) serverProfileSpec { + return serverProfileSpec{ + Home: home, + Port: port, + Provider: providerConfig{ + Name: "Parsar Gateway", + BaseURL: "https://example.invalid/v1", + API: "openai-completions", + APIKeyEnv: "PARSAR_DSH_API_KEY", + Model: "deepseek/deepseek-v4-flash", + }, + HasProvider: true, + } +} + +func TestWriteServerProfileLaysOutTheProfileTree(t *testing.T) { + home := t.TempDir() + if err := writeServerProfile(testProfileSpec(home, 45678)); err != nil { + t.Fatalf("writeServerProfile: %v", err) + } + + dir := filepath.Join(home, "profiles", serverProfileName) + for _, name := range []string{"package.json", "cordis.yml", "cordis.patch.yml"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("missing %s: %v", name, err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("%s mode = %v, want 0600", name, info.Mode().Perm()) + } + } + + manifest, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + if !strings.Contains(string(manifest), baseBundle) { + t.Errorf("manifest does not declare the base bundle: %s", manifest) + } +} + +// rowsFromPatch decodes the generated patch layer into a name→config map +// for the insert rows and a set of overridden row ids. +func rowsFromPatch(t *testing.T, body []byte) (map[string]map[string]any, map[string]bool) { + t.Helper() + var entries []map[string]any + if err := yaml.Unmarshal(body, &entries); err != nil { + t.Fatalf("unmarshal patch: %v\n%s", err, body) + } + inserts := map[string]map[string]any{} + overrides := map[string]bool{} + for _, entry := range entries { + if raw, ok := entry["insert"]; ok { + list, ok := raw.([]any) + if !ok { + t.Fatalf("insert is %T, want a list", raw) + } + for _, item := range list { + row, ok := item.(map[string]any) + if !ok { + t.Fatalf("insert item is %T", item) + } + name, _ := row["name"].(string) + cfg, _ := row["config"].(map[string]any) + inserts[name] = cfg + } + continue + } + if id, ok := entry["id"].(string); ok { + overrides[id] = true + } + } + return inserts, overrides +} + +func TestServerPatchCarriesEveryRowTheGatewayNeeds(t *testing.T) { + home := t.TempDir() + body, err := renderServerPatch(testProfileSpec(home, 45678)) + if err != nil { + t.Fatalf("renderServerPatch: %v", err) + } + inserts, overrides := rowsFromPatch(t, body) + + // Each of these was required to boot: dropping any one of them either + // fails config validation or leaves /api answering 404. + required := []string{ + "@deepseek-ai/dsh-storage", + "@deepseek-ai/dsh-storage-json", + "@deepseek-ai/dsh-storage-domain", + "@deepseek-ai/dsh-workspace", + "@deepseek-ai/dsh-host-directory-picker-auto", + "@deepseek-ai/dsh-host-apiproxy", + "@deepseek-ai/dsh-host-webserver", + "@deepseek-ai/dsh-client-connection", + } + for _, name := range required { + if _, ok := inserts[name]; !ok { + t.Errorf("generated profile is missing plugin row %s", name) + } + } + for _, id := range []string{"permission", "llm-pi-ai", "agent-default-model"} { + if !overrides[id] { + t.Errorf("generated profile does not override row %q", id) + } + } +} + +func TestServerPatchPinsLoopbackAndTheAssignedPort(t *testing.T) { + home := t.TempDir() + body, err := renderServerPatch(testProfileSpec(home, 45678)) + if err != nil { + t.Fatalf("renderServerPatch: %v", err) + } + inserts, _ := rowsFromPatch(t, body) + + web := inserts["@deepseek-ai/dsh-host-webserver"] + if got := web["host"]; got != "127.0.0.1" { + t.Errorf("web server host = %v, want 127.0.0.1", got) + } + if got := web["port"]; got != 45678 { + t.Errorf("web server port = %v, want the assigned 45678", got) + } + + // An empty trusted-host list is what keeps the unauthenticated gateway + // reachable only from inside the sandbox. + carrier := inserts["@deepseek-ai/dsh-client-connection"] + hosts, ok := carrier["trustedHosts"] + if !ok { + t.Fatal("carrier row does not set trustedHosts") + } + if list, _ := hosts.([]any); len(list) != 0 { + t.Errorf("trustedHosts = %v, want empty", hosts) + } + + gateway := inserts["@deepseek-ai/dsh-host-apiproxy"] + if got := gateway["nativeOpen"]; got != false { + t.Errorf("nativeOpen = %v, want false", got) + } +} + +func TestServerPatchRootsStorageUnderTheHome(t *testing.T) { + home := t.TempDir() + body, err := renderServerPatch(testProfileSpec(home, 1234)) + if err != nil { + t.Fatalf("renderServerPatch: %v", err) + } + inserts, _ := rowsFromPatch(t, body) + + root, _ := inserts["@deepseek-ai/dsh-storage-json"]["root"].(string) + if root != filepath.Join(home, "storages") { + t.Errorf("storage root = %q, want %q", root, filepath.Join(home, "storages")) + } + // dsh's own profiles express this with a `!!js` expression. The + // generated file must stay a plain literal so nothing here depends on + // dsh evaluating script in a config layer. + if strings.Contains(string(body), "!!js") { + t.Errorf("generated profile smuggled a script tag:\n%s", body) + } + + backend, _ := inserts["@deepseek-ai/dsh-storage-domain"]["backend"].(string) + if backend != "json" { + t.Errorf("storage-domain backend = %q, want json", backend) + } +} + +func TestServerPatchPinsUnattendedPermissions(t *testing.T) { + home := t.TempDir() + body, err := renderServerPatch(testProfileSpec(home, 1234)) + if err != nil { + t.Fatalf("renderServerPatch: %v", err) + } + var entries []map[string]any + if err := yaml.Unmarshal(body, &entries); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var perm map[string]any + for _, entry := range entries { + if entry["id"] == "permission" { + perm, _ = entry["config"].(map[string]any) + } + } + if perm == nil { + t.Fatal("no permission row") + } + if perm["defaultPreset"] != unattendedPreset { + t.Errorf("defaultPreset = %v, want %s", perm["defaultPreset"], unattendedPreset) + } + presets, _ := perm["presets"].(map[string]any) + preset, _ := presets[unattendedPreset].(map[string]any) + if preset["sandbox"] != sandboxPermissionMode || preset["approval"] != "never" { + t.Errorf("unattended preset = %v, want workspace-write / never", preset) + } +} + +// TestMaterialiseServerProfileForManualBoot writes a profile to a real +// DSH_HOME so a live dsh boot can be driven against generated (not +// hand-written) config. Skipped unless the path is supplied, because it +// needs a dsh install. +func TestMaterialiseServerProfileForManualBoot(t *testing.T) { + home := strings.TrimSpace(os.Getenv("PARSAR_DSH_PROFILE_OUT")) + if home == "" { + t.Skip("set PARSAR_DSH_PROFILE_OUT to materialise a bootable profile") + } + port := 3400 + spec := testProfileSpec(home, port) + spec.Provider.BaseURL = "https://api.sandbase.ai/v1" + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatalf("mkdir home: %v", err) + } + if err := writeServerProfile(spec); err != nil { + t.Fatalf("writeServerProfile: %v", err) + } + t.Logf("wrote profile %s into %s (port %d)", serverProfileName, home, port) +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session.go b/apps/parsar-daemon/internal/agent/deepseekharness/session.go index f9be53f7..4a7202d9 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/session.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session.go @@ -18,6 +18,7 @@ import ( "io" "log/slog" "os" + "strings" "sync" "time" @@ -45,10 +46,36 @@ func defaultConfig() sessionConfig { } // Factory implements agent.Factory for agent_kind="deepseek_harness". +// +// It picks between the two dsh surfaces by where the daemon runs, because +// the resident-server surface is only acceptable in a sandbox: +// +// - Sandbox: a resident `dsh --profile parsar-api` bound to a loopback +// port inside the container. The port does not leave the container, so +// the gateway's "loopback callers are trusted" model is contained by +// the sandbox boundary. This surface streams and resumes. +// - Local device: the one-shot headless CLI. dsh's web server has no +// authentication of any kind, so opening a port on a developer's own +// machine would expose an agent runtime with filesystem access to +// every local process. Continuity on this surface comes from the +// server injecting prior turns. func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + if RunsResidentServer() { + return newServerSession(ctx, req, out, defaultConfig()) + } return newSession(ctx, req, out, defaultConfig()) } +// RunsResidentServer reports whether this daemon should drive dsh through +// a resident /api server. IS_SANDBOX is set by the sandbox image, so a +// local install never trips it by accident. +func RunsResidentServer() bool { + return strings.TrimSpace(os.Getenv(sandboxMarkerEnvVar)) != "" +} + +// sandboxMarkerEnvVar is baked into the Parsar sandbox image. +const sandboxMarkerEnvVar = "IS_SANDBOX" + // Session wraps a single `dsh --profile headless` subprocess. type Session struct { runID string diff --git a/apps/parsar-daemon/internal/cli/agent_cli.go b/apps/parsar-daemon/internal/cli/agent_cli.go index d42441b8..760335db 100644 --- a/apps/parsar-daemon/internal/cli/agent_cli.go +++ b/apps/parsar-daemon/internal/cli/agent_cli.go @@ -192,16 +192,43 @@ func agentCLIDescriptors() agentCLIDiscovery { }, }, DeepseekHarness: proto.SupportedAgentKind{ - Kind: "deepseek_harness", - // `dsh --profile headless` is the harness's only supported - // automation surface: it prints the final assistant text and - // exits, with no event stream, token accounting, resume flag, - // or approval channel to advertise. - Capabilities: proto.AgentKindCapabilities{}, + Kind: "deepseek_harness", + Capabilities: deepseekHarnessCapabilities(), }, } } +// deepseekHarnessCapabilities is the one descriptor in this table that +// depends on where the daemon runs, because dsh's two automation surfaces +// are not equivalent and the adapter can only use the better one inside a +// sandbox (dsh's web server has no authentication, so Parsar will not open +// its port on a developer's own machine). +// +// The distinction is load-bearing beyond UI copy: the server injects prior +// conversation turns into the prompt precisely when a device reports +// Resume=false, so getting this wrong would either lose continuity or +// duplicate history the engine already has. +func deepseekHarnessCapabilities() proto.AgentKindCapabilities { + if deepseekharness.RunsResidentServer() { + // Resident `dsh --profile parsar-api`: the /api gateway streams + // token-level deltas and tool events, reports per-request token + // usage, and continues a conversation by prompting its session id + // (warm from memory or loaded from disk after a restart). + // + // Permissions stays false deliberately: the generated profile pins + // the unattended preset, so dsh rejects escalation itself rather + // than asking, and there is no approver on this path. + return proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + Resume: true, + } + } + // `dsh --profile headless` prints the final assistant text and exits: + // no event stream, no token accounting, no resume, no approval channel. + return proto.AgentKindCapabilities{} +} + func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery) { registry.RegisterKind(agentCLIs.ClaudeCode, claudecode.Factory) registry.RegisterKind(agentCLIs.OpenCode, opencodeagent.Factory) diff --git a/apps/parsar-daemon/internal/cli/agent_cli_test.go b/apps/parsar-daemon/internal/cli/agent_cli_test.go index c0e23945..108faf5e 100644 --- a/apps/parsar-daemon/internal/cli/agent_cli_test.go +++ b/apps/parsar-daemon/internal/cli/agent_cli_test.go @@ -267,3 +267,32 @@ func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { } } } + +func TestDeepseekHarnessCapabilitiesFollowTheRunLocation(t *testing.T) { + // dsh's two automation surfaces are not equivalent, and the adapter can + // only use the resident-server one inside a sandbox. The descriptor has + // to say so, because the server keys its conversation-history injection + // off Resume: advertising Resume=true on the headless surface would + // silently drop continuity. + t.Setenv("IS_SANDBOX", "") + local := deepseekHarnessCapabilities() + if local != (proto.AgentKindCapabilities{}) { + t.Errorf("local device capabilities = %+v, want none", local) + } + + t.Setenv("IS_SANDBOX", "1") + sandbox := deepseekHarnessCapabilities() + if !sandbox.Streaming || !sandbox.Usage || !sandbox.Resume { + t.Errorf("sandbox capabilities = %+v, want streaming, usage and resume", sandbox) + } + // The generated profile pins the unattended preset, so dsh rejects + // escalation itself instead of asking; there is no approver on this + // path and claiming otherwise would surface dead permission cards. + if sandbox.Permissions { + t.Error("the resident-server path has no approver and must not claim permissions") + } + + if got := agentCLIDescriptors().DeepseekHarness.Capabilities; got != sandbox { + t.Errorf("descriptor table does not use the run-location capabilities: %+v", got) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 679f48e7..5b51a3ef 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -12,6 +12,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/deepseekharness" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" @@ -273,6 +274,10 @@ func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agent registry := agent.NewRegistry() registerAgentKinds(registry, agentCLIs) + // Engines the daemon keeps resident between prompts must not outlive + // the daemon: an orphaned server would hold a loopback port and a + // session store that nothing owns. + defer deepseekharness.ShutdownServers() dial := func(ctx context.Context) (*transport.Conn, error) { return transport.Dial(ctx, transport.DialOptions{ diff --git a/apps/parsar-daemon/internal/enginehost/client.go b/apps/parsar-daemon/internal/enginehost/client.go new file mode 100644 index 00000000..2963a6d4 --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/client.go @@ -0,0 +1,106 @@ +package enginehost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client is the transport half of the engine-server contract: JSON over +// loopback HTTP plus a WebSocket downlink. It is deliberately protocol +// agnostic — it moves bytes and decodes JSON, and knows nothing about any +// engine's envelope, method names or event vocabulary. Adapters wrap it +// with their own typed calls. +// +// Requests carry no Origin header. The engines this package supervises +// gate /api on a browser-trust check that accepts a loopback Host with no +// Origin, and rejects a cross-site one; sending an Origin we invented +// would be the one way to fail that check. +type Client struct { + baseURL string + http *http.Client +} + +// NewClient binds a Client to a lease's base URL. timeout bounds unary +// calls; pass 0 for no client-side deadline (long turns should instead be +// bounded by the caller's context). +func NewClient(baseURL string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: timeout}, + } +} + +// BaseURL is the origin this client talks to. +func (c *Client) BaseURL() string { return c.baseURL } + +// PostJSON sends body as JSON to path and decodes the response into out. +// A nil out discards the body. Non-2xx responses become errors carrying a +// truncated body, because engine gateways answer policy rejections with a +// bare status and a one-word body. +func (c *Client) PostJSON(ctx context.Context, path string, body, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("enginehost: encode request %s: %w", path, err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url(path), bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("enginehost: build request %s: %w", path, err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("enginehost: post %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("enginehost: post %s: status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(snippet))) + } + if out == nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("enginehost: decode response %s: %w", path, err) + } + return nil +} + +// Probe is a ServerSpec.Ready helper: it reports success when a POST to +// path answers any 2xx. Adapters that need a specific handshake write +// their own probe instead. +func (c *Client) Probe(ctx context.Context, path string, body any) error { + return c.PostJSON(ctx, path, body, nil) +} + +func (c *Client) url(path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return c.baseURL + path +} + +// wsURL rewrites the client's origin to the ws scheme for a downlink. +func (c *Client) wsURL(path string) (string, error) { + u, err := url.Parse(c.url(path)) + if err != nil { + return "", fmt.Errorf("enginehost: parse downlink url: %w", err) + } + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + } + return u.String(), nil +} diff --git a/apps/parsar-daemon/internal/enginehost/client_test.go b/apps/parsar-daemon/internal/enginehost/client_test.go new file mode 100644 index 00000000..6a301a54 --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/client_test.go @@ -0,0 +1,126 @@ +package enginehost + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestPostJSONRoundTripsAndRejectsNonSuccess(t *testing.T) { + var gotPath, gotBody, gotOrigin string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotOrigin = r.Header.Get("Origin") + buf := make([]byte, 256) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + if r.URL.Path == "/api/denied" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + return + } + _, _ = w.Write([]byte(`{"echo":"pong"}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, 5*time.Second) + + var out struct{ Echo string } + if err := c.PostJSON(context.Background(), "api/ping", map[string]string{"ping": "1"}, &out); err != nil { + t.Fatalf("PostJSON: %v", err) + } + if out.Echo != "pong" { + t.Fatalf("decoded %+v", out) + } + if gotPath != "/api/ping" { + t.Fatalf("path not normalised, got %q", gotPath) + } + if gotBody != `{"ping":"1"}` { + t.Fatalf("body %q", gotBody) + } + // The loopback trust rule these engines apply rejects a mismatched + // Origin, so the client must not invent one. + if gotOrigin != "" { + t.Fatalf("client sent an Origin header: %q", gotOrigin) + } + + err := c.PostJSON(context.Background(), "/api/denied", struct{}{}, nil) + if err == nil { + t.Fatal("expected an error for a 403") + } + if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "forbidden") { + t.Fatalf("error should carry status and body, got %v", err) + } +} + +func TestDialStreamsFramesAndClosesCleanly(t *testing.T) { + upgrader := websocket.Upgrader{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for _, frame := range []string{`{"seq":1}`, `{"seq":2}`, `{"seq":3}`} { + if err := conn.WriteMessage(websocket.TextMessage, []byte(frame)); err != nil { + return + } + } + _ = conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + })) + defer srv.Close() + + c := NewClient(srv.URL, 5*time.Second) + down, err := c.Dial(context.Background(), "/api/events.mux") + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer down.Close() + + var got []string + for frame := range down.Frames() { + got = append(got, string(frame)) + } + if strings.Join(got, ",") != `{"seq":1},{"seq":2},{"seq":3}` { + t.Fatalf("frames %v", got) + } + // A server-side normal close is a clean end of stream, not a failure. + if err := down.Err(); err != nil { + t.Fatalf("clean close reported an error: %v", err) + } +} + +func TestDialFailsOnNonUpgradePath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := NewClient(srv.URL, 5*time.Second) + if _, err := c.Dial(context.Background(), "/api/events.mux"); err == nil { + t.Fatal("expected a dial failure") + } else if !strings.Contains(err.Error(), "404") { + t.Fatalf("error should report the status, got %v", err) + } +} + +func TestWSURLDerivesSchemeFromBase(t *testing.T) { + for base, want := range map[string]string{ + "http://127.0.0.1:1234": "ws://127.0.0.1:1234/api/events.mux", + "https://127.0.0.1:1234": "wss://127.0.0.1:1234/api/events.mux", + } { + got, err := NewClient(base, 0).wsURL("/api/events.mux") + if err != nil { + t.Fatalf("wsURL(%s): %v", base, err) + } + if got != want { + t.Fatalf("wsURL(%s) = %q, want %q", base, got, want) + } + } +} diff --git a/apps/parsar-daemon/internal/enginehost/downlink.go b/apps/parsar-daemon/internal/enginehost/downlink.go new file mode 100644 index 00000000..ff6a1a8a --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/downlink.go @@ -0,0 +1,121 @@ +package enginehost + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// downlinkBuffer is how many frames may queue before the reader blocks. +// Engine servers emit token-level deltas, so a small buffer smooths the +// gap between a burst and a slow consumer without hiding backpressure. +const downlinkBuffer = 256 + +// Downlink is a read-only WebSocket event stream from an engine server. +// Frames arrive as raw JSON so the adapter owns all decoding: the event +// vocabulary is engine-specific and this package must not grow a switch +// over it. +// +// Lifetime: Frames is closed exactly once, after which Err reports why. +// Close is idempotent and unblocks the reader. +type Downlink struct { + frames chan []byte + conn *websocket.Conn + + closeOnce sync.Once + errMu sync.Mutex + err error +} + +// Dial opens a downlink on path (e.g. "/api/events.mux"). The handshake +// sends no Origin header, matching the loopback trust rule these engines +// enforce. +func (c *Client) Dial(ctx context.Context, path string) (*Downlink, error) { + target, err := c.wsURL(path) + if err != nil { + return nil, err + } + dialer := &websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + Proxy: nil, // loopback: never route a downlink through a proxy + } + conn, resp, err := dialer.DialContext(ctx, target, http.Header{}) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + _ = resp.Body.Close() + } + return nil, fmt.Errorf("enginehost: dial downlink %s (status %d): %w", path, status, err) + } + if resp != nil { + _ = resp.Body.Close() + } + + d := &Downlink{frames: make(chan []byte, downlinkBuffer), conn: conn} + go d.read() + return d, nil +} + +// Frames yields every text frame the server sent, in order. +func (d *Downlink) Frames() <-chan []byte { return d.frames } + +// Err returns the reason the stream ended. A clean server-side close +// reports nil. +func (d *Downlink) Err() error { + d.errMu.Lock() + defer d.errMu.Unlock() + return d.err +} + +// Close tears the connection down. Idempotent. +func (d *Downlink) Close() { + d.closeOnce.Do(func() { _ = d.conn.Close() }) +} + +func (d *Downlink) read() { + defer close(d.frames) + for { + msgType, payload, err := d.conn.ReadMessage() + if err != nil { + if !isCleanClose(err) { + d.setErr(err) + } + return + } + if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage { + continue + } + // Copying is required: gorilla reuses its read buffer, so handing + // the slice to a consumer that outlives the next ReadMessage would + // alias mutated bytes. + frame := make([]byte, len(payload)) + copy(frame, payload) + d.frames <- frame + } +} + +func (d *Downlink) setErr(err error) { + d.errMu.Lock() + defer d.errMu.Unlock() + if d.err == nil { + d.err = err + } +} + +// isCleanClose treats the shutdown paths that are not failures as clean: +// a normal/going-away close frame, and a local Close racing the reader. +func isCleanClose(err error) bool { + if errors.Is(err, context.Canceled) { + return true + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + return true + } + return errors.Is(err, websocket.ErrCloseSent) +} diff --git a/apps/parsar-daemon/internal/enginehost/instance.go b/apps/parsar-daemon/internal/enginehost/instance.go new file mode 100644 index 00000000..02c74d54 --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/instance.go @@ -0,0 +1,203 @@ +package enginehost + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "net" + "strconv" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// DefaultStderrLines is how many trailing stderr lines an instance keeps +// so a readiness failure can be explained without a log dive. +const DefaultStderrLines = 40 + +// LoopbackHost is the only interface an engine server is ever bound to. +// These servers authenticate nobody: the engines this package supervises +// gate requests on "the peer is on loopback" and nothing else, so binding +// any other interface would publish an unauthenticated agent runtime to +// the network. +const LoopbackHost = "127.0.0.1" + +// instance is one resident engine server. It is created ready or not at +// all: newInstance returns only after the spec's Ready probe passes, so +// callers never observe a half-booted server. +type instance struct { + key string + port int + baseURL string + proc *clirunner.Process + + stderr *lineTail + + // leases counts live Lease values. Guarded by the supervisor's mutex, + // not by the instance, because lease transitions and the map lookup + // that finds this instance have to be one atomic step. + leases int + + // idleTimer fires the reclamation when leases hits zero. Also guarded + // by the supervisor's mutex. + idleTimer *time.Timer + + stopOnce sync.Once + exited chan struct{} +} + +// newInstance allocates a port, prepares state, launches, and waits for +// readiness. Every failure path leaves no process running. +func newInstance(ctx context.Context, spec ServerSpec) (*instance, error) { + logger := spec.Logger + if logger == nil { + logger = obslog.Bg() + } + + port, err := freeLoopbackPort() + if err != nil { + return nil, err + } + if spec.Prepare != nil { + if err := spec.Prepare(ctx, port); err != nil { + return nil, fmt.Errorf("enginehost: prepare %s: %w", spec.Key, err) + } + } + + var args []string + if spec.Args != nil { + args = spec.Args(port) + } + var env []string + if spec.Env != nil { + env = spec.Env(port) + } + + // The process is deliberately parented to context.Background(): it + // outlives the prompt that launched it, and its lifetime is owned by + // lease counting and Stop, not by any one caller's context. + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: context.Background(), + Binary: spec.Binary, + Args: args, + Dir: spec.Dir, + Env: env, + KillTimeout: spec.killTimeout(), + }) + if err != nil { + return nil, fmt.Errorf("enginehost: start %s: %w", spec.Binary, err) + } + + lines := spec.StderrLines + if lines <= 0 { + lines = DefaultStderrLines + } + inst := &instance{ + key: spec.Key, + port: port, + baseURL: "http://" + LoopbackHost + ":" + strconv.Itoa(port), + proc: proc, + stderr: newLineTail(lines), + exited: make(chan struct{}), + } + + go inst.drain(proc.Stderr, logger, "stderr") + // Engine servers log to stdout too; draining it prevents a full pipe + // from wedging the process, and the tail helps diagnose a bad boot. + go inst.drain(proc.Stdout, logger, "stdout") + go func() { + waitErr := proc.Wait() + close(inst.exited) + logger.Info("enginehost: engine server exited", "key", spec.Key, "port", port, "err", waitErr) + }() + + if err := inst.awaitReady(ctx, spec); err != nil { + inst.stop() + return nil, err + } + logger.Info("enginehost: engine server ready", "key", spec.Key, "port", port) + return inst, nil +} + +// awaitReady polls the spec probe until it passes, the deadline expires, +// or the process dies. A dead process short-circuits: waiting out the +// full ready timeout on a process that already exited only delays the +// error the caller needs. +func (i *instance) awaitReady(ctx context.Context, spec ServerSpec) error { + deadline := time.Now().Add(spec.readyTimeout()) + var lastErr error + for { + select { + case <-i.exited: + return fmt.Errorf("enginehost: %s exited before becoming ready: %s", spec.Key, i.stderr.String()) + case <-ctx.Done(): + return fmt.Errorf("enginehost: %s readiness cancelled: %w", spec.Key, ctx.Err()) + default: + } + + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + lastErr = spec.Ready(probeCtx, i.baseURL) + cancel() + if lastErr == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("enginehost: %s not ready within %s: %w (stderr: %s)", + spec.Key, spec.readyTimeout(), lastErr, i.stderr.String()) + } + select { + case <-time.After(250 * time.Millisecond): + case <-i.exited: + case <-ctx.Done(): + } + } +} + +func (i *instance) drain(r io.Reader, logger *slog.Logger, stream string) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + i.stderr.push(line) + logger.Debug("enginehost: engine server output", "key", i.key, "stream", stream, "line", line) + } +} + +// alive reports whether the process is still running. +func (i *instance) alive() bool { + select { + case <-i.exited: + return false + default: + return true + } +} + +// stop terminates the process. Idempotent. +func (i *instance) stop() { + i.stopOnce.Do(func() { i.proc.Cancel() }) +} + +// freeLoopbackPort asks the kernel for an unused loopback port and +// releases it immediately so the engine can bind it. +// +// This hands the port over through a close/bind gap rather than passing a +// listening socket, because these engines bind their own listener from a +// config value. The gap is a real (if narrow) race; a lost race surfaces +// as a boot failure with the engine's own "address in use" on the stderr +// tail, which the caller retries at the next prompt. +func freeLoopbackPort() (int, error) { + l, err := net.Listen("tcp", net.JoinHostPort(LoopbackHost, "0")) + if err != nil { + return 0, fmt.Errorf("enginehost: reserve loopback port: %w", err) + } + port := l.Addr().(*net.TCPAddr).Port + if err := l.Close(); err != nil { + return 0, fmt.Errorf("enginehost: release reserved port %d: %w", port, err) + } + return port, nil +} diff --git a/apps/parsar-daemon/internal/enginehost/linetail.go b/apps/parsar-daemon/internal/enginehost/linetail.go new file mode 100644 index 00000000..871217c4 --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/linetail.go @@ -0,0 +1,44 @@ +package enginehost + +import ( + "strings" + "sync" +) + +// lineTail is a fixed-size ring of the most recent output lines. Engine +// boot failures are explained by the last few lines, so the tail is +// bounded rather than accumulating a whole session's chatter in memory. +type lineTail struct { + mu sync.Mutex + limit int + lines []string +} + +func newLineTail(limit int) *lineTail { + if limit <= 0 { + limit = DefaultStderrLines + } + return &lineTail{limit: limit, lines: make([]string, 0, limit)} +} + +func (t *lineTail) push(line string) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if len(t.lines) == t.limit { + copy(t.lines, t.lines[1:]) + t.lines = t.lines[:t.limit-1] + } + t.lines = append(t.lines, line) +} + +func (t *lineTail) String() string { + if t == nil { + return "" + } + t.mu.Lock() + defer t.mu.Unlock() + return strings.Join(t.lines, " | ") +} diff --git a/apps/parsar-daemon/internal/enginehost/spec.go b/apps/parsar-daemon/internal/enginehost/spec.go new file mode 100644 index 00000000..38fc642b --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/spec.go @@ -0,0 +1,135 @@ +// Package enginehost supervises long-lived local engine servers on behalf +// of agent adapters. +// +// Some agent CLIs expose their full capability surface (streaming events, +// approvals, cross-process session resume) only through a resident HTTP +// server rather than a one-shot invocation. Those adapters need the same +// four things, none of which is engine-specific: +// +// - one server process per state key, shared by every prompt of that key +// instead of relaunched per run, +// - a loopback port that never leaves the machine, +// - a readiness gate so the first prompt does not race the bind, +// - idle reclamation so an abandoned conversation does not pin a process. +// +// Supervisor owns all four. An adapter contributes only a ServerSpec: how +// to lay out state, how to launch, and how to tell "listening" from +// "still booting". Nothing in this package names a concrete engine, and +// nothing here speaks an engine's wire protocol — adapters own their own +// request and event mapping (see Client for the transport helpers). +// +// Ownership boundary: a Supervisor hands out *Lease values, never raw +// processes. A lease keeps the instance alive; releasing the last lease +// starts the idle clock. Adapters must Release exactly once per Acquire, +// and must not retain a BaseURL past Release. +package enginehost + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" +) + +// Default timings. Each is deliberately generous: an engine server's +// first boot may compile a plugin tree or provision a state directory. +const ( + DefaultReadyTimeout = 90 * time.Second + DefaultIdleTimeout = 10 * time.Minute + DefaultKillTimeout = 5 * time.Second +) + +// ServerSpec describes one engine server an adapter wants resident. The +// zero value is not usable: Key, Binary and Ready are required. +// +// Args and Env are functions of the assigned port because the supervisor +// picks the port, not the adapter — a spec that hardcoded a port could +// not be shared by two state keys on the same host. +type ServerSpec struct { + // Key is the reuse identity. Two Acquire calls with equal keys share + // one process. Adapters normally derive it from the daemon's + // agent_state_key so a conversation keeps its resident engine (and, + // with it, that engine's session store) across prompts. + Key string + + // Binary is the executable to launch, resolved through PATH. + Binary string + + // Args returns the argv tail for the assigned port. + Args func(port int) []string + + // Env returns the full environment for the process. A nil Env means + // the process inherits the daemon's environment unchanged. + Env func(port int) []string + + // Dir is the working directory. Engines that treat CWD as the + // workspace root need this set; others may leave it empty. + Dir string + + // Prepare runs once per launch, before the process starts. Adapters + // use it to materialise a profile or config tree. A Prepare error + // fails the Acquire without leaving a process behind. + Prepare func(ctx context.Context, port int) error + + // Ready reports whether the server at baseURL is serving. It is + // polled until it returns nil or ReadyTimeout elapses, so it must be + // cheap and side-effect free. A nil error means "listening and + // answering"; any error means "not yet". + Ready func(ctx context.Context, baseURL string) error + + // ReadyTimeout bounds the readiness poll. Zero means + // DefaultReadyTimeout. + ReadyTimeout time.Duration + + // IdleTimeout is how long an instance with no live lease is kept + // warm. Zero means DefaultIdleTimeout. Negative means "stop as soon + // as the last lease is released". + IdleTimeout time.Duration + + // KillTimeout is the grace period between SIGTERM and SIGKILL during + // teardown. Zero means DefaultKillTimeout. + KillTimeout time.Duration + + // StderrLines caps the retained stderr tail used to explain a failed + // boot. Zero means DefaultStderrLines. + StderrLines int + + // Logger receives lifecycle and stderr records. Zero means the + // process-wide background logger. + Logger *slog.Logger +} + +func (s ServerSpec) validate() error { + if strings.TrimSpace(s.Key) == "" { + return fmt.Errorf("enginehost: spec key required") + } + if strings.TrimSpace(s.Binary) == "" { + return fmt.Errorf("enginehost: spec binary required") + } + if s.Ready == nil { + return fmt.Errorf("enginehost: spec ready probe required") + } + return nil +} + +func (s ServerSpec) readyTimeout() time.Duration { + if s.ReadyTimeout <= 0 { + return DefaultReadyTimeout + } + return s.ReadyTimeout +} + +func (s ServerSpec) idleTimeout() time.Duration { + if s.IdleTimeout == 0 { + return DefaultIdleTimeout + } + return s.IdleTimeout +} + +func (s ServerSpec) killTimeout() time.Duration { + if s.KillTimeout <= 0 { + return DefaultKillTimeout + } + return s.KillTimeout +} diff --git a/apps/parsar-daemon/internal/enginehost/supervisor.go b/apps/parsar-daemon/internal/enginehost/supervisor.go new file mode 100644 index 00000000..6507fa6c --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/supervisor.go @@ -0,0 +1,262 @@ +package enginehost + +import ( + "context" + "log/slog" + "sync" + "time" + + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// Supervisor keeps at most one resident engine server per spec key and +// shares it across prompts. Safe for concurrent use. +// +// Concurrency model: one mutex guards the key map, every instance's lease +// count and every idle timer. Launching a server is slow (it waits for +// readiness), so a launch does NOT hold the mutex; instead the launching +// goroutine installs a pending entry that other Acquire calls for the +// same key wait on. That keeps two simultaneous first prompts of one +// conversation from starting two servers, which for engines with a +// single-writer session store would corrupt state rather than merely +// waste a process. +type Supervisor struct { + mu sync.Mutex + entries map[string]*entry + logger *slog.Logger + stopping bool +} + +// entry is either a launch in flight or a live instance. ready is closed +// when the launch settles; inst and err are valid only after that. +type entry struct { + ready chan struct{} + inst *instance + err error + + // spec timings are captured at launch so a later Release reclaims + // with the idle window the launching caller asked for. + idleTimeout time.Duration +} + +func NewSupervisor(logger *slog.Logger) *Supervisor { + if logger == nil { + logger = obslog.Bg() + } + return &Supervisor{entries: make(map[string]*entry), logger: logger} +} + +// Lease is a live claim on a resident engine server. BaseURL is valid +// until Release; the instance is not reclaimed while any lease is open. +type Lease struct { + sup *Supervisor + key string + inst *instance + once sync.Once +} + +// BaseURL is the loopback origin of the engine server, e.g. +// "http://127.0.0.1:51234". It carries no trailing slash. +func (l *Lease) BaseURL() string { + if l == nil || l.inst == nil { + return "" + } + return l.inst.baseURL +} + +// Exited is closed when the engine server process terminates. Adapters +// select on it so a crashed engine fails the run instead of hanging on a +// request that will never be answered. +func (l *Lease) Exited() <-chan struct{} { + if l == nil || l.inst == nil { + closed := make(chan struct{}) + close(closed) + return closed + } + return l.inst.exited +} + +// Diagnostics returns the retained output tail, for error messages. +func (l *Lease) Diagnostics() string { + if l == nil || l.inst == nil { + return "" + } + return l.inst.stderr.String() +} + +// Release drops this claim. Idempotent. When it drops the last claim the +// instance stays warm for the spec's idle timeout, then is stopped. +func (l *Lease) Release() { + if l == nil { + return + } + l.once.Do(func() { l.sup.release(l.key, l.inst) }) +} + +// Acquire returns a lease on the server for spec.Key, launching it if +// there is no live one. A launch already in flight for the same key is +// awaited rather than duplicated. +func (s *Supervisor) Acquire(ctx context.Context, spec ServerSpec) (*Lease, error) { + if err := spec.validate(); err != nil { + return nil, err + } + if spec.Logger == nil { + spec.Logger = s.logger + } + for { + lease, wait, err := s.tryAcquire(ctx, spec) + if err != nil { + return nil, err + } + if lease != nil { + return lease, nil + } + if wait == nil { + // The cached entry was dead or failed and has been evicted. + // Retry immediately; the next pass launches a replacement. + continue + } + // Another caller is launching this key. Wait for it, then retake + // the lock: its instance may already have died, in which case the + // next pass launches a replacement. + select { + case <-wait: + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +// tryAcquire performs one attempt. It returns exactly one of: a lease, a +// channel to wait on, an error, or all-nil meaning "a stale entry was +// evicted, retry immediately". +func (s *Supervisor) tryAcquire(ctx context.Context, spec ServerSpec) (*Lease, <-chan struct{}, error) { + s.mu.Lock() + if s.stopping { + s.mu.Unlock() + return nil, nil, context.Canceled + } + if existing, ok := s.entries[spec.Key]; ok { + select { + case <-existing.ready: + // Settled. A failed or dead instance is discarded here so the + // caller's next pass launches a fresh one. + if existing.err != nil || existing.inst == nil || !existing.inst.alive() { + delete(s.entries, spec.Key) + s.mu.Unlock() + return nil, nil, nil + } + lease := s.attachLocked(existing, spec.Key) + s.mu.Unlock() + return lease, nil, nil + default: + wait := existing.ready + s.mu.Unlock() + return nil, wait, nil + } + } + + e := &entry{ready: make(chan struct{}), idleTimeout: spec.idleTimeout()} + s.entries[spec.Key] = e + s.mu.Unlock() + + // Launch outside the lock. The pending entry is already published, so + // concurrent Acquire calls for this key queue on e.ready. + inst, err := newInstance(ctx, spec) + + s.mu.Lock() + e.inst, e.err = inst, err + close(e.ready) + if err != nil || inst == nil { + delete(s.entries, spec.Key) + s.mu.Unlock() + return nil, nil, err + } + if s.stopping { + delete(s.entries, spec.Key) + s.mu.Unlock() + inst.stop() + return nil, nil, context.Canceled + } + lease := s.attachLocked(e, spec.Key) + s.mu.Unlock() + return lease, nil, nil +} + +// attachLocked adds a lease to a live instance. Caller holds s.mu. +func (s *Supervisor) attachLocked(e *entry, key string) *Lease { + e.inst.leases++ + if e.inst.idleTimer != nil { + e.inst.idleTimer.Stop() + e.inst.idleTimer = nil + } + return &Lease{sup: s, key: key, inst: e.inst} +} + +func (s *Supervisor) release(key string, inst *instance) { + s.mu.Lock() + defer s.mu.Unlock() + if inst == nil { + return + } + inst.leases-- + if inst.leases > 0 { + return + } + inst.leases = 0 + + e, ok := s.entries[key] + if !ok || e.inst != inst { + // Already superseded or dropped; nothing keeps this process. + inst.stop() + return + } + if s.stopping || e.idleTimeout < 0 || !inst.alive() { + delete(s.entries, key) + inst.stop() + return + } + inst.idleTimer = time.AfterFunc(e.idleTimeout, func() { s.reclaim(key, inst) }) +} + +// reclaim stops an instance whose idle window expired, unless a lease was +// taken in the meantime. +func (s *Supervisor) reclaim(key string, inst *instance) { + s.mu.Lock() + e, ok := s.entries[key] + if !ok || e.inst != inst || inst.leases > 0 { + s.mu.Unlock() + return + } + delete(s.entries, key) + inst.idleTimer = nil + s.mu.Unlock() + + s.logger.Info("enginehost: reclaiming idle engine server", "key", key, "port", inst.port) + inst.stop() +} + +// Shutdown stops every resident server and rejects further Acquire calls. +// Used on daemon teardown so engine servers do not outlive the daemon. +func (s *Supervisor) Shutdown() { + s.mu.Lock() + s.stopping = true + pending := make([]*entry, 0, len(s.entries)) + for key, e := range s.entries { + pending = append(pending, e) + delete(s.entries, key) + } + s.mu.Unlock() + + for _, e := range pending { + select { + case <-e.ready: + if e.inst != nil { + e.inst.stop() + } + default: + // A launch in flight observes s.stopping when it settles and + // stops its own instance. + } + } +} diff --git a/apps/parsar-daemon/internal/enginehost/supervisor_test.go b/apps/parsar-daemon/internal/enginehost/supervisor_test.go new file mode 100644 index 00000000..6e0753f7 --- /dev/null +++ b/apps/parsar-daemon/internal/enginehost/supervisor_test.go @@ -0,0 +1,409 @@ +package enginehost + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// The tests launch this same test binary as the "engine server": with +// enginehostFakeServerEnv set it serves /ping on the given port and does +// nothing else. That exercises the real process, port and readiness paths +// instead of stubbing them out, which is where the ownership bugs live. +const ( + enginehostFakeServerEnv = "ENGINEHOST_TEST_FAKE_SERVER_PORT" + enginehostFakeDelayEnv = "ENGINEHOST_TEST_FAKE_BOOT_DELAY" + enginehostFakeFailEnv = "ENGINEHOST_TEST_FAKE_FAIL" +) + +func TestMain(m *testing.M) { + if port := os.Getenv(enginehostFakeServerEnv); port != "" { + runFakeServer(port) + return + } + os.Exit(m.Run()) +} + +func runFakeServer(port string) { + if os.Getenv(enginehostFakeFailEnv) != "" { + fmt.Fprintln(os.Stderr, "fake engine: refusing to boot") + os.Exit(3) + } + if delay := os.Getenv(enginehostFakeDelayEnv); delay != "" { + if d, err := time.ParseDuration(delay); err == nil { + time.Sleep(d) + } + } + mux := http.NewServeMux() + mux.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + // Announced on stdout so a test can assert the tail is captured. + fmt.Println("fake engine listening on " + port) + srv := &http.Server{Addr: net.JoinHostPort(LoopbackHost, port), Handler: mux, ReadHeaderTimeout: 5 * time.Second} + _ = srv.ListenAndServe() + os.Exit(0) +} + +func fakeSpec(t *testing.T, key string, extraEnv ...string) ServerSpec { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatalf("locate test binary: %v", err) + } + return ServerSpec{ + Key: key, + Binary: exe, + Env: func(port int) []string { + env := append(os.Environ(), enginehostFakeServerEnv+"="+strconv.Itoa(port)) + return append(env, extraEnv...) + }, + Ready: pingReady, + ReadyTimeout: 20 * time.Second, + IdleTimeout: time.Hour, + KillTimeout: time.Second, + Logger: testLogger(), + } +} + +func pingReady(ctx context.Context, baseURL string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/ping", nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + return nil +} + +func TestAcquireReusesOneServerPerKey(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + first, err := sup.Acquire(context.Background(), fakeSpec(t, "reuse")) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + second, err := sup.Acquire(context.Background(), fakeSpec(t, "reuse")) + if err != nil { + t.Fatalf("second acquire: %v", err) + } + if first.BaseURL() != second.BaseURL() { + t.Fatalf("expected one shared server, got %q and %q", first.BaseURL(), second.BaseURL()) + } + + other, err := sup.Acquire(context.Background(), fakeSpec(t, "other-key")) + if err != nil { + t.Fatalf("other acquire: %v", err) + } + if other.BaseURL() == first.BaseURL() { + t.Fatalf("distinct keys must not share a server, both got %q", other.BaseURL()) + } + first.Release() + second.Release() + other.Release() +} + +func TestAcquireDeduplicatesConcurrentLaunches(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + // A boot delay widens the window in which a duplicate launch would + // happen if the pending entry were not published before the launch. + spec := fakeSpec(t, "concurrent", enginehostFakeDelayEnv+"=300ms") + + const callers = 6 + var wg sync.WaitGroup + urls := make([]string, callers) + errs := make([]error, callers) + leases := make([]*Lease, callers) + for i := range callers { + wg.Add(1) + go func() { + defer wg.Done() + lease, err := sup.Acquire(context.Background(), spec) + errs[i] = err + if err == nil { + leases[i] = lease + urls[i] = lease.BaseURL() + } + }() + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("caller %d: %v", i, err) + } + } + for i, u := range urls { + if u != urls[0] { + t.Fatalf("caller %d got %q, want the shared %q", i, u, urls[0]) + } + } + for _, l := range leases { + l.Release() + } +} + +func TestReleaseWithNegativeIdleTimeoutStopsImmediately(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + spec := fakeSpec(t, "eager-stop") + spec.IdleTimeout = -1 + + lease, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("acquire: %v", err) + } + exited := lease.Exited() + lease.Release() + + select { + case <-exited: + case <-time.After(10 * time.Second): + t.Fatal("released instance was not stopped") + } + + // A later Acquire must launch a replacement rather than hand back the + // corpse. + next, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("re-acquire: %v", err) + } + if next.BaseURL() == "" { + t.Fatal("replacement lease has no base URL") + } + next.Release() +} + +func TestIdleReclamationStopsAbandonedServer(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + spec := fakeSpec(t, "idle") + spec.IdleTimeout = 150 * time.Millisecond + + lease, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("acquire: %v", err) + } + exited := lease.Exited() + lease.Release() + + select { + case <-exited: + case <-time.After(10 * time.Second): + t.Fatal("idle instance was never reclaimed") + } +} + +func TestAcquireWithinIdleWindowKeepsServerWarm(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + spec := fakeSpec(t, "warm") + spec.IdleTimeout = 3 * time.Second + + first, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("acquire: %v", err) + } + url := first.BaseURL() + first.Release() + + second, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("re-acquire: %v", err) + } + defer second.Release() + if second.BaseURL() != url { + t.Fatalf("expected the warm server %q, got %q", url, second.BaseURL()) + } + select { + case <-second.Exited(): + t.Fatal("warm server was stopped despite the new lease") + default: + } +} + +func TestAcquireSurfacesBootFailureWithDiagnostics(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + spec := fakeSpec(t, "bad-boot", enginehostFakeFailEnv+"=1") + spec.ReadyTimeout = 10 * time.Second + + _, err := sup.Acquire(context.Background(), spec) + if err == nil { + t.Fatal("expected a boot failure") + } + if !strings.Contains(err.Error(), "refusing to boot") { + t.Fatalf("error should carry the engine's own output, got %v", err) + } + + // The failed entry must not be cached: the next attempt gets a real + // launch, which for a now-healthy spec has to succeed. + good := fakeSpec(t, "bad-boot") + lease, err := sup.Acquire(context.Background(), good) + if err != nil { + t.Fatalf("retry after failure: %v", err) + } + lease.Release() +} + +func TestAcquireReplacesDeadServer(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + spec := fakeSpec(t, "dead") + lease, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("acquire: %v", err) + } + firstURL := lease.BaseURL() + + // Kill the engine out from under the live lease, the way a crash + // would, and confirm the next Acquire launches a replacement instead + // of handing out a dead instance. + killLeaseProcess(t, sup, spec.Key) + select { + case <-lease.Exited(): + case <-time.After(10 * time.Second): + t.Fatal("engine did not exit after kill") + } + + next, err := sup.Acquire(context.Background(), spec) + if err != nil { + t.Fatalf("acquire after crash: %v", err) + } + defer next.Release() + if next.BaseURL() == firstURL { + t.Fatalf("expected a fresh server, still on %q", firstURL) + } + lease.Release() +} + +func TestShutdownStopsEverything(t *testing.T) { + sup := NewSupervisor(testLogger()) + + a, err := sup.Acquire(context.Background(), fakeSpec(t, "shutdown-a")) + if err != nil { + t.Fatalf("acquire a: %v", err) + } + b, err := sup.Acquire(context.Background(), fakeSpec(t, "shutdown-b")) + if err != nil { + t.Fatalf("acquire b: %v", err) + } + + sup.Shutdown() + for name, ch := range map[string]<-chan struct{}{"a": a.Exited(), "b": b.Exited()} { + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatalf("server %s survived shutdown", name) + } + } + if _, err := sup.Acquire(context.Background(), fakeSpec(t, "shutdown-c")); err == nil { + t.Fatal("Acquire must be refused after Shutdown") + } +} + +func TestFreeLoopbackPortReturnsBindablePorts(t *testing.T) { + seen := map[int]bool{} + for range 5 { + port, err := freeLoopbackPort() + if err != nil { + t.Fatalf("freeLoopbackPort: %v", err) + } + if port <= 0 || port > 65535 { + t.Fatalf("implausible port %d", port) + } + if seen[port] { + t.Fatalf("port %d handed out twice in a row", port) + } + seen[port] = true + l, err := net.Listen("tcp", net.JoinHostPort(LoopbackHost, strconv.Itoa(port))) + if err != nil { + t.Fatalf("reserved port %d is not bindable: %v", port, err) + } + _ = l.Close() + } +} + +func TestLineTailKeepsOnlyTheTail(t *testing.T) { + tail := newLineTail(3) + for i := range 6 { + tail.push("line-" + strconv.Itoa(i)) + } + got := tail.String() + if got != "line-3 | line-4 | line-5" { + t.Fatalf("unexpected tail %q", got) + } +} + +func TestZeroValueLeaseIsInert(t *testing.T) { + var l *Lease + l.Release() // must not panic + if l.BaseURL() != "" { + t.Fatal("nil lease should have no base URL") + } + select { + case <-l.Exited(): + default: + t.Fatal("nil lease should report exited") + } +} + +func TestSpecValidation(t *testing.T) { + cases := map[string]ServerSpec{ + "missing key": {Binary: "x", Ready: pingReady}, + "missing binary": {Key: "k", Ready: pingReady}, + "missing ready": {Key: "k", Binary: "x"}, + } + for name, spec := range cases { + t.Run(name, func(t *testing.T) { + if err := spec.validate(); err == nil { + t.Fatal("expected a validation error") + } + }) + } +} + +// killLeaseProcess SIGKILLs the OS process behind the instance registered +// under key, simulating an engine crash. +func killLeaseProcess(t *testing.T, sup *Supervisor, key string) { + t.Helper() + sup.mu.Lock() + e, ok := sup.entries[key] + sup.mu.Unlock() + if !ok || e.inst == nil || e.inst.proc.Cmd.Process == nil { + t.Fatalf("no live instance for key %q", key) + } + if err := e.inst.proc.Cmd.Process.Kill(); err != nil { + t.Fatalf("kill engine: %v", err) + } +} + +// testLogger keeps supervisor logging out of test output while still +// exercising every logging path. +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug})) +} From b577f0fc025ff2e75eb1bdd0152a13b7575821c7 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 20 Aug 2026 17:35:17 +0800 Subject: [PATCH 4/6] feat(runtime): add DSH sandbox skills and MCP --- CONTRIBUTING.md | 26 +- .../internal/agent/claudecode/skills.go | 176 +---- .../internal/agent/claudecode/skills_test.go | 22 + .../agent/deepseekharness/capabilities.go | 169 +++++ .../deepseekharness/capabilities_test.go | 166 +++++ .../internal/agent/deepseekharness/events.go | 31 +- .../agent/deepseekharness/server_session.go | 29 +- .../deepseekharness/server_session_test.go | 40 ++ .../agent/deepseekharness/serverhost.go | 24 +- .../agent/deepseekharness/serverhost_test.go | 3 + .../agent/deepseekharness/serverprofile.go | 24 + .../deepseekharness/serverprofile_test.go | 27 + .../internal/agent/deepseekharness/session.go | 24 +- .../agent/deepseekharness/session_test.go | 15 + .../parsar-daemon/internal/agent/pi/skills.go | 500 +-------------- .../internal/agent/skillinstall/install.go | 599 ++++++++++++++++++ .../agent/skillinstall/install_test.go | 96 +++ apps/parsar-daemon/internal/cli/connect.go | 4 +- .../parsar-daemon/internal/dispatch/router.go | 14 + .../parsar-daemon/internal/enginehost/spec.go | 7 + .../internal/enginehost/supervisor.go | 144 ++++- .../internal/enginehost/supervisor_test.go | 92 +++ docker-compose.yml | 25 + internal/agentdaemon/proto/envelope_test.go | 36 ++ server/cmd/server/sandbox_docker.go | 75 ++- server/cmd/server/sandbox_docker_test.go | 37 ++ .../capability/render/deepseekharness.go | 18 +- .../capability/render/renderer_test.go | 38 +- .../agentdaemon/capability_runtime.go | 115 ++-- .../capability_runtime_dispatch_test.go | 23 + .../agentdaemon/capability_runtime_test.go | 60 +- .../connector/agentdaemon/model_injection.go | 5 + server/internal/sandbox/docker/client.go | 43 +- .../sandbox/docker/client_integration_test.go | 2 +- server/internal/sandbox/docker/client_test.go | 37 +- 35 files changed, 1935 insertions(+), 811 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/capabilities.go create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/capabilities_test.go create mode 100644 apps/parsar-daemon/internal/agent/skillinstall/install.go create mode 100644 apps/parsar-daemon/internal/agent/skillinstall/install_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf5ba197..77e04bc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -208,6 +208,11 @@ names a concrete engine or speaks an engine's protocol. 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 @@ -226,8 +231,25 @@ names a concrete engine or speaks an engine's protocol. 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. -- Resident servers must not outlive the daemon. Wire the supervisor's - `Shutdown` into daemon teardown. +- 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 diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills.go b/apps/parsar-daemon/internal/agent/claudecode/skills.go index 272add8c..2105b463 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/skills.go +++ b/apps/parsar-daemon/internal/agent/claudecode/skills.go @@ -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 /.claude/skills/, no CLI flag. type SkillInstallResult struct { Warnings []string } -// installSkills materialises every skill under -// /.claude/skills//. 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) } diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills_test.go b/apps/parsar-daemon/internal/agent/claudecode/skills_test.go index b3b89b6c..21b9c0eb 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/skills_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/skills_test.go @@ -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{ diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/capabilities.go b/apps/parsar-daemon/internal/agent/deepseekharness/capabilities.go new file mode 100644 index 00000000..d3ca235f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/capabilities.go @@ -0,0 +1,169 @@ +package deepseekharness + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/skillinstall" +) + +var mcpServerNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`) + +func materializeManagedSkills(ctx context.Context, launch serverLaunch, raw any) error { + descriptors, warnings := skillinstall.Decode(raw) + if len(warnings) > 0 { + return fmt.Errorf("deepseekharness: decode managed skills: %s", strings.Join(warnings, "; ")) + } + root := filepath.Join(launch.Home, "skills") + result, err := skillinstall.Install(ctx, nil, root, descriptors) + if err != nil { + return fmt.Errorf("deepseekharness: install managed skills: %w", err) + } + if len(result.Warnings) > 0 { + return fmt.Errorf("deepseekharness: install managed skills: %s", strings.Join(result.Warnings, "; ")) + } + if err := skillinstall.Prune(root, descriptors); err != nil { + return fmt.Errorf("deepseekharness: reconcile managed skills: %w", err) + } + return nil +} + +func normaliseMCPRows(raw any, workDir string) ([]pluginRow, error) { + if raw == nil { + return nil, nil + } + servers, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("deepseekharness: mcp_servers must be object, got %T", raw) + } + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + rows := make([]pluginRow, 0, len(names)) + for _, name := range names { + if !mcpServerNamePattern.MatchString(name) { + return nil, fmt.Errorf("deepseekharness: invalid MCP server name %q", name) + } + entry, ok := servers[name].(map[string]any) + if !ok { + return nil, fmt.Errorf("deepseekharness: mcp_servers[%q] must be object, got %T", name, servers[name]) + } + config, err := normaliseMCPConfig(name, entry, workDir) + if err != nil { + return nil, err + } + rows = append(rows, pluginRow{ + ID: "parsar-mcp-" + name, + Name: "@deepseek-ai/dsh-mcp-client", + Config: config, + }) + } + return rows, nil +} + +func normaliseMCPConfig(name string, entry map[string]any, workDir string) (any, error) { + url := stringOpt(entry, "url") + command := stringOpt(entry, "command") + if url != "" && command != "" { + return nil, fmt.Errorf("deepseekharness: mcp_servers[%q] cannot set both command and url", name) + } + if url != "" { + headers, err := stringMap(entry["headers"]) + if err != nil { + return nil, fmt.Errorf("deepseekharness: mcp_servers[%q].headers: %w", name, err) + } + return mcpHTTPConfig{ + Transport: "streamable-http", + ServerName: name, + URL: url, + Headers: headers, + ToolCallTimeoutMS: 60_000, + FailOnStartupError: true, + }, nil + } + if command == "" { + return nil, fmt.Errorf("deepseekharness: mcp_servers[%q] missing command or url", name) + } + args, err := stringList(entry["args"]) + if err != nil { + return nil, fmt.Errorf("deepseekharness: mcp_servers[%q].args: %w", name, err) + } + env, err := stringMap(entry["env"]) + if err != nil { + return nil, fmt.Errorf("deepseekharness: mcp_servers[%q].env: %w", name, err) + } + return mcpStdioConfig{ + Transport: "stdio", + ServerName: name, + Command: command, + Args: args, + Env: env, + CWD: workDir, + ToolCallTimeoutMS: 60_000, + FailOnStartupError: true, + }, nil +} + +func stringList(raw any) ([]string, error) { + if raw == nil { + return []string{}, nil + } + items, ok := raw.([]any) + if !ok { + if typed, ok := raw.([]string); ok { + return append([]string{}, typed...), nil + } + return nil, fmt.Errorf("must be array, got %T", raw) + } + out := make([]string, 0, len(items)) + for i, item := range items { + value, ok := item.(string) + if !ok { + return nil, fmt.Errorf("item %d must be string, got %T", i, item) + } + out = append(out, value) + } + return out, nil +} + +func stringMap(raw any) (map[string]string, error) { + if raw == nil { + return map[string]string{}, nil + } + if typed, ok := raw.(map[string]string); ok { + out := make(map[string]string, len(typed)) + for key, value := range typed { + out[key] = value + } + return out, nil + } + values, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("must be object, got %T", raw) + } + out := make(map[string]string, len(values)) + for key, rawValue := range values { + value, ok := rawValue.(string) + if !ok { + return nil, fmt.Errorf("%s must be string, got %T", key, rawValue) + } + out[key] = value + } + return out, nil +} + +func fingerprintMCPRows(rows []pluginRow) (string, error) { + body, err := json.Marshal(rows) + if err != nil { + return "", errors.New("marshal MCP rows") + } + return string(body), nil +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/capabilities_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/capabilities_test.go new file mode 100644 index 00000000..b911bd57 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/capabilities_test.go @@ -0,0 +1,166 @@ +package deepseekharness + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestNormaliseMCPRowsStdio(t *testing.T) { + rows, err := normaliseMCPRows(map[string]any{ + "proof": map[string]any{ + "command": "node", + "args": []any{"/opt/mcp/proof.mjs"}, + "env": map[string]any{"PROOF_TOKEN": "secret"}, + }, + }, "/workspace") + if err != nil { + t.Fatalf("normaliseMCPRows: %v", err) + } + if len(rows) != 1 || rows[0].Name != "@deepseek-ai/dsh-mcp-client" || rows[0].ID != "parsar-mcp-proof" { + t.Fatalf("rows = %+v", rows) + } + cfg, ok := rows[0].Config.(mcpStdioConfig) + if !ok { + t.Fatalf("config = %T", rows[0].Config) + } + if cfg.Transport != "stdio" || cfg.ServerName != "proof" || cfg.Command != "node" || cfg.CWD != "/workspace" { + t.Fatalf("config = %+v", cfg) + } + if !reflect.DeepEqual(cfg.Args, []string{"/opt/mcp/proof.mjs"}) || cfg.Env["PROOF_TOKEN"] != "secret" { + t.Fatalf("config = %+v", cfg) + } + if !cfg.FailOnStartupError || cfg.ToolCallTimeoutMS != 60_000 { + t.Fatalf("startup policy = %+v", cfg) + } +} + +func TestNormaliseMCPRowsHTTPAndStableOrder(t *testing.T) { + rows, err := normaliseMCPRows(map[string]any{ + "zeta": map[string]any{"url": "https://zeta.example/mcp"}, + "alpha": map[string]any{ + "url": "https://alpha.example/mcp", + "headers": map[string]any{"Authorization": "Bearer secret"}, + }, + }, "/workspace") + if err != nil { + t.Fatalf("normaliseMCPRows: %v", err) + } + if len(rows) != 2 || rows[0].ID != "parsar-mcp-alpha" || rows[1].ID != "parsar-mcp-zeta" { + t.Fatalf("rows are not sorted: %+v", rows) + } + cfg, ok := rows[0].Config.(mcpHTTPConfig) + if !ok { + t.Fatalf("config = %T", rows[0].Config) + } + if cfg.Transport != "streamable-http" || cfg.ServerName != "alpha" || cfg.URL != "https://alpha.example/mcp" { + t.Fatalf("config = %+v", cfg) + } + if cfg.Headers["Authorization"] != "Bearer secret" || !cfg.FailOnStartupError { + t.Fatalf("config = %+v", cfg) + } +} + +func TestNormaliseMCPRowsRejectsInvalidEntries(t *testing.T) { + cases := []struct { + name string + raw map[string]any + want string + }{ + {"invalid name", map[string]any{"not valid": map[string]any{"command": "node"}}, "invalid MCP server name"}, + {"both transports", map[string]any{"proof": map[string]any{"command": "node", "url": "https://example.com/mcp"}}, "cannot set both"}, + {"missing transport", map[string]any{"proof": map[string]any{}}, "missing command or url"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := normaliseMCPRows(tc.raw, "/workspace") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestMaterializeManagedSkillsInstallsCachesAndPrunes(t *testing.T) { + var zipBody bytes.Buffer + zw := zip.NewWriter(&zipBody) + w, err := zw.Create("SKILL.md") + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("---\nname: parsar-proof\ndescription: proof\n---\nReturn DSH_SKILL_PROOF.")) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(zipBody.Bytes()) + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + _, _ = w.Write(zipBody.Bytes()) + })) + t.Cleanup(srv.Close) + + home := t.TempDir() + root := filepath.Join(home, "skills") + old := filepath.Join(root, "old-managed") + unmanaged := filepath.Join(root, "unmanaged") + for _, dir := range []string{old, unmanaged} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(old, ".cache-key"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + raw := []any{map[string]any{ + "name": "parsar-proof", "version": "1", "download_url": srv.URL, + "sha256": hex.EncodeToString(digest[:]), + }} + launch := serverLaunch{Home: home} + if err := materializeManagedSkills(context.Background(), launch, raw); err != nil { + t.Fatalf("first materialize: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "parsar-proof", "SKILL.md")); err != nil { + t.Fatalf("installed SKILL.md: %v", err) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf("old managed directory was not pruned: %v", err) + } + if _, err := os.Stat(unmanaged); err != nil { + t.Fatalf("unmanaged directory must be preserved: %v", err) + } + if err := materializeManagedSkills(context.Background(), launch, raw); err != nil { + t.Fatalf("cached materialize: %v", err) + } + if hits != 1 { + t.Fatalf("downloads = %d, want 1", hits) + } +} + +func TestMaterializeManagedSkillsInlineMarkdown(t *testing.T) { + home := t.TempDir() + content := "---\nname: parsar-proof\ndescription: proof\n---\nReturn DSH_SKILL_PROOF_20260820.\n" + raw := []any{map[string]any{ + "name": "parsar-proof", "version": "1", "content": content, + }} + launch := serverLaunch{Home: home} + if err := materializeManagedSkills(context.Background(), launch, raw); err != nil { + t.Fatalf("materialize inline skill: %v", err) + } + got, err := os.ReadFile(filepath.Join(home, "skills", "parsar-proof", "SKILL.md")) + if err != nil { + t.Fatalf("read inline SKILL.md: %v", err) + } + if string(got) != content { + t.Fatalf("SKILL.md = %q, want %q", got, content) + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/events.go b/apps/parsar-daemon/internal/agent/deepseekharness/events.go index 8fba5c94..d47bfba3 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/events.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/events.go @@ -26,6 +26,7 @@ const ( eventToolResult = "tool/result" eventApprovalAsked = "approval/asked" eventAgentError = "agent/error" + eventLLMRetry = "llm/retry" ) // Streaming chunk types inside assistant/chunk. @@ -128,11 +129,37 @@ type toolResultData struct { type turnEndData struct { Turn int `json:"turn"` Reason struct { - Kind string `json:"kind"` - Message string `json:"message"` + Kind string `json:"kind"` + Message string `json:"message"` + Error llmFailure `json:"error"` } `json:"reason"` } +type llmRetryData struct { + Retry int `json:"retry"` + MaxRetries int `json:"maxRetries"` + Failure llmFailure `json:"failure"` +} + +type llmFailure struct { + Code string `json:"code"` + Message string `json:"message"` + Status int `json:"status"` + RequestID string `json:"requestId"` +} + +func (f llmFailure) summary() string { + message := strings.TrimSpace(f.Message) + code := strings.TrimSpace(f.Code) + if code == "" { + return message + } + if message == "" { + return code + } + return code + ": " + message +} + // textFromToolResult flattens a tool result's nested content into one // string plus its error flag, which is all the Parsar tool_call frame // carries. diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go b/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go index e9f62532..29499a9a 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go @@ -58,6 +58,9 @@ type serverSession struct { // re-streams and would otherwise be counted twice. answer strings.Builder usage proto.Usage + // lastRetryFailure is diagnostic only. A scheduled retry may recover, so + // it is surfaced only when the enclosing turn ultimately ends in error. + lastRetryFailure string cancelOnce sync.Once closeOutOnce sync.Once @@ -88,8 +91,11 @@ func newServerSession(parent context.Context, req proto.PromptRequestPayload, ou return nil, err } launch.Binary = cfg.binary + if err := materializeManagedSkills(parent, launch, req.AgentOptions["skills"]); err != nil { + return nil, err + } - lease, err := serverSupervisor.Acquire(parent, launch.spec()) + lease, err := enginehost.Acquire(parent, launch.spec()) if err != nil { return nil, fmt.Errorf("deepseekharness: start resident dsh server: %w", err) } @@ -298,6 +304,11 @@ func (s *serverSession) handleEvent(event sessionEventPayload) (bool, string) { return true, "deepseek-harness: engine asked for approval, but this run has no approver" case eventAgentError: return false, "deepseek-harness: " + truncate(strings.TrimSpace(string(event.Event.Data)), 400) + case eventLLMRetry: + var data llmRetryData + if err := json.Unmarshal(event.Event.Data, &data); err == nil { + s.lastRetryFailure = data.Failure.summary() + } case eventTurnEnd: var data turnEndData if err := json.Unmarshal(event.Event.Data, &data); err != nil { @@ -305,8 +316,15 @@ func (s *serverSession) handleEvent(event sessionEventPayload) (bool, string) { } if data.Reason.Kind != "completed" { reason := data.Reason.Kind - if data.Reason.Message != "" { - reason += ": " + data.Reason.Message + detail := strings.TrimSpace(data.Reason.Message) + if detail == "" { + detail = data.Reason.Error.summary() + } + if detail == "" { + detail = s.lastRetryFailure + } + if detail != "" { + reason += ": " + truncate(detail, 400) } return true, "deepseek-harness: turn ended without completing (" + reason + ")" } @@ -460,6 +478,10 @@ func buildServerLaunch(req proto.PromptRequestPayload) (serverLaunch, error) { if err != nil { return serverLaunch{}, err } + mcpRows, err := normaliseMCPRows(req.AgentOptions["mcp_servers"], workDir) + if err != nil { + return serverLaunch{}, err + } stateKey := strings.TrimSpace(req.AgentStateKey) if stateKey == "" { @@ -472,6 +494,7 @@ func buildServerLaunch(req proto.PromptRequestPayload) (serverLaunch, error) { HasProvider: hasProvider, Model: stringOpt(req.AgentOptions, "model"), ProviderID: stringOpt(req.AgentOptions, "provider"), + MCPRows: mcpRows, Env: append(os.Environ(), extra...), StateKey: stateKey, }, nil diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go index e710fe5e..f02b68e7 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go @@ -302,6 +302,46 @@ func TestServerSessionFailsAnIncompleteTurn(t *testing.T) { } } +func TestServerSessionSurfacesTheTerminalModelFailure(t *testing.T) { + h := newHarness(t, baseRequest()) + sid := h.gateway.nextSessionID + emitEvent(t, h.conn, sid, eventLLMRetry, 4, map[string]any{ + "retry": 2, "maxRetries": 2, + "failure": map[string]any{"code": "TRANSPORT", "message": "Connection error."}, + }) + emitEvent(t, h.conn, sid, eventTurnEnd, 5, map[string]any{ + "turn": 1, + "reason": map[string]any{ + "kind": "error", + "error": map[string]any{"code": "TRANSPORT", "message": "Connection error."}, + }, + }) + + envs := h.collect(t) + msg := decodeEnv[proto.ErrorPayload](t, framesOfType(envs, proto.TypeError)[0]).Error + if !strings.Contains(msg, "TRANSPORT: Connection error.") { + t.Fatalf("terminal model failure was lost: %q", msg) + } +} + +func TestServerSessionFallsBackToLastRetryFailure(t *testing.T) { + h := newHarness(t, baseRequest()) + sid := h.gateway.nextSessionID + emitEvent(t, h.conn, sid, eventLLMRetry, 4, map[string]any{ + "retry": 2, "maxRetries": 2, + "failure": map[string]any{"code": "TRANSPORT", "message": "DNS lookup failed"}, + }) + emitEvent(t, h.conn, sid, eventTurnEnd, 5, map[string]any{ + "turn": 1, "reason": map[string]any{"kind": "error"}, + }) + + envs := h.collect(t) + msg := decodeEnv[proto.ErrorPayload](t, framesOfType(envs, proto.TypeError)[0]).Error + if !strings.Contains(msg, "TRANSPORT: DNS lookup failed") { + t.Fatalf("retry failure fallback was lost: %q", msg) + } +} + func TestServerSessionKeepsAResumedSessionIDAfterAFailedTurn(t *testing.T) { req := baseRequest() req.AgentSessionID = "session-prior-9" diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go index e0313d40..27615742 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go @@ -13,16 +13,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" ) -// serverSupervisor is process-wide because the resident servers are: a -// state key's engine has to be shared by every prompt of that key, and -// agent.Factory is a plain function with nowhere to hang per-daemon -// state. Shutdown is wired to daemon teardown by the dispatch layer. -var serverSupervisor = enginehost.NewSupervisor(nil) - -// ShutdownServers stops every resident dsh server. Called on daemon -// teardown so an engine does not outlive the process that started it. -func ShutdownServers() { serverSupervisor.Shutdown() } - // readyProbeTimeout bounds one readiness attempt. A cold dsh boot // compiles its plugin tree, so the overall gate is generous while each // individual probe stays short. @@ -38,6 +28,7 @@ type serverLaunch struct { HasProvider bool Model string ProviderID string + MCPRows []pluginRow Env []string StateKey string } @@ -45,9 +36,10 @@ type serverLaunch struct { // spec turns a launch into an enginehost.ServerSpec. func (l serverLaunch) spec() enginehost.ServerSpec { return enginehost.ServerSpec{ - Key: l.key(), - Binary: l.Binary, - Dir: l.WorkDir, + Key: l.key(), + StateKey: l.StateKey, + Binary: l.Binary, + Dir: l.WorkDir, Args: func(int) []string { // The port reaches dsh through the generated profile, not the // command line: dsh has no port flag, the webserver row owns @@ -66,6 +58,7 @@ func (l serverLaunch) spec() enginehost.ServerSpec { HasProvider: l.HasProvider, Model: l.Model, ProviderID: l.ProviderID, + MCPRows: l.MCPRows, }) }, Ready: probeReady, @@ -97,6 +90,11 @@ func (l serverLaunch) key() string { h.Write([]byte(k + "=" + l.Provider.Headers[k])) h.Write([]byte{0}) } + mcpFingerprint, err := fingerprintMCPRows(l.MCPRows) + if err == nil { + h.Write([]byte(mcpFingerprint)) + h.Write([]byte{0}) + } // The env is hashed, never recorded: it carries the API key value. env := append([]string{}, l.Env...) sort.Strings(env) diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go index 7caab9a1..508fe3be 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go @@ -97,6 +97,9 @@ func TestServerSpecPointsAtTheGeneratedProfile(t *testing.T) { if spec.Dir != "/work" { t.Errorf("spec dir = %q", spec.Dir) } + if spec.StateKey != sampleLaunch().StateKey { + t.Errorf("spec state key = %q, want %q", spec.StateKey, sampleLaunch().StateKey) + } if spec.Ready == nil || spec.Prepare == nil || spec.Env == nil { t.Error("spec must supply a readiness probe, a prepare step and an environment") } diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go index 5056490c..1d96e1a2 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile.go @@ -70,6 +70,26 @@ type webServerConfig struct { Port int `yaml:"port"` } +type mcpStdioConfig struct { + Transport string `yaml:"transport"` + ServerName string `yaml:"serverName"` + Command string `yaml:"command"` + Args []string `yaml:"args"` + Env map[string]string `yaml:"env"` + CWD string `yaml:"cwd"` + ToolCallTimeoutMS int `yaml:"toolCallTimeoutMs"` + FailOnStartupError bool `yaml:"failOnStartupError"` +} + +type mcpHTTPConfig struct { + Transport string `yaml:"transport"` + ServerName string `yaml:"serverName"` + URL string `yaml:"url"` + Headers map[string]string `yaml:"headers"` + ToolCallTimeoutMS int `yaml:"toolCallTimeoutMs"` + FailOnStartupError bool `yaml:"failOnStartupError"` +} + type clientConnectionConfig struct { // TrustedHosts stays empty so the gateway's trust fence accepts only // loopback callers. Adding an entry here would expose an @@ -87,6 +107,7 @@ type serverProfileSpec struct { HasProvider bool Model string ProviderID string + MCPRows []pluginRow } // writeServerProfile materialises $DSH_HOME/profiles/ @@ -174,6 +195,9 @@ func renderServerPatch(spec serverProfileSpec) ([]byte, error) { {ID: "client-connection", Name: "@deepseek-ai/dsh-client-connection", Config: clientConnectionConfig{TrustedHosts: []string{}}}, }}) + if len(spec.MCPRows) > 0 { + rows = append(rows, insertRow{Insert: append([]pluginRow{}, spec.MCPRows...)}) + } body, err := yaml.Marshal(rows) if err != nil { diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go index 1ce51179..958c09e5 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverprofile_test.go @@ -149,6 +149,33 @@ func TestServerPatchPinsLoopbackAndTheAssignedPort(t *testing.T) { } } +func TestServerPatchIncludesMCPClientWithoutLoggingSecrets(t *testing.T) { + spec := testProfileSpec(t.TempDir(), 45678) + rows, err := normaliseMCPRows(map[string]any{ + "proof": map[string]any{ + "url": "https://mcp.example/mcp", + "headers": map[string]any{"Authorization": "Bearer secret-marker"}, + }, + }, "/workspace") + if err != nil { + t.Fatal(err) + } + spec.MCPRows = rows + body, err := renderServerPatch(spec) + if err != nil { + t.Fatalf("renderServerPatch: %v", err) + } + inserts, _ := rowsFromPatch(t, body) + cfg := inserts["@deepseek-ai/dsh-mcp-client"] + if cfg["transport"] != "streamable-http" || cfg["serverName"] != "proof" { + t.Fatalf("mcp config = %v", cfg) + } + launch := serverLaunch{StateKey: "conversation", MCPRows: rows} + if got := launch.key(); strings.Contains(got, "secret-marker") { + t.Fatalf("server key leaked MCP header: %q", got) + } +} + func TestServerPatchRootsStorageUnderTheHome(t *testing.T) { home := t.TempDir() body, err := renderServerPatch(testProfileSpec(home, 1234)) diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session.go b/apps/parsar-daemon/internal/agent/deepseekharness/session.go index 4a7202d9..b5c289a7 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/session.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session.go @@ -110,9 +110,8 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan cfg.killTimeout = 3 * time.Second } for _, key := range unsupportedOptions { - if value, ok := req.AgentOptions[key]; ok && value != nil { - cfg.logger.Warn("deepseekharness: agent option unsupported by dsh headless, ignored", - "run_id", req.RunID, "option", key) + if value, ok := req.AgentOptions[key]; ok && optionConfigured(value) { + return nil, fmt.Errorf("deepseekharness: agent option %q requires the sandbox resident runtime", key) } } @@ -152,6 +151,25 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan return s, nil } +func optionConfigured(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(typed) != "" + case []any: + return len(typed) > 0 + case []string: + return len(typed) > 0 + case map[string]any: + return len(typed) > 0 + case map[string]string: + return len(typed) > 0 + default: + return true + } +} + func (s *Session) Cancel(context.Context) error { s.cancelOnce.Do(func() { s.proc.Cancel() diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go index c05b1ecc..699ab66f 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go @@ -263,6 +263,21 @@ func TestSessionRejectsEmptyPrompt(t *testing.T) { } } +func TestHeadlessSessionRejectsResidentOnlyCapabilities(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + for _, option := range []string{"mcp_servers", "skills", "skill_dirs", "plugin_dirs"} { + t.Run(option, func(t *testing.T) { + out := make(chan proto.Envelope, 4) + req := dshHelperReq("run_unsupported_"+option, "hello", "success") + req.AgentOptions[option] = map[string]any{"configured": true} + _, err := deepseekharness.NewSessionForTest(context.Background(), req, out, dshHelperConfig()) + if err == nil || !strings.Contains(err.Error(), "requires the sandbox resident runtime") { + t.Fatalf("error = %v, want resident-runtime rejection", err) + } + }) + } +} + func TestSessionBadBinaryFailsToStart(t *testing.T) { t.Setenv("PARSAR_HOME", t.TempDir()) out := make(chan proto.Envelope, 4) diff --git a/apps/parsar-daemon/internal/agent/pi/skills.go b/apps/parsar-daemon/internal/agent/pi/skills.go index 0c827cdc..181613b2 100644 --- a/apps/parsar-daemon/internal/agent/pi/skills.go +++ b/apps/parsar-daemon/internal/agent/pi/skills.go @@ -1,513 +1,31 @@ package pi import ( - "archive/zip" "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" "log/slog" - "maps" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" - 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"]: -// -// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } -type skillDescriptor struct { - Name string - Version string - DownloadURL string - SHA256 string -} - -// SkillInstallResult carries the per-skill directories to feed into -// repeated `--skill ` flags plus warnings the session surfaces. -// Unlike Claude Code (which auto-scans .claude/skills/), pi needs an -// explicit flag per skill, so SkillDirs is populated even on a cache hit. -type SkillInstallResult struct { - SkillDirs []string - Warnings []string -} - -const skillInstallTimeout = 60 * time.Second - -// maxSkillZipBytes mirrors the server-side cap. Defense in depth. -const maxSkillZipBytes int64 = 32 * 1024 * 1024 - -var skillsHTTPClient = &http.Client{Timeout: skillInstallTimeout + 10*time.Second} - -// installSkills materialises every skill under // and returns -// the local paths. Per skill: -// -// 1. Cache hit (/.cache-key == name@sha256) returns the dir without -// a network round-trip — but still returns it, so --skill is injected -// on every turn. -// 2. Fetch → verify SHA-256 → extract (single wrapping dir stripped, -// __MACOSX/ ignored) → stamp .cache-key. -// -// Errors during fetch/verify/extract demote one skill to a warning and -// continue. A hard error means the root dir itself was uncreatable. -func installSkills( - 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("pi skills: root is required") - } - if err := os.MkdirAll(root, 0o755); err != nil { - return SkillInstallResult{}, fmt.Errorf("pi 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("pi 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("pi skills: cache hit", "name", s.Name, "version", s.Version, "dir", dir) - result.SkillDirs = append(result.SkillDirs, dir) - continue - } - - perCtx, cancel := context.WithTimeout(ctx, skillInstallTimeout) - 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("pi skills: install failed", "name", s.Name, "version", s.Version, "err", err.Error()) - continue - } - result.SkillDirs = append(result.SkillDirs, dir) - logger.Info("pi skills: installed", "name", s.Name, "version", s.Version, "dir", dir) - } - return result, nil -} - -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) - } - - // Per-call uuid so concurrent installs of the same (name, version) - // don't truncate each other's bytes, and nothing on disk between - // verify and extract can be a different file than the one hashed. - zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) - defer func() { _ = os.Remove(zipPath) }() - - fd, err := fetchSkillZip(ctx, s.DownloadURL, zipPath) - if err != nil { - return err - } - defer fd.Close() - - // Verify and extract BOTH read through the same FD (not the path): - // Unix file semantics pin the inode, so a swap on disk between - // hashing and extraction cannot change the bytes we use. - if err := verifySHA256FromFD(fd, s.SHA256); err != nil { - return err - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("seek: %w", 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 := extractSkillZipFromFD(fd, fi.Size(), dir); err != nil { - _ = os.RemoveAll(dir) - return err - } - - if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { - logger.Warn("pi skills: write cache key failed", "path", cacheKey, "err", err.Error()) - } - return nil -} - -// fetchSkillZip GETs url into dst, capping the body at maxSkillZipBytes. -// Returns an OPEN file descriptor at offset 0; the caller closes it. -// Holding the FD across verify + extract closes the TOCTOU between -// hashing the on-disk bytes and reading them for extract. -// -// Only http/https are accepted to defend against a future download_url -// reaching this code with file:// or http://internal-ip/... values. -func fetchSkillZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { - parsed, err := url.Parse(downloadURL) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return nil, errors.New("download_url must be http(s)") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if err != nil { - return nil, errors.New("build request failed") - } - resp, err := skillsHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) - return nil, fmt.Errorf("get: status %d", resp.StatusCode) - } - - f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("open dst: %w", err) - } - - limited := io.LimitReader(resp.Body, maxSkillZipBytes+1) - written, err := io.Copy(f, limited) - if err != nil { - _ = f.Close() - return nil, fmt.Errorf("copy body: %w", err) - } - if written > maxSkillZipBytes { - _ = f.Close() - return nil, fmt.Errorf("zip exceeds %d byte cap", maxSkillZipBytes) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - _ = f.Close() - return nil, fmt.Errorf("seek after write: %w", err) - } - return f, nil -} +type skillDescriptor = skillinstall.Descriptor +type SkillInstallResult = skillinstall.Result -// sanitizeHTTPClientError strips the URL embedded by *url.Error so a -// presigned download_url (OSSAccessKeyId + Signature) never lands in the -// daemon log. Format is ` "": `. -func sanitizeHTTPClientError(err error) string { - if err == nil { - return "" - } - msg := err.Error() - open := strings.Index(msg, `"`) - if open < 0 { - return msg - } - closeRel := strings.Index(msg[open+1:], `"`) - if closeRel < 0 { - return msg - } - closeAbs := open + 1 + closeRel - if closeAbs+2 > len(msg) { - return msg - } - return msg[:open] + "" + msg[closeAbs+1:] +func installSkills(ctx context.Context, logger *slog.Logger, root string, skills []skillDescriptor) (SkillInstallResult, error) { + return skillinstall.Install(ctx, logger, root, skills) } -func verifySHA256FromFD(fd *os.File, want string) error { - want = strings.ToLower(strings.TrimSpace(want)) - if want == "" { - return errors.New("verify: empty expected sha256") - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("verify: seek: %w", err) - } - h := sha256.New() - if _, err := io.Copy(h, fd); err != nil { - return fmt.Errorf("verify: hash: %w", err) - } - got := hex.EncodeToString(h.Sum(nil)) - if got != want { - return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) - } - return nil -} - -// extractSkillZipFromFD reads via io.NewSectionReader rather than -// re-opening the path so the byte stream stays identical to the verified -// one (TOCTOU defense). -func extractSkillZipFromFD(fd *os.File, size int64, dst string) error { - zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) - if err != nil { - return fmt.Errorf("extract: open zip: %w", err) - } - - root := detectSingleZipRoot(zr.File) - absDst, err := filepath.Abs(dst) - if err != nil { - return fmt.Errorf("extract: abs dst: %w", err) - } - - for _, f := range zr.File { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - // Skip non-regular entries (symlinks, devices). A symlink entry - // would otherwise be written as a plain file holding the link - // target string — an exfil vector. - mode := f.Mode() - if !f.FileInfo().IsDir() && !mode.IsRegular() { - continue - } - if root != "" { - if !strings.HasPrefix(name, root) { - continue - } - name = strings.TrimPrefix(name, root) - if name == "" { - continue - } - } - - target := filepath.Join(absDst, name) - rel, err := filepath.Rel(absDst, target) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("extract: entry %q escapes target", f.Name) - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { - return fmt.Errorf("extract: mkdir %s: %w", target, err) - } - continue - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) - } - if err := writeZipEntry(f, target); err != nil { - return err - } - } - return nil -} - -func writeZipEntry(f *zip.File, target string) error { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("extract: open entry %s: %w", f.Name, err) - } - defer rc.Close() - - mode := f.Mode().Perm() - if mode == 0 { - mode = 0o644 - } - out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { - return fmt.Errorf("extract: open target %s: %w", target, err) - } - defer out.Close() - if _, err := io.Copy(out, rc); err != nil { - return fmt.Errorf("extract: copy %s: %w", target, err) - } - return nil -} - -// detectSingleZipRoot returns the common wrapping directory (with -// trailing slash) shared by every non-MACOSX entry, or "" when there is -// none. Bare directory entries (no internal "/") are skipped when picking -// the first candidate so `zip -r skill skill/` doesn't short-circuit on -// its own leading directory entry. Hidden roots (".*") are NOT treated as -// wrappers. -func detectSingleZipRoot(files []*zip.File) string { - var first string - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if !strings.Contains(name, "/") { - continue - } - first = name - break - } - if first == "" { - return "" - } - idx := strings.Index(first, "/") - if idx <= 0 { - return "" - } - root := first[:idx+1] - if strings.HasPrefix(root, ".") { - return "" - } - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if name+"/" == root { - continue - } - if !strings.HasPrefix(name, root) { - return "" - } - } - return root -} - -func normaliseZipPath(name string) string { - p := strings.ReplaceAll(name, "\\", "/") - return strings.TrimSuffix(p, "/") -} - -// decodeSkillDescriptors converts agent_options["skills"] into typed -// descriptors. Entries that fail to decode are dropped with a warning; -// the rest may still be installable. 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 + return skillinstall.Decode(raw) } -func stringField(m map[string]any, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} - -func (s skillDescriptor) validate() error { - if strings.TrimSpace(s.Name) == "" { - return errors.New("name is required") - } - // Block path-traversal names before they hit filepath.Join. - 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)) -} - -// resolveSkillsRoot returns the absolute directory under which managed -// skills install, one subdir per skill. Kept under ~/.parsar/ (runtime -// state lives there, not the user's project tree) and scoped per -// conversation so consecutive turns reuse .cache-key files without two -// conversations racing the same skill dir. runID scopes the one-shot -// fallback when there is no conversation. func resolveSkillsRoot(conversationID, runID string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("pi skills: resolve home: %w", err) - } - base := filepath.Join(home, ".parsar", "runtime", "pi") - if id := strings.TrimSpace(conversationID); id != "" { - return filepath.Join(base, "conv-"+id, "skills"), nil - } - return filepath.Join(base, "run-"+strings.TrimSpace(runID), "skills"), nil + return skillinstall.ResolveRoot("pi", conversationID, runID) } -// mergeSkillDirs combines a caller-supplied skill_dirs override (accepted -// as []string OR []any) with the install-resolved list, preserving order -// and deduplicating. Override wins on collision. func mergeSkillDirs(existing any, resolved []string) []string { - preset := coerceStringSlice(existing) - seen := make(map[string]bool, len(preset)+len(resolved)) - out := make([]string, 0, len(preset)+len(resolved)) - for _, d := range append(append([]string{}, preset...), resolved...) { - if d == "" || seen[d] { - continue - } - seen[d] = true - out = append(out, d) - } - return out -} - -func coerceStringSlice(v any) []string { - switch t := v.(type) { - case nil: - return nil - case []string: - return t - case []any: - out := make([]string, 0, len(t)) - for _, item := range t { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - default: - return nil - } + return skillinstall.MergeDirs(existing, resolved) } -// cloneAgentOptions returns a shallow copy so we never mutate the -// caller's map when overwriting the top-level "skill_dirs" key. func cloneAgentOptions(opts map[string]any) map[string]any { - if opts == nil { - return map[string]any{} - } - out := make(map[string]any, len(opts)) - maps.Copy(out, opts) - return out + return skillinstall.CloneOptions(opts) } diff --git a/apps/parsar-daemon/internal/agent/skillinstall/install.go b/apps/parsar-daemon/internal/agent/skillinstall/install.go new file mode 100644 index 00000000..91f49fd0 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/skillinstall/install.go @@ -0,0 +1,599 @@ +// Package skillinstall securely materialises server-resolved Skills for +// daemon-side agent engines. +package skillinstall + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "maps" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/google/uuid" +) + +// Descriptor is the daemon-side view of one server-sent skill entry +// under agent_options["skills"]: +// +// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } +// +// Markdown-only Skills use content instead of download_url + sha256. +type Descriptor struct { + Name string + Version string + DownloadURL string + SHA256 string + Content string +} + +// Result carries installed directories plus per-skill warnings. SkillDirs is +// populated on cache hits as well as fresh installs so engines with explicit +// skill path configuration can reuse it on every turn. +type Result struct { + SkillDirs []string + Warnings []string +} + +const skillInstallTimeout = 60 * time.Second + +// maxSkillZipBytes mirrors the server-side cap. Defense in depth. +const maxSkillZipBytes int64 = 32 * 1024 * 1024 + +var skillsHTTPClient = &http.Client{Timeout: skillInstallTimeout + 10*time.Second} + +// Install materialises every skill under // and returns +// the local paths. Per skill: +// +// 1. Cache hit (/.cache-key == name@sha256) returns the dir without +// a network round-trip — but still returns it, so --skill is injected +// on every turn. +// 2. Fetch → verify SHA-256 → extract (single wrapping dir stripped, +// __MACOSX/ ignored) → stamp .cache-key. +// +// Errors during fetch/verify/extract demote one skill to a warning and +// continue. A hard error means the root dir itself was uncreatable. +func Install( + ctx context.Context, + logger *slog.Logger, + root string, + skills []Descriptor, +) (Result, error) { + if logger == nil { + logger = obslog.Bg() + } + if len(skills) == 0 { + return Result{}, nil + } + if strings.TrimSpace(root) == "" { + return Result{}, errors.New("skill install: root is required") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return Result{}, fmt.Errorf("skill install: mkdir %s: %w", root, err) + } + + result := Result{} + 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("skill install: 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("skill install: cache hit", "name", s.Name, "version", s.Version, "dir", dir) + result.SkillDirs = append(result.SkillDirs, dir) + continue + } + + var err error + if s.isInline() { + err = installInlineSkill(dir, cacheKey, expectedKey, s.Content) + } else { + perCtx, cancel := context.WithTimeout(ctx, skillInstallTimeout) + 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("skill install: install failed", "name", s.Name, "version", s.Version, "err", err.Error()) + continue + } + result.SkillDirs = append(result.SkillDirs, dir) + logger.Info("skill install: installed", "name", s.Name, "version", s.Version, "dir", dir) + } + return result, nil +} + +func installInlineSkill(dir, cacheKey, expectedKey, content string) error { + 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 := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + _ = os.RemoveAll(dir) + return fmt.Errorf("write SKILL.md: %w", err) + } + if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { + _ = os.RemoveAll(dir) + return fmt.Errorf("write cache key: %w", err) + } + return nil +} + +// Prune removes installer-owned skill directories that are no longer present +// in the server-resolved descriptor set. +func Prune(root string, skills []Descriptor) error { + entries, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("skill install: read root %s: %w", root, err) + } + keep := make(map[string]bool, len(skills)) + for _, skill := range skills { + if err := skill.validate(); err != nil { + return fmt.Errorf("skill install: prune descriptor: %w", err) + } + keep[skill.Name] = true + } + for _, entry := range entries { + name := entry.Name() + if !entry.IsDir() || strings.HasPrefix(name, ".") || keep[name] { + continue + } + if _, err := os.Stat(filepath.Join(root, name, ".cache-key")); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return fmt.Errorf("skill install: inspect %s: %w", name, err) + } + if err := os.RemoveAll(filepath.Join(root, name)); err != nil { + return fmt.Errorf("skill install: prune %s: %w", name, err) + } + } + return nil +} + +func installOneSkill( + ctx context.Context, + logger *slog.Logger, + root, dir, cacheKey, expectedKey string, + s Descriptor, +) error { + tmpDir := filepath.Join(root, ".tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("mkdir tmp: %w", err) + } + + // Per-call uuid so concurrent installs of the same (name, version) + // don't truncate each other's bytes, and nothing on disk between + // verify and extract can be a different file than the one hashed. + zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) + defer func() { _ = os.Remove(zipPath) }() + + fd, err := fetchSkillZip(ctx, s.DownloadURL, zipPath) + if err != nil { + return err + } + defer fd.Close() + + // Verify and extract BOTH read through the same FD (not the path): + // Unix file semantics pin the inode, so a swap on disk between + // hashing and extraction cannot change the bytes we use. + if err := verifySHA256FromFD(fd, s.SHA256); err != nil { + return err + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek: %w", 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 := extractSkillZipFromFD(fd, fi.Size(), dir); err != nil { + _ = os.RemoveAll(dir) + return err + } + + if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { + logger.Warn("skill install: write cache key failed", "path", cacheKey, "err", err.Error()) + } + return nil +} + +// fetchSkillZip GETs url into dst, capping the body at maxSkillZipBytes. +// Returns an OPEN file descriptor at offset 0; the caller closes it. +// Holding the FD across verify + extract closes the TOCTOU between +// hashing the on-disk bytes and reading them for extract. +// +// Only http/https are accepted to defend against a future download_url +// reaching this code with file:// or http://internal-ip/... values. +func fetchSkillZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { + parsed, err := url.Parse(downloadURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, errors.New("download_url must be http(s)") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, errors.New("build request failed") + } + resp, err := skillsHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + return nil, fmt.Errorf("get: status %d", resp.StatusCode) + } + + f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open dst: %w", err) + } + + limited := io.LimitReader(resp.Body, maxSkillZipBytes+1) + written, err := io.Copy(f, limited) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("copy body: %w", err) + } + if written > maxSkillZipBytes { + _ = f.Close() + return nil, fmt.Errorf("zip exceeds %d byte cap", maxSkillZipBytes) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = f.Close() + return nil, fmt.Errorf("seek after write: %w", err) + } + return f, nil +} + +// sanitizeHTTPClientError strips the URL embedded by *url.Error so a +// presigned download_url (OSSAccessKeyId + Signature) never lands in the +// daemon log. Format is ` "": `. +func sanitizeHTTPClientError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + open := strings.Index(msg, `"`) + if open < 0 { + return msg + } + closeRel := strings.Index(msg[open+1:], `"`) + if closeRel < 0 { + return msg + } + closeAbs := open + 1 + closeRel + if closeAbs+2 > len(msg) { + return msg + } + return msg[:open] + "" + msg[closeAbs+1:] +} + +func verifySHA256FromFD(fd *os.File, want string) error { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" { + return errors.New("verify: empty expected sha256") + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("verify: seek: %w", err) + } + h := sha256.New() + if _, err := io.Copy(h, fd); err != nil { + return fmt.Errorf("verify: hash: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) + } + return nil +} + +// extractSkillZipFromFD reads via io.NewSectionReader rather than +// re-opening the path so the byte stream stays identical to the verified +// one (TOCTOU defense). +func extractSkillZipFromFD(fd *os.File, size int64, dst string) error { + zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) + if err != nil { + return fmt.Errorf("extract: open zip: %w", err) + } + + root := detectSingleZipRoot(zr.File) + absDst, err := filepath.Abs(dst) + if err != nil { + return fmt.Errorf("extract: abs dst: %w", err) + } + + for _, f := range zr.File { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + // Skip non-regular entries (symlinks, devices). A symlink entry + // would otherwise be written as a plain file holding the link + // target string — an exfil vector. + mode := f.Mode() + if !f.FileInfo().IsDir() && !mode.IsRegular() { + continue + } + if root != "" { + if !strings.HasPrefix(name, root) { + continue + } + name = strings.TrimPrefix(name, root) + if name == "" { + continue + } + } + + target := filepath.Join(absDst, name) + rel, err := filepath.Rel(absDst, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("extract: entry %q escapes target", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("extract: mkdir %s: %w", target, err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) + } + if err := writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + +func writeZipEntry(f *zip.File, target string) error { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("extract: open entry %s: %w", f.Name, err) + } + defer rc.Close() + + mode := f.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return fmt.Errorf("extract: open target %s: %w", target, err) + } + defer out.Close() + if _, err := io.Copy(out, rc); err != nil { + return fmt.Errorf("extract: copy %s: %w", target, err) + } + return nil +} + +// detectSingleZipRoot returns the common wrapping directory (with +// trailing slash) shared by every non-MACOSX entry, or "" when there is +// none. Bare directory entries (no internal "/") are skipped when picking +// the first candidate so `zip -r skill skill/` doesn't short-circuit on +// its own leading directory entry. Hidden roots (".*") are NOT treated as +// wrappers. +func detectSingleZipRoot(files []*zip.File) string { + var first string + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + if !strings.Contains(name, "/") { + continue + } + first = name + break + } + if first == "" { + return "" + } + idx := strings.Index(first, "/") + if idx <= 0 { + return "" + } + root := first[:idx+1] + if strings.HasPrefix(root, ".") { + return "" + } + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + if name+"/" == root { + continue + } + if !strings.HasPrefix(name, root) { + return "" + } + } + return root +} + +func normaliseZipPath(name string) string { + p := strings.ReplaceAll(name, "\\", "/") + return strings.TrimSuffix(p, "/") +} + +// Decode converts agent_options["skills"] into typed +// descriptors. Entries that fail to decode are dropped with a warning; +// the rest may still be installable. +func Decode(raw any) ([]Descriptor, []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([]Descriptor, 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 := Descriptor{ + Name: stringField(obj, "name"), + Version: stringField(obj, "version"), + DownloadURL: stringField(obj, "download_url"), + SHA256: stringField(obj, "sha256"), + Content: stringField(obj, "content"), + } + 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 stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func (s Descriptor) validate() error { + if strings.TrimSpace(s.Name) == "" { + return errors.New("name is required") + } + // Block path-traversal names before they hit filepath.Join. + if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { + return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) + } + hasInline := strings.TrimSpace(s.Content) != "" + hasArchive := strings.TrimSpace(s.DownloadURL) != "" || strings.TrimSpace(s.SHA256) != "" + if hasInline == hasArchive { + return errors.New("exactly one of content or download_url + sha256 is required") + } + if hasArchive { + 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)) + } + if _, err := hex.DecodeString(s.SHA256); err != nil { + return errors.New("sha256 must be hexadecimal") + } + } + return nil +} + +func (s Descriptor) cacheKey() string { + digest := strings.ToLower(s.SHA256) + if s.isInline() { + sum := sha256.Sum256([]byte(s.Content)) + digest = hex.EncodeToString(sum[:]) + } + return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), digest) +} + +func (s Descriptor) isInline() bool { + return strings.TrimSpace(s.Content) != "" +} + +// ResolveRoot returns the absolute directory under which managed +// skills install, one subdir per skill. Kept under ~/.parsar/ (runtime +// state lives there, not the user's project tree) and scoped per +// conversation so consecutive turns reuse .cache-key files without two +// conversations racing the same skill dir. runID scopes the one-shot +// fallback when there is no conversation. +func ResolveRoot(runtimeName, conversationID, runID string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("skill install: resolve home: %w", err) + } + runtimeName = strings.TrimSpace(runtimeName) + if runtimeName == "" || strings.ContainsAny(runtimeName, "/\\") || runtimeName == "." || runtimeName == ".." { + return "", fmt.Errorf("skill install: invalid runtime name %q", runtimeName) + } + base := filepath.Join(home, ".parsar", "runtime", runtimeName) + if id := strings.TrimSpace(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "skills"), nil + } + return filepath.Join(base, "run-"+strings.TrimSpace(runID), "skills"), nil +} + +// MergeDirs combines a caller-supplied skill_dirs override (accepted +// as []string OR []any) with the install-resolved list, preserving order +// and deduplicating. Override wins on collision. +func MergeDirs(existing any, resolved []string) []string { + preset := coerceStringSlice(existing) + seen := make(map[string]bool, len(preset)+len(resolved)) + out := make([]string, 0, len(preset)+len(resolved)) + for _, d := range append(append([]string{}, preset...), resolved...) { + if d == "" || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +func coerceStringSlice(v any) []string { + switch t := v.(type) { + case nil: + return nil + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// CloneOptions returns a shallow copy so we never mutate the +// caller's map when overwriting the top-level "skill_dirs" key. +func CloneOptions(opts map[string]any) map[string]any { + if opts == nil { + return map[string]any{} + } + out := make(map[string]any, len(opts)) + maps.Copy(out, opts) + return out +} diff --git a/apps/parsar-daemon/internal/agent/skillinstall/install_test.go b/apps/parsar-daemon/internal/agent/skillinstall/install_test.go new file mode 100644 index 00000000..d17929d4 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/skillinstall/install_test.go @@ -0,0 +1,96 @@ +package skillinstall + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestInstallInlineSkillMaterializesAndCaches(t *testing.T) { + root := t.TempDir() + content := "---\nname: proof\ndescription: proof\n---\nReturn DSH_SKILL_PROOF.\n" + descriptor := Descriptor{Name: "proof", Version: "1", Content: content} + + first, err := Install(context.Background(), testLogger(), root, []Descriptor{descriptor}) + if err != nil { + t.Fatalf("first install: %v", err) + } + if len(first.Warnings) != 0 || len(first.SkillDirs) != 1 { + t.Fatalf("first result = %+v", first) + } + dir := first.SkillDirs[0] + got, err := os.ReadFile(filepath.Join(dir, "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) + } + sum := sha256.Sum256([]byte(content)) + wantKey := "proof@" + hex.EncodeToString(sum[:]) + cacheKey, err := os.ReadFile(filepath.Join(dir, ".cache-key")) + if err != nil { + t.Fatalf("read cache key: %v", err) + } + if string(cacheKey) != wantKey { + t.Fatalf("cache key = %q, want %q", cacheKey, wantKey) + } + + if err := os.WriteFile(filepath.Join(dir, "sentinel"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + second, err := Install(context.Background(), testLogger(), root, []Descriptor{descriptor}) + if err != nil { + t.Fatalf("cached install: %v", err) + } + if len(second.SkillDirs) != 1 { + t.Fatalf("cached result = %+v", second) + } + if _, err := os.Stat(filepath.Join(dir, "sentinel")); err != nil { + t.Fatalf("cache miss unexpectedly replaced directory: %v", err) + } +} + +func TestDecodeAcceptsInlineAndRejectsAmbiguousSource(t *testing.T) { + raw := []any{ + map[string]any{"name": "inline", "version": "1", "content": "body"}, + map[string]any{ + "name": "ambiguous", "content": "body", "download_url": "https://example.test/x.zip", + "sha256": strings.Repeat("a", 64), + }, + } + got, warnings := Decode(raw) + if len(got) != 1 || got[0].Name != "inline" || got[0].Content != "body" { + t.Fatalf("decoded = %+v", got) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "exactly one") { + t.Fatalf("warnings = %v", warnings) + } +} + +func TestPruneTreatsInlineSkillAsManaged(t *testing.T) { + root := t.TempDir() + old := filepath.Join(root, "old") + if err := os.MkdirAll(old, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, ".cache-key"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := Prune(root, []Descriptor{{Name: "current", Content: "body"}}); err != nil { + t.Fatalf("prune: %v", err) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf("old managed directory still exists: %v", err) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 5b51a3ef..978417fa 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -12,10 +12,10 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/enginehost" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -277,7 +277,7 @@ func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agent // Engines the daemon keeps resident between prompts must not outlive // the daemon: an orphaned server would hold a loopback port and a // session store that nothing owns. - defer deepseekharness.ShutdownServers() + defer enginehost.Shutdown() dial := func(ctx context.Context) (*transport.Conn, error) { return transport.Dial(ctx, transport.DialOptions{ diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/parsar-daemon/internal/dispatch/router.go index 118fa05d..748cdeb1 100644 --- a/apps/parsar-daemon/internal/dispatch/router.go +++ b/apps/parsar-daemon/internal/dispatch/router.go @@ -16,6 +16,7 @@ import ( "errors" "fmt" "log/slog" + "sort" "strings" "sync" "time" @@ -249,6 +250,10 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo "run_id", runID, "agent_kind", req.AgentKind, "work_dir", req.WorkDir, "prompt_len", len(req.Prompt), "has_agent_options", req.AgentOptions != nil, + "agent_option_count", len(req.AgentOptions), + "has_mcp_servers", req.AgentOptions["mcp_servers"] != nil, + "has_skills", req.AgentOptions["skills"] != nil, + "agent_option_keys", sortedOptionKeys(req.AgentOptions), "agent_session_id", req.AgentSessionID, "agent_state_key", req.AgentStateKey) @@ -320,6 +325,15 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo return nil } +func sortedOptionKeys(options map[string]any) []string { + keys := make([]string, 0, len(options)) + for key := range options { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) error { r.mu.Lock() state, ok := r.sessions[env.ID] diff --git a/apps/parsar-daemon/internal/enginehost/spec.go b/apps/parsar-daemon/internal/enginehost/spec.go index 38fc642b..80231786 100644 --- a/apps/parsar-daemon/internal/enginehost/spec.go +++ b/apps/parsar-daemon/internal/enginehost/spec.go @@ -53,6 +53,13 @@ type ServerSpec struct { // with it, that engine's session store) across prompts. Key string + // StateKey is the exclusive ownership identity for mutable engine state. + // Specs with different Key values but the same StateKey never run at the + // same time: the supervisor drains and stops the old variant before it + // launches the replacement. Leave empty when Key already names both the + // reusable process and its private state. + StateKey string + // Binary is the executable to launch, resolved through PATH. Binary string diff --git a/apps/parsar-daemon/internal/enginehost/supervisor.go b/apps/parsar-daemon/internal/enginehost/supervisor.go index 6507fa6c..12590e1a 100644 --- a/apps/parsar-daemon/internal/enginehost/supervisor.go +++ b/apps/parsar-daemon/internal/enginehost/supervisor.go @@ -3,6 +3,7 @@ package enginehost import ( "context" "log/slog" + "strings" "sync" "time" @@ -10,29 +11,50 @@ import ( ) // Supervisor keeps at most one resident engine server per spec key and -// shares it across prompts. Safe for concurrent use. +// shares it across prompts. Specs may also declare an exclusive state key; +// configuration variants for that state are replaced rather than overlapped. +// Safe for concurrent use. // // Concurrency model: one mutex guards the key map, every instance's lease // count and every idle timer. Launching a server is slow (it waits for // readiness), so a launch does NOT hold the mutex; instead the launching // goroutine installs a pending entry that other Acquire calls for the -// same key wait on. That keeps two simultaneous first prompts of one -// conversation from starting two servers, which for engines with a -// single-writer session store would corrupt state rather than merely -// waste a process. +// same key wait on. Calls for different configuration keys that share one +// state key wait for the current leases to drain and for the old process to +// exit. That protects single-writer session stores from overlap. type Supervisor struct { mu sync.Mutex entries map[string]*entry + states map[string]*entry logger *slog.Logger stopping bool } +// processSupervisor owns resident engine servers for the daemon process. +// Adapters use Acquire rather than creating per-engine supervisors so daemon +// teardown stays independent of the concrete engines that are registered. +var processSupervisor = NewSupervisor(nil) + +// Acquire returns a lease from the process-wide engine server supervisor. +func Acquire(ctx context.Context, spec ServerSpec) (*Lease, error) { + return processSupervisor.Acquire(ctx, spec) +} + +// Shutdown stops every resident engine server owned by this daemon process. +func Shutdown() { processSupervisor.Shutdown() } + // entry is either a launch in flight or a live instance. ready is closed // when the launch settles; inst and err are valid only after that. type entry struct { - ready chan struct{} - inst *instance - err error + key string + stateKey string + ready chan struct{} + changed chan struct{} + inst *instance + err error + // retiring prevents a configuration variant that is being replaced from + // accepting a new lease while its process is still shutting down. + retiring bool // spec timings are captured at launch so a later Release reclaims // with the idle window the launching caller asked for. @@ -43,7 +65,11 @@ func NewSupervisor(logger *slog.Logger) *Supervisor { if logger == nil { logger = obslog.Bg() } - return &Supervisor{entries: make(map[string]*entry), logger: logger} + return &Supervisor{ + entries: make(map[string]*entry), + states: make(map[string]*entry), + logger: logger, + } } // Lease is a live claim on a resident engine server. BaseURL is valid @@ -116,9 +142,9 @@ func (s *Supervisor) Acquire(ctx context.Context, spec ServerSpec) (*Lease, erro // Retry immediately; the next pass launches a replacement. continue } - // Another caller is launching this key. Wait for it, then retake - // the lock: its instance may already have died, in which case the - // next pass launches a replacement. + // Another caller is launching this key, or a different configuration + // still owns the same mutable state. Wait for that entry to change, + // then retry under the lock. select { case <-wait: case <-ctx.Done(): @@ -136,16 +162,53 @@ func (s *Supervisor) tryAcquire(ctx context.Context, spec ServerSpec) (*Lease, < s.mu.Unlock() return nil, nil, context.Canceled } + stateKey := strings.TrimSpace(spec.StateKey) + if stateKey == "" { + stateKey = spec.Key + } + if owner, ok := s.states[stateKey]; ok && owner.key != spec.Key { + select { + case <-owner.ready: + if owner.err != nil || owner.inst == nil || !owner.inst.alive() { + s.removeEntryLocked(owner) + s.mu.Unlock() + return nil, nil, nil + } + if owner.inst.leases > 0 { + wait := owner.changed + s.mu.Unlock() + return nil, wait, nil + } + if owner.inst.idleTimer != nil { + owner.inst.idleTimer.Stop() + owner.inst.idleTimer = nil + } + owner.retiring = true + wait := owner.inst.exited + owner.inst.stop() + s.mu.Unlock() + return nil, wait, nil + default: + wait := owner.ready + s.mu.Unlock() + return nil, wait, nil + } + } if existing, ok := s.entries[spec.Key]; ok { select { case <-existing.ready: // Settled. A failed or dead instance is discarded here so the // caller's next pass launches a fresh one. if existing.err != nil || existing.inst == nil || !existing.inst.alive() { - delete(s.entries, spec.Key) + s.removeEntryLocked(existing) s.mu.Unlock() return nil, nil, nil } + if existing.retiring { + wait := existing.inst.exited + s.mu.Unlock() + return nil, wait, nil + } lease := s.attachLocked(existing, spec.Key) s.mu.Unlock() return lease, nil, nil @@ -156,8 +219,15 @@ func (s *Supervisor) tryAcquire(ctx context.Context, spec ServerSpec) (*Lease, < } } - e := &entry{ready: make(chan struct{}), idleTimeout: spec.idleTimeout()} + e := &entry{ + key: spec.Key, + stateKey: stateKey, + ready: make(chan struct{}), + changed: make(chan struct{}), + idleTimeout: spec.idleTimeout(), + } s.entries[spec.Key] = e + s.states[stateKey] = e s.mu.Unlock() // Launch outside the lock. The pending entry is already published, so @@ -167,13 +237,14 @@ func (s *Supervisor) tryAcquire(ctx context.Context, spec ServerSpec) (*Lease, < s.mu.Lock() e.inst, e.err = inst, err close(e.ready) + s.notifyChangedLocked(e) if err != nil || inst == nil { - delete(s.entries, spec.Key) + s.removeEntryLocked(e) s.mu.Unlock() return nil, nil, err } if s.stopping { - delete(s.entries, spec.Key) + s.removeEntryLocked(e) s.mu.Unlock() inst.stop() return nil, nil, context.Canceled @@ -186,6 +257,7 @@ func (s *Supervisor) tryAcquire(ctx context.Context, spec ServerSpec) (*Lease, < // attachLocked adds a lease to a live instance. Caller holds s.mu. func (s *Supervisor) attachLocked(e *entry, key string) *Lease { e.inst.leases++ + s.notifyChangedLocked(e) if e.inst.idleTimer != nil { e.inst.idleTimer.Stop() e.inst.idleTimer = nil @@ -199,20 +271,23 @@ func (s *Supervisor) release(key string, inst *instance) { if inst == nil { return } + e, ok := s.entries[key] inst.leases-- if inst.leases > 0 { + if ok && e.inst == inst { + s.notifyChangedLocked(e) + } return } inst.leases = 0 - - e, ok := s.entries[key] if !ok || e.inst != inst { // Already superseded or dropped; nothing keeps this process. inst.stop() return } + s.notifyChangedLocked(e) if s.stopping || e.idleTimeout < 0 || !inst.alive() { - delete(s.entries, key) + s.removeEntryLocked(e) inst.stop() return } @@ -228,7 +303,7 @@ func (s *Supervisor) reclaim(key string, inst *instance) { s.mu.Unlock() return } - delete(s.entries, key) + s.removeEntryLocked(e) inst.idleTimer = nil s.mu.Unlock() @@ -242,9 +317,13 @@ func (s *Supervisor) Shutdown() { s.mu.Lock() s.stopping = true pending := make([]*entry, 0, len(s.entries)) - for key, e := range s.entries { + for _, e := range s.entries { pending = append(pending, e) - delete(s.entries, key) + if e.inst != nil && e.inst.idleTimer != nil { + e.inst.idleTimer.Stop() + e.inst.idleTimer = nil + } + s.removeEntryLocked(e) } s.mu.Unlock() @@ -260,3 +339,24 @@ func (s *Supervisor) Shutdown() { } } } + +func (s *Supervisor) removeEntryLocked(e *entry) { + if e == nil { + return + } + if s.entries[e.key] == e { + delete(s.entries, e.key) + } + if s.states[e.stateKey] == e { + delete(s.states, e.stateKey) + } + s.notifyChangedLocked(e) +} + +func (s *Supervisor) notifyChangedLocked(e *entry) { + if e == nil || e.changed == nil { + return + } + close(e.changed) + e.changed = make(chan struct{}) +} diff --git a/apps/parsar-daemon/internal/enginehost/supervisor_test.go b/apps/parsar-daemon/internal/enginehost/supervisor_test.go index 6e0753f7..73827974 100644 --- a/apps/parsar-daemon/internal/enginehost/supervisor_test.go +++ b/apps/parsar-daemon/internal/enginehost/supervisor_test.go @@ -2,6 +2,7 @@ package enginehost import ( "context" + "errors" "fmt" "io" "log/slog" @@ -162,6 +163,63 @@ func TestAcquireDeduplicatesConcurrentLaunches(t *testing.T) { } } +func TestAcquireReplacesConfigurationOnlyAfterSharedStateIsReleased(t *testing.T) { + sup := NewSupervisor(testLogger()) + t.Cleanup(sup.Shutdown) + + firstSpec := fakeSpec(t, "state-v1") + firstSpec.StateKey = "shared-session-store" + first, err := sup.Acquire(context.Background(), firstSpec) + if err != nil { + t.Fatalf("acquire first configuration: %v", err) + } + firstURL := first.BaseURL() + firstExited := first.Exited() + + secondSpec := fakeSpec(t, "state-v2") + secondSpec.StateKey = firstSpec.StateKey + type result struct { + lease *Lease + err error + } + resultCh := make(chan result, 1) + go func() { + lease, acquireErr := sup.Acquire(context.Background(), secondSpec) + resultCh <- result{lease: lease, err: acquireErr} + }() + + select { + case got := <-resultCh: + if got.lease != nil { + got.lease.Release() + } + t.Fatalf("replacement acquired shared state before the active lease released: %v", got.err) + case <-time.After(200 * time.Millisecond): + } + + first.Release() + var second *Lease + select { + case got := <-resultCh: + if got.err != nil { + t.Fatalf("acquire replacement: %v", got.err) + } + second = got.lease + case <-time.After(10 * time.Second): + t.Fatal("replacement did not acquire shared state") + } + defer second.Release() + + select { + case <-firstExited: + default: + t.Fatal("replacement became ready before the old state owner exited") + } + if second.BaseURL() == firstURL { + t.Fatalf("configuration replacement reused old server %q", firstURL) + } +} + func TestReleaseWithNegativeIdleTimeoutStopsImmediately(t *testing.T) { sup := NewSupervisor(testLogger()) t.Cleanup(sup.Shutdown) @@ -326,6 +384,40 @@ func TestShutdownStopsEverything(t *testing.T) { } } +func TestShutdownUnblocksConfigurationReplacementWaiter(t *testing.T) { + sup := NewSupervisor(testLogger()) + firstSpec := fakeSpec(t, "shutdown-state-v1") + firstSpec.StateKey = "shutdown-shared-state" + first, err := sup.Acquire(context.Background(), firstSpec) + if err != nil { + t.Fatalf("acquire first configuration: %v", err) + } + + secondSpec := fakeSpec(t, "shutdown-state-v2") + secondSpec.StateKey = firstSpec.StateKey + errCh := make(chan error, 1) + go func() { + _, acquireErr := sup.Acquire(context.Background(), secondSpec) + errCh <- acquireErr + }() + select { + case err := <-errCh: + t.Fatalf("replacement returned before shutdown: %v", err) + case <-time.After(200 * time.Millisecond): + } + + sup.Shutdown() + first.Release() + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("replacement error = %v, want context.Canceled", err) + } + case <-time.After(10 * time.Second): + t.Fatal("replacement waiter survived supervisor shutdown") + } +} + func TestFreeLoopbackPortReturnsBindablePorts(t *testing.T) { seen := map[int]bool{} for range 5 { diff --git a/docker-compose.yml b/docker-compose.yml index 3fb9d43f..c4ca68fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,8 @@ services: # Dokploy may add an ingress network; keep internal service DNS available. networks: - default + extra_hosts: + - "host.docker.internal:host-gateway" command: ["/bin/sh", "-c", "parsar-migrate && exec /usr/local/bin/parsar-server"] ports: - "${PARSAR_BIND_ADDR:-127.0.0.1}:${PARSAR_LOCAL_PORT:-18080}:8080" @@ -36,6 +38,16 @@ services: PARSAR_MASTER_KEY: "${PARSAR_MASTER_KEY:-0000000000000000000000000000000000000000000000000000000000000000}" PARSAR_SHARED_RUNTIME_TOKEN: "${PARSAR_SHARED_RUNTIME_TOKEN:-parsar-local-runtime-token-change-me}" PARSAR_AGENT_DAEMON_WS_URL: "ws://parsar-server:8080/agent-daemon/ws" + # Keep operator-configured egress proxies available to dynamically + # created Docker sandboxes. Internal Compose traffic must stay direct. + HTTP_PROXY: "${PARSAR_CONTAINER_HTTP_PROXY:-${HTTP_PROXY:-}}" + HTTPS_PROXY: "${PARSAR_CONTAINER_HTTPS_PROXY:-${HTTPS_PROXY:-}}" + ALL_PROXY: "${PARSAR_CONTAINER_ALL_PROXY:-${ALL_PROXY:-}}" + NO_PROXY: "127.0.0.1,localhost,parsar-server,postgres,${NO_PROXY:-}" + http_proxy: "${PARSAR_CONTAINER_HTTP_PROXY:-${http_proxy:-}}" + https_proxy: "${PARSAR_CONTAINER_HTTPS_PROXY:-${https_proxy:-}}" + all_proxy: "${PARSAR_CONTAINER_ALL_PROXY:-${all_proxy:-}}" + no_proxy: "127.0.0.1,localhost,parsar-server,postgres,${no_proxy:-}" volumes: - ${PARSAR_DATA_DIR:-server-data}:/var/lib/parsar healthcheck: @@ -48,8 +60,21 @@ services: depends_on: parsar-server: condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" environment: PARSAR_SHARED_RUNTIME_TOKEN: "${PARSAR_SHARED_RUNTIME_TOKEN:-parsar-local-runtime-token-change-me}" + # Agent engines inherit these variables from parsar-daemon. Node-based + # engines need NODE_USE_ENV_PROXY to make fetch honour the proxy. + HTTP_PROXY: "${PARSAR_CONTAINER_HTTP_PROXY:-${HTTP_PROXY:-}}" + HTTPS_PROXY: "${PARSAR_CONTAINER_HTTPS_PROXY:-${HTTPS_PROXY:-}}" + ALL_PROXY: "${PARSAR_CONTAINER_ALL_PROXY:-${ALL_PROXY:-}}" + NO_PROXY: "127.0.0.1,localhost,parsar-server,postgres,${NO_PROXY:-}" + http_proxy: "${PARSAR_CONTAINER_HTTP_PROXY:-${http_proxy:-}}" + https_proxy: "${PARSAR_CONTAINER_HTTPS_PROXY:-${https_proxy:-}}" + all_proxy: "${PARSAR_CONTAINER_ALL_PROXY:-${all_proxy:-}}" + no_proxy: "127.0.0.1,localhost,parsar-server,postgres,${no_proxy:-}" + NODE_USE_ENV_PROXY: "${NODE_USE_ENV_PROXY:-1}" command: ["/opt/parsar/bin/runtime-entrypoint.sh"] volumes: - parsar-runtime-home:/root/.parsar diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index 10fac504..789340b1 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -52,6 +52,42 @@ func TestEnvelopeOmitsEmptyPayload(t *testing.T) { } } +func TestPromptRequestAgentOptionsRoundTrip(t *testing.T) { + in := PromptRequestPayload{ + AgentKind: "deepseek_harness", + RunID: "run-capabilities", + Prompt: "prove capabilities", + AgentOptions: map[string]any{ + "mcp_servers": map[string]any{ + "proof": map[string]any{"command": "node", "args": []any{"proof.mjs"}}, + }, + "skills": []any{map[string]any{"name": "proof", "download_url": "https://example.invalid/proof.zip"}}, + }, + } + env, err := NewEnvelope(TypePromptRequest, in.RunID, in) + if err != nil { + t.Fatalf("NewEnvelope: %v", err) + } + wire, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var decoded Envelope + if err := json.Unmarshal(wire, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + var got PromptRequestPayload + if err := decoded.DecodePayload(&got); err != nil { + t.Fatalf("DecodePayload: %v", err) + } + if _, ok := got.AgentOptions["mcp_servers"].(map[string]any); !ok { + t.Fatalf("mcp_servers = %T (%v)", got.AgentOptions["mcp_servers"], got.AgentOptions["mcp_servers"]) + } + if skills, ok := got.AgentOptions["skills"].([]any); !ok || len(skills) != 1 { + t.Fatalf("skills = %T (%v)", got.AgentOptions["skills"], got.AgentOptions["skills"]) + } +} + func TestDecodePayloadEmptyIsNoop(t *testing.T) { env := Envelope{Type: TypePromptCancel, ID: "run-789"} var out PromptCancelPayload diff --git a/server/cmd/server/sandbox_docker.go b/server/cmd/server/sandbox_docker.go index 10250aaf..d7f743c6 100644 --- a/server/cmd/server/sandbox_docker.go +++ b/server/cmd/server/sandbox_docker.go @@ -102,6 +102,8 @@ func agentDaemonWSURLFromBase(base string) string { // _XL_MEMORY / _XL_CPUS — optional per-size overrides. // - AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT — optional pids cap; unset = no // cap (docker default). +// - Standard HTTP_PROXY / HTTPS_PROXY / ALL_PROXY variables are inherited +// by the sandbox. NO_PROXY is merged with loopback and Compose services. func buildDockerAgentDaemonSandboxProvider( env func(string) string, cfg config.Config, @@ -194,17 +196,88 @@ const ( // resolveDockerLimit for the 0/unlimited escape hatch. func dockerClientFromEnv(env func(string) string, image, network string, hostGateway bool) *dockersandbox.Client { standardLimits, xlLimits := dockerLimitsFromEnv(env) + networkEnv := dockerSandboxNetworkEnv(env) return &dockersandbox.Client{ Image: image, Network: network, - HostGateway: hostGateway, + HostGateway: hostGateway || proxyUsesHostGateway(networkEnv), Memory: standardLimits.Memory, CPUs: standardLimits.CPUs, PidsLimit: standardLimits.PidsLimit, LimitsBySize: map[string]dockersandbox.ResourceLimits{"standard": standardLimits, "xl": xlLimits}, + ContainerEnv: networkEnv, } } +var dockerSandboxProxyEnvKeys = []string{ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "http_proxy", "https_proxy", "all_proxy", +} + +var dockerSandboxNoProxyHosts = []string{"127.0.0.1", "localhost", "parsar-server", "postgres"} + +// dockerSandboxNetworkEnv keeps Docker sandbox egress consistent with the +// server process while preserving direct loopback and Compose service traffic. +// NODE_USE_ENV_PROXY activates standard proxy variables for Node fetch, used by +// DSH and reusable by other Node-based resident engines. +func dockerSandboxNetworkEnv(env func(string) string) map[string]string { + out := make(map[string]string) + hasProxy := false + for _, key := range dockerSandboxProxyEnvKeys { + if value := strings.TrimSpace(env(key)); value != "" { + out[key] = value + hasProxy = true + } + } + for _, key := range []string{"NO_PROXY", "no_proxy"} { + out[key] = mergeNoProxy(env(key), dockerSandboxNoProxyHosts...) + } + if value := strings.TrimSpace(env("NODE_USE_ENV_PROXY")); value != "" { + out["NODE_USE_ENV_PROXY"] = value + } else if hasProxy { + out["NODE_USE_ENV_PROXY"] = "1" + } + return out +} + +func mergeNoProxy(raw string, required ...string) string { + seen := make(map[string]bool) + merged := make([]string, 0, len(required)+4) + appendEntry := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + key := strings.ToLower(value) + if seen[key] { + return + } + seen[key] = true + merged = append(merged, value) + } + for _, value := range required { + appendEntry(value) + } + for _, value := range strings.Split(raw, ",") { + appendEntry(value) + } + return strings.Join(merged, ",") +} + +func proxyUsesHostGateway(env map[string]string) bool { + for _, key := range dockerSandboxProxyEnvKeys { + raw := strings.TrimSpace(env[key]) + if raw == "" { + continue + } + parsed, err := url.Parse(raw) + if err == nil && strings.EqualFold(parsed.Hostname(), "host.docker.internal") { + return true + } + } + return false +} + func dockerLimitsFromEnv(env func(string) string) (standard dockersandbox.ResourceLimits, xl dockersandbox.ResourceLimits) { globalMemory := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_MEMORY")) globalCPUs := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_CPUS")) diff --git a/server/cmd/server/sandbox_docker_test.go b/server/cmd/server/sandbox_docker_test.go index 367fd240..16e15284 100644 --- a/server/cmd/server/sandbox_docker_test.go +++ b/server/cmd/server/sandbox_docker_test.go @@ -46,6 +46,43 @@ func TestDockerClientFromEnvReadsResourceLimits(t *testing.T) { } } +func TestDockerClientFromEnvPropagatesProxyAndMergesNoProxy(t *testing.T) { + env := dockerBackendEnv(map[string]string{ + "HTTPS_PROXY": "http://proxy.example", + "NO_PROXY": "localhost,custom.internal,POSTGRES", + }) + c := dockerClientFromEnv(env, "img", "net", false) + if c.ContainerEnv["HTTPS_PROXY"] != "http://proxy.example" { + t.Fatal("HTTPS_PROXY was not propagated") + } + if c.ContainerEnv["NODE_USE_ENV_PROXY"] != "1" { + t.Fatal("Node environment proxy activation was not enabled") + } + if got := c.ContainerEnv["NO_PROXY"]; got != "127.0.0.1,localhost,parsar-server,postgres,custom.internal" { + t.Fatalf("NO_PROXY = %q", got) + } +} + +func TestDockerSandboxNetworkEnvHonoursExplicitNodeSetting(t *testing.T) { + env := dockerBackendEnv(map[string]string{ + "HTTP_PROXY": "http://proxy.example", + "NODE_USE_ENV_PROXY": "0", + }) + if got := dockerSandboxNetworkEnv(env)["NODE_USE_ENV_PROXY"]; got != "0" { + t.Fatalf("NODE_USE_ENV_PROXY = %q, want explicit 0", got) + } +} + +func TestDockerClientAddsHostGatewayForContainerProxy(t *testing.T) { + env := dockerBackendEnv(map[string]string{ + "HTTPS_PROXY": "http://host.docker.internal:7890", + }) + c := dockerClientFromEnv(env, "img", "parsar_default", false) + if !c.HostGateway { + t.Fatal("container proxy through host.docker.internal requires a host-gateway mapping") + } +} + func TestDockerClientFromEnvAppliesBuiltInDefaults(t *testing.T) { // With the env unset the operator gets the smaller advertised standard size. // PidsLimit stays unset: a low pids cap is a classic build-breaker diff --git a/server/internal/capability/render/deepseekharness.go b/server/internal/capability/render/deepseekharness.go index ca8a6551..087e361b 100644 --- a/server/internal/capability/render/deepseekharness.go +++ b/server/internal/capability/render/deepseekharness.go @@ -8,18 +8,16 @@ import ( ) // deepseekHarnessRenderer serializes capability specs for the DeepSeek -// Harness runtime (`dsh --profile headless`). That surface takes a task -// string and a config overlay only: the daemon adapter folds a rendered -// system prompt into the task text, while skills, managed MCP servers and -// plugins have no seam there and return ErrUnsupported, which the -// agentdaemon connector treats as a soft degrade (skip + disabled-capability -// notice). +// Harness runtime. Sandbox daemons use the resident profile, which installs +// managed Skill archives and translates MCP entries into dsh-mcp-client rows. +// Local-device headless daemons still reject those options at the adapter +// boundary because exposing a resident unauthenticated server there is unsafe. type deepseekHarnessRenderer struct{} func (deepseekHarnessRenderer) Target() Target { return TargetDeepseekHarness } func (deepseekHarnessRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindSystemPrompt + return kind == canonical.KindMCP || kind == canonical.KindSkill || kind == canonical.KindSystemPrompt } func (deepseekHarnessRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -27,9 +25,13 @@ func (deepseekHarnessRenderer) Render(_ context.Context, spec canonical.Spec) (O return Output{}, fmt.Errorf("deepseek_harness render: invalid spec: %w", err) } switch spec.Kind { + case canonical.KindMCP: + return renderClaudeCodeMCP(spec.MCP) + case canonical.KindSkill: + return renderClaudeCodeSkill(spec.Skill) case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) - case canonical.KindSkill, canonical.KindMCP, canonical.KindPlugin: + case canonical.KindPlugin: return Output{}, ErrUnsupported default: return Output{}, fmt.Errorf("deepseek_harness render: unknown kind %q", spec.Kind) diff --git a/server/internal/capability/render/renderer_test.go b/server/internal/capability/render/renderer_test.go index c34a63f0..0277e8cf 100644 --- a/server/internal/capability/render/renderer_test.go +++ b/server/internal/capability/render/renderer_test.go @@ -90,8 +90,8 @@ func TestSupports(t *testing.T) { {TargetOpenCode, canonical.KindSkill, false}, {TargetPi, canonical.KindMCP, false}, {TargetPi, canonical.KindSkill, true}, - {TargetDeepseekHarness, canonical.KindMCP, false}, - {TargetDeepseekHarness, canonical.KindSkill, false}, + {TargetDeepseekHarness, canonical.KindMCP, true}, + {TargetDeepseekHarness, canonical.KindSkill, true}, {TargetDeepseekHarness, canonical.KindSystemPrompt, true}, } for _, tc := range cases { @@ -281,6 +281,40 @@ func TestCodexRenderer_StreamableHTTP(t *testing.T) { } } +func TestDeepseekHarnessRendererMCPAndSkillGolden(t *testing.T) { + mcpOut, err := (deepseekHarnessRenderer{}).Render(context.Background(), mcpFixture()) + if err != nil { + t.Fatalf("render MCP: %v", err) + } + claudeMCP, err := (claudeCodeRenderer{}).Render(context.Background(), mcpFixture()) + if err != nil { + t.Fatalf("render Claude MCP: %v", err) + } + dshCanonical, err := canonicalizeJSON(mcpOut.Content) + if err != nil { + t.Fatalf("canonicalize DSH MCP: %v", err) + } + claudeCanonical, err := canonicalizeJSON(claudeMCP.Content) + if err != nil { + t.Fatalf("canonicalize Claude MCP: %v", err) + } + if dshCanonical != claudeCanonical { + t.Fatalf("DSH MCP shape diverged from Claude Code: dsh=%s claude=%s", dshCanonical, claudeCanonical) + } + + skillOut, err := (deepseekHarnessRenderer{}).Render(context.Background(), skillFixture()) + if err != nil { + t.Fatalf("render Skill: %v", err) + } + var skill claudeCodeSkillDocument + if err := json.Unmarshal(skillOut.Content, &skill); err != nil { + t.Fatalf("decode Skill: %v", err) + } + if skill.Name == "" { + t.Fatalf("Skill name is empty: %s", skillOut.Content) + } +} + func TestOpenCodeRenderer_StreamableHTTP(t *testing.T) { out, err := openCodeRenderer{}.Render(context.Background(), remoteMCPFixture()) if err != nil { diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index d5407a54..784702f7 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -7,6 +7,7 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" "time" @@ -33,8 +34,7 @@ type CapabilityRuntimeStore interface { // given a capability's oss_key, return a short-lived presigned GET URL // the daemon will fetch. Used for both plugin and skill zip downloads. // *oss.Client (server/internal/storage/oss) satisfies this; passing nil -// keeps the connector silently zip-less (both plugin and skill capability -// types are skipped with a log warning). +// skips archive-backed capabilities while Markdown-only Skills remain inline. type OSSPresigner interface { PresignGet(ctx context.Context, key string, ttl time.Duration) (string, time.Time, error) } @@ -59,19 +59,15 @@ type ResolvedPlugin struct { } // ResolvedSkill is the per-skill descriptor the connector embeds in -// agent_options["skills"]. Daemon-side code reads this list, downloads -// each Zip from DownloadURL, verifies SHA256, extracts to -// /.claude/skills//. Claude Code's startup auto-scans -// that directory and registers each skill via the native Skill tool — -// no CLI flag is needed. -// -// JSON shape is byte-identical to ResolvedPlugin so the daemon's -// generic zip installer can decode both with the same code. +// agent_options["skills"]. Archive-backed Skills carry DownloadURL + +// SHA256. Markdown-only Skills carry Content and are materialised as a +// generated SKILL.md without requiring object storage. type ResolvedSkill struct { Name string `json:"name"` Version string `json:"version"` DownloadURL string `json:"download_url"` SHA256 string `json:"sha256"` + Content string `json:"content,omitempty"` } var credentialPlaceholderRe = regexp.MustCompile(`\$\{PARSAR_CREDENTIAL:([a-zA-Z0-9_]+)\}`) @@ -167,10 +163,9 @@ const CapabilityCredentialMissing = "capability_credential_missing" // flip pinning_mode to "latest". const CapabilityVersionUnavailable = "capability_version_unavailable" -// errCapabilityVersionUnavailable is the sentinel error returned by -// resolveSkillCapability / resolvePluginCapability when the version -// they were asked to use (pinned column or joined latest) has an empty -// oss_key. The caller wraps it into a DisabledCapability via +// errCapabilityVersionUnavailable is the sentinel error returned when a +// resolved capability lacks the source required to execute it. The caller +// wraps it into a DisabledCapability via // disabledForUnavailableVersion so the user sees a system-message nudge // instead of the historical silent skip. Returning a sentinel (rather // than a *DisabledCapability via a new signature) keeps the existing @@ -909,53 +904,19 @@ type claudeCodeMCPServerEntry struct { Enabled *bool `json:"enabled,omitempty"` } -// resolveSkillCapability mirrors resolvePluginCapability — skill and -// plugin share the same OSS zip path and daemon-side installer. -// -// Returns nil + no error when OSSPresigner is missing, canonical_spec -// is empty, or oss_key is empty (legacy markdown-paste skill the -// operator must re-upload as a zip). All other failures bubble up; -// silently losing a skill the user enabled is worse than a loud -// install failure. +// resolveSkillCapability supports both multi-file OSS archives and +// Markdown-only canonical Skills. The latter are sent inline so the +// import UI's Paste Markdown path remains executable at runtime. func (c *Connector) resolveSkillCapability( ctx context.Context, cap store.EnabledCapabilityRead, renderer render.Renderer, ) (*ResolvedSkill, error) { - if c.oss == nil { - c.log.Warn("agent_daemon: skill capability skipped — OSSPresigner not configured", - "capability_id", cap.CapabilityID, - "capability_name", cap.Name) - return nil, nil - } // PinningMode-aware field selection: 'latest' picks the lateral- // joined latest_* fields so a reupload of the skill flows through // without any agent_capabilities rewrite. resolved := resolveVersionFields(cap) - ossKey := strings.TrimSpace(resolved.OssKey) - if ossKey == "" { - // Two cases land here: - // * pinning_mode='pinned' on a pre-b77a1c1c version (column - // empty); - // * pinning_mode='latest' but the capability has not been - // uploaded as a zip yet (only markdown-paste exists). - // Either way the daemon needs a system-message nudge — silent - // skip used to leave the user unsure why the skill never - // loaded. errCapabilityVersionUnavailable is converted into a - // DisabledCapability by the caller. - c.log.Warn("agent_daemon: skill capability has empty oss_key, emitting DisabledCapability", - "capability_id", cap.CapabilityID, - "capability_name", cap.Name, - "pinning_mode", cap.PinningMode, - "pinned_version_id", cap.CapabilityVersionID, - "latest_version_id", cap.LatestVersionID) - return nil, errCapabilityVersionUnavailable - } if len(resolved.CanonicalSpec) == 0 { - // Same "user enabled but version isn't usable" story as the - // empty-oss_key branch: a row with oss_key present but - // canonical_spec missing is a corrupted version, not a legacy - // row. Treat the same way so the user sees a nudge. c.log.Warn("agent_daemon: skill capability has empty canonical_spec on resolved version, emitting DisabledCapability", "capability_id", cap.CapabilityID, "capability_name", cap.Name, @@ -980,6 +941,37 @@ func (c *Connector) resolveSkillCapability( return nil, fmt.Errorf("agent_daemon: render skill %s: %w", cap.CapabilityID, err) } + name := strings.TrimSpace(spec.Skill.Slug) + if name == "" { + name = strings.TrimSpace(cap.Name) + } + if name == "" { + return nil, fmt.Errorf("agent_daemon: skill capability %s has empty slug and name", cap.CapabilityID) + } + + ossKey := strings.TrimSpace(resolved.OssKey) + if ossKey == "" { + if len(spec.Skill.Files) > 0 { + c.log.Warn("agent_daemon: multi-file skill capability has empty oss_key, emitting DisabledCapability", + "capability_id", cap.CapabilityID, + "capability_name", cap.Name, + "pinning_mode", cap.PinningMode) + return nil, errCapabilityVersionUnavailable + } + return &ResolvedSkill{ + Name: name, + Version: resolved.Version, + Content: renderInlineSkillContent(name, spec.Skill), + }, nil + } + + if c.oss == nil { + c.log.Warn("agent_daemon: skill archive skipped — OSSPresigner not configured", + "capability_id", cap.CapabilityID, + "capability_name", cap.Name) + return nil, nil + } + url, _, err := c.oss.PresignGet(ctx, ossKey, ossPresignTTL) if err != nil { return nil, fmt.Errorf("agent_daemon: presign skill %s (capability_id=%s): %s", spec.Skill.Slug, cap.CapabilityID, sanitizeOSSError(err)) @@ -990,14 +982,6 @@ func (c *Connector) resolveSkillCapability( return nil, fmt.Errorf("agent_daemon: skill capability %s has oss_key but empty sha256 (pinning_mode=%s)", cap.CapabilityID, cap.PinningMode) } - name := strings.TrimSpace(spec.Skill.Slug) - if name == "" { - name = strings.TrimSpace(cap.Name) - } - if name == "" { - return nil, fmt.Errorf("agent_daemon: skill capability %s has empty slug and name", cap.CapabilityID) - } - return &ResolvedSkill{ Name: name, Version: resolved.Version, @@ -1006,6 +990,18 @@ func (c *Connector) resolveSkillCapability( }, nil } +func renderInlineSkillContent(name string, skill *canonical.SkillSpec) string { + description := strings.TrimSpace(skill.Description) + if description == "" { + description = strings.TrimSpace(skill.Title) + } + if description == "" { + description = name + } + body := strings.TrimRight(skill.Instruction, " \t\r\n") + return fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n%s\n", strconv.Quote(name), strconv.Quote(description), body) +} + // resolveSystemPromptCapability decodes a capability_version.canonical_spec // into a ResolvedSystemPrompt. The render call is kept for wire-shape // consistency only (mirrors resolveSkillCapability); the prompt text is @@ -1062,6 +1058,7 @@ func mergeSkillsIntoOptions(opts map[string]any, skills []ResolvedSkill) { "version": s.Version, "download_url": s.DownloadURL, "sha256": s.SHA256, + "content": s.Content, }) } opts["skills"] = out diff --git a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go index ad543a08..f4df85fc 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go @@ -149,6 +149,29 @@ func TestResolveCapabilityAdditions_PiSkillRenders(t *testing.T) { } } +func TestResolveCapabilityAdditions_DeepseekHarnessSkillAndMCPRender(t *testing.T) { + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{ + newSkillRow(t, "skill-a", "Skill A", "do a"), + newMCPRow(t, "mcp-1", "proof", []canonical.MCPServer{ + {Name: "proof", Command: "node", Args: []string{"/opt/mcp/proof.mjs"}}, + }, nil), + }}, + oss: &stubPluginPresigner{}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "deepseek_harness") + if err != nil { + t.Fatalf("deepseek harness capabilities must render: %v", err) + } + if len(got.Skills) != 1 || got.MCPServers["proof"] == nil { + t.Fatalf("capabilities = %+v", got) + } + if len(got.Disabled) != 0 { + t.Fatalf("supported capabilities were disabled: %+v", got.Disabled) + } +} + // TestResolveCapabilityAdditions_PiMCPSoftDegrades is the negative half: // managed MCP is out of scope for pi, so the pi renderer returns // ErrUnsupported and the connector must skip the row as a Disabled diff --git a/server/internal/connector/agentdaemon/capability_runtime_test.go b/server/internal/connector/agentdaemon/capability_runtime_test.go index 455b4b51..35a6308b 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "log/slog" + "strings" "testing" "time" @@ -84,8 +85,7 @@ func encryptPayload(t *testing.T, svc *secrets.Service, payload map[string]any) // test fixture builders // --------------------------------------------------------------------------- -// newSkillRow builds a skill row already migrated to the OSS-zip path -// (oss_key + sha256 columns populated). Legacy markdown-paste rows +// newSkillRow builds a skill row on the OSS-zip path. Markdown-only rows // without oss_key are built via newLegacySkillRow. func newSkillRow(t *testing.T, id, name, instruction string) store.EnabledCapabilityRead { t.Helper() @@ -113,9 +113,8 @@ func newSkillRow(t *testing.T, id, name, instruction string) store.EnabledCapabi } } -// newLegacySkillRow builds a markdown-paste-era skill: canonical_spec -// present, oss_key/sha256 empty. The connector should skip these with -// a warning telling the operator to re-upload as a zip. +// newLegacySkillRow builds a Markdown-only Skill: canonical_spec present, +// oss_key/sha256 empty. The connector sends these inline. func newLegacySkillRow(t *testing.T, id, name, instruction string) store.EnabledCapabilityRead { t.Helper() row := newSkillRow(t, id, name, instruction) @@ -252,36 +251,29 @@ func TestResolveCapabilityAdditions_SkillKindMismatchErrors(t *testing.T) { } } -func TestResolveCapabilityAdditions_LegacyMarkdownSkillSurfacedAsDisabled(t *testing.T) { - // Markdown-paste-era skill (canonical_spec present but oss_key / - // sha256 empty), pinning_mode 'pinned' (the migration default). - // The b77a1c1c-era silent skip is gone: the resolver now emits a - // DisabledCapability with SubKind=CapabilityVersionUnavailable so - // the user sees a system-message nudge instead of an invisible - // failure. They can fix it by switching pinning_mode to 'latest' - // (if a newer version exists) or re-uploading the skill. +func TestResolveCapabilityAdditions_MarkdownSkillResolvedInline(t *testing.T) { row := newLegacySkillRow(t, "old", "legacy", "stale instruction") row.PinningMode = store.PinningModePinned c := &Connector{ capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, - oss: &stubPluginPresigner{}, log: discardLogger(), } got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") if err != nil { - t.Fatalf("legacy row: %v", err) + t.Fatalf("markdown row: %v", err) } - if len(got.Skills) != 0 { - t.Fatalf("expected legacy skill not to resolve, got %+v", got.Skills) + if len(got.Skills) != 1 { + t.Fatalf("expected one inline skill, got %+v", got.Skills) } - if len(got.Disabled) != 1 { - t.Fatalf("expected 1 DisabledCapability, got %d: %+v", len(got.Disabled), got.Disabled) + skill := got.Skills[0] + if skill.Name != "skill-old" || skill.DownloadURL != "" || skill.SHA256 != "" { + t.Fatalf("inline descriptor = %+v", skill) } - if got.Disabled[0].SubKind != CapabilityVersionUnavailable { - t.Fatalf("Disabled[0].SubKind = %q, want %q", got.Disabled[0].SubKind, CapabilityVersionUnavailable) + if !strings.Contains(skill.Content, "name: \"skill-old\"") || !strings.Contains(skill.Content, "stale instruction") { + t.Fatalf("inline SKILL.md = %q", skill.Content) } - if got.Disabled[0].CapabilityID != "old" { - t.Fatalf("Disabled[0].CapabilityID = %q, want %q", got.Disabled[0].CapabilityID, "old") + if len(got.Disabled) != 0 { + t.Fatalf("inline skill should not be disabled: %+v", got.Disabled) } } @@ -384,6 +376,22 @@ func TestResolveCapabilityAdditions_SkillSkippedWhenPresignerNil(t *testing.T) { } } +func TestResolveCapabilityAdditions_InlineSkillDoesNotRequirePresigner(t *testing.T) { + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{ + newLegacySkillRow(t, "inline", "Inline", "Use the inline proof marker."), + }}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "deepseek_harness") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + if len(got.Skills) != 1 || got.Skills[0].Content == "" { + t.Fatalf("inline skill = %+v", got.Skills) + } +} + func TestResolveCapabilityAdditions_StoreErrorPropagates(t *testing.T) { sentinel := errors.New("db blip") c := &Connector{ @@ -785,7 +793,7 @@ func TestMergeSkillsIntoOptions_PopulatesOptsSkills(t *testing.T) { opts := map[string]any{} mergeSkillsIntoOptions(opts, []ResolvedSkill{ {Name: "code-review", Version: "1.0.0", DownloadURL: "https://x", SHA256: "aa"}, - {Name: "writer", Version: "2.0.0", DownloadURL: "https://y", SHA256: "bb"}, + {Name: "writer", Version: "2.0.0", Content: "inline body"}, }) got, ok := opts["skills"].([]any) if !ok || len(got) != 2 { @@ -795,6 +803,10 @@ func TestMergeSkillsIntoOptions_PopulatesOptsSkills(t *testing.T) { if first["name"] != "code-review" || first["download_url"] != "https://x" { t.Fatalf("first entry shape wrong: %+v", first) } + second, _ := got[1].(map[string]any) + if second["name"] != "writer" || second["content"] != "inline body" { + t.Fatalf("second entry shape wrong: %+v", second) + } } func TestMergeSkillsIntoOptions_OverrideWins(t *testing.T) { diff --git a/server/internal/connector/agentdaemon/model_injection.go b/server/internal/connector/agentdaemon/model_injection.go index e0b0b84c..7a37661a 100644 --- a/server/internal/connector/agentdaemon/model_injection.go +++ b/server/internal/connector/agentdaemon/model_injection.go @@ -104,6 +104,11 @@ func (c *Connector) buildAgentOptions(ctx context.Context, in connector.PromptIn mergeSkillsIntoOptions(opts, additions.Skills) mergeMCPServersIntoOptions(opts, additions.MCPServers) mergePluginsIntoOptions(opts, additions.Plugins) + c.log.Info("agent_daemon: agent options ready", + "run_id", in.RunID, + "agent_option_count", len(opts), + "has_mcp_servers", opts["mcp_servers"] != nil, + "has_skills", opts["skills"] != nil) // Surface every Disabled capability as a runtime_error system // message so the channel layer can render the credential-form // nudge. SystemMessages may be nil on dev / smoke contexts; diff --git a/server/internal/sandbox/docker/client.go b/server/internal/sandbox/docker/client.go index caa414ed..d3c1e910 100644 --- a/server/internal/sandbox/docker/client.go +++ b/server/internal/sandbox/docker/client.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "strconv" "strings" @@ -27,7 +28,7 @@ type execResult struct { // runnerFunc is the injection seam: production wires an os/exec-backed // runner, tests wire a fake so no real docker daemon is required. -type runnerFunc func(ctx context.Context, name string, args []string, stdin io.Reader) (execResult, error) +type runnerFunc func(ctx context.Context, name string, args []string, stdin io.Reader, env []string) (execResult, error) type Client struct { Image string @@ -41,6 +42,10 @@ type Client struct { CPUs string // --cpus, e.g. "1.5" PidsLimit string // --pids-limit, e.g. "512" LimitsBySize map[string]ResourceLimits + // ContainerEnv is inherited by every created sandbox. Values are placed + // in the docker client's environment and passed with key-only `-e` flags, + // so proxy credentials do not appear in the docker command line. + ContainerEnv map[string]string runner runnerFunc } @@ -71,12 +76,17 @@ func (c *Client) Create(ctx context.Context, input e2b.CreateInput) (e2b.Sandbox for k, v := range input.Metadata { args = append(args, "--label", k+"="+v) } + for k := range c.ContainerEnv { + if _, overridden := input.Env[k]; !overridden { + args = append(args, "-e", k) + } + } for k, v := range input.Env { args = append(args, "-e", k+"="+v) } args = append(args, c.Image, "infinity") - res, err := c.runnerOrDefault()(ctx, "docker", args, nil) + res, err := c.runnerOrDefault()(ctx, "docker", args, nil, mergeProcessEnv(c.ContainerEnv)) if err != nil { return e2b.Sandbox{}, err } @@ -127,7 +137,7 @@ func (c *Client) RunCommand(ctx context.Context, input e2b.RunCommandInput) (e2b } args = append(args, input.Sandbox.SandboxID, "/bin/bash", "-l", "-c", input.Command) - res, err := c.runnerOrDefault()(ctx, "docker", args, nil) + res, err := c.runnerOrDefault()(ctx, "docker", args, nil, nil) if err != nil { return e2b.CommandResult{}, err } @@ -144,7 +154,7 @@ func (c *Client) Kill(ctx context.Context, sandboxID string) error { if sandboxID == "" { return errors.New("dockersandbox: sandbox id is empty") } - res, err := c.runnerOrDefault()(ctx, "docker", []string{"rm", "-f", sandboxID}, nil) + res, err := c.runnerOrDefault()(ctx, "docker", []string{"rm", "-f", sandboxID}, nil, nil) if err != nil { return err } @@ -188,8 +198,11 @@ func (c *Client) runnerOrDefault() runnerFunc { // osExecRun runs a local process. A non-zero exit is a normal result // (ExitCode set, err nil) so RunCommand can report it as Status; only a // launch failure or context cancellation returns a non-nil error. -func osExecRun(ctx context.Context, name string, args []string, stdin io.Reader) (execResult, error) { +func osExecRun(ctx context.Context, name string, args []string, stdin io.Reader, env []string) (execResult, error) { cmd := exec.CommandContext(ctx, name, args...) + if env != nil { + cmd.Env = env + } if stdin != nil { cmd.Stdin = stdin } @@ -211,3 +224,23 @@ func osExecRun(ctx context.Context, name string, args []string, stdin io.Reader) } return res, nil } + +func mergeProcessEnv(overrides map[string]string) []string { + if len(overrides) == 0 { + return nil + } + merged := make(map[string]string, len(os.Environ())+len(overrides)) + for _, entry := range os.Environ() { + if key, _, ok := strings.Cut(entry, "="); ok { + merged[key] = entry + } + } + for key, value := range overrides { + merged[key] = key + "=" + value + } + out := make([]string, 0, len(merged)) + for _, entry := range merged { + out = append(out, entry) + } + return out +} diff --git a/server/internal/sandbox/docker/client_integration_test.go b/server/internal/sandbox/docker/client_integration_test.go index bf8624aa..ccc9b144 100644 --- a/server/internal/sandbox/docker/client_integration_test.go +++ b/server/internal/sandbox/docker/client_integration_test.go @@ -45,7 +45,7 @@ func TestIntegrationRealDockerLifecycle(t *testing.T) { t.Errorf("kill: %v", err) } out, _ := osExecRun(context.Background(), "docker", - []string{"ps", "-a", "--filter", "id=" + sb.SandboxID, "--format", "{{.ID}}"}, nil) + []string{"ps", "-a", "--filter", "id=" + sb.SandboxID, "--format", "{{.ID}}"}, nil, nil) if strings.TrimSpace(out.Stdout) != "" { t.Errorf("expected container removed, still present: %q", out.Stdout) } diff --git a/server/internal/sandbox/docker/client_test.go b/server/internal/sandbox/docker/client_test.go index 7fb1c230..f5eda635 100644 --- a/server/internal/sandbox/docker/client_test.go +++ b/server/internal/sandbox/docker/client_test.go @@ -15,6 +15,7 @@ type recordedCall struct { Name string Args []string Stdin string + Env []string } // fakeRunner records calls and returns canned output so unit tests never @@ -24,13 +25,13 @@ type fakeRunner struct { handler func(call recordedCall) (execResult, error) } -func (f *fakeRunner) run(_ context.Context, name string, args []string, stdin io.Reader) (execResult, error) { +func (f *fakeRunner) run(_ context.Context, name string, args []string, stdin io.Reader, env []string) (execResult, error) { var stdinStr string if stdin != nil { b, _ := io.ReadAll(stdin) stdinStr = string(b) } - call := recordedCall{Name: name, Args: args, Stdin: stdinStr} + call := recordedCall{Name: name, Args: args, Stdin: stdinStr, Env: env} f.calls = append(f.calls, call) if f.handler != nil { return f.handler(call) @@ -38,6 +39,8 @@ func (f *fakeRunner) run(_ context.Context, name string, args []string, stdin io return execResult{}, nil } +func containsEnv(env []string, want string) bool { return slices.Contains(env, want) } + func containsArg(args []string, want string) bool { return slices.Contains(args, want) } @@ -128,6 +131,32 @@ func TestCreateAppliesNetworkHostGatewayEnvAndLabels(t *testing.T) { } } +func TestCreatePassesBaseEnvironmentWithoutPuttingValuesInArgs(t *testing.T) { + var got recordedCall + fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { + got = call + return execResult{Stdout: "cid\n"}, nil + }} + client := &Client{ + Image: "img", + ContainerEnv: map[string]string{"HTTPS_PROXY": "http://proxy-secret.example"}, + runner: fake.run, + } + if _, err := client.Create(context.Background(), e2b.CreateInput{}); err != nil { + t.Fatalf("create: %v", err) + } + joined := strings.Join(got.Args, " ") + if !strings.Contains(joined, "-e HTTPS_PROXY") { + t.Fatalf("expected key-only proxy env passthrough, got %v", got.Args) + } + if strings.Contains(joined, "proxy-secret") { + t.Fatalf("proxy value leaked into docker args: %v", got.Args) + } + if !containsEnv(got.Env, "HTTPS_PROXY=http://proxy-secret.example") { + t.Fatal("docker client process did not receive the proxy value") + } +} + func TestCreateAppliesSizeSpecificResourceLimits(t *testing.T) { var got recordedCall fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { @@ -212,7 +241,7 @@ func TestGetInfoReturnsSyntheticFutureExpiry(t *testing.T) { } func TestOSExecRunCapturesStdoutAndZeroExit(t *testing.T) { - res, err := osExecRun(context.Background(), "sh", []string{"-c", "printf hello"}, nil) + res, err := osExecRun(context.Background(), "sh", []string{"-c", "printf hello"}, nil, nil) if err != nil { t.Fatalf("run: %v", err) } @@ -227,7 +256,7 @@ func TestOSExecRunCapturesStdoutAndZeroExit(t *testing.T) { func TestOSExecRunCapturesNonZeroExitWithoutError(t *testing.T) { // A command exiting non-zero is a normal result, not a runner failure: // RunCommand must surface it as CommandResult.Status, so err stays nil. - res, err := osExecRun(context.Background(), "sh", []string{"-c", "printf oops >&2; exit 7"}, nil) + res, err := osExecRun(context.Background(), "sh", []string{"-c", "printf oops >&2; exit 7"}, nil, nil) if err != nil { t.Fatalf("expected nil err for clean non-zero exit, got %v", err) } From 25a780c2385f2b1b394903d9f4541aa850012da9 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 20 Aug 2026 23:25:43 +0800 Subject: [PATCH 5/6] fix(runtime): harden DSH session recovery --- CONTRIBUTING.md | 4 + .../agent/deepseekharness/apiclient.go | 6 + .../agent/deepseekharness/fakegateway_test.go | 1 + .../internal/agent/deepseekharness/prompt.go | 49 ++ .../agent/deepseekharness/server_session.go | 71 +-- .../deepseekharness/server_session_test.go | 51 +- .../agent/deepseekharness/serverhost.go | 4 +- .../agent/deepseekharness/serverhost_test.go | 16 + .../internal/agent/deepseekharness/session.go | 10 +- .../internal/agent/skillinstall/archive.go | 214 +++++++++ .../internal/agent/skillinstall/descriptor.go | 157 ++++++ .../internal/agent/skillinstall/install.go | 449 +----------------- .../internal/enginehost/client_test.go | 47 ++ .../internal/enginehost/downlink.go | 30 +- internal/agentdaemon/proto/envelope_test.go | 10 +- internal/agentdaemon/proto/outbound.go | 4 + .../connector/agentdaemon/connector.go | 20 +- .../agentdaemon/history_injection.go | 54 ++- .../agentdaemon/history_injection_test.go | 34 ++ 19 files changed, 688 insertions(+), 543 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/deepseekharness/prompt.go create mode 100644 apps/parsar-daemon/internal/agent/skillinstall/archive.go create mode 100644 apps/parsar-daemon/internal/agent/skillinstall/descriptor.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77e04bc0..0e3e1cbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,10 @@ 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. diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go b/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go index 69822a59..835faec3 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/apiclient.go @@ -3,6 +3,7 @@ package deepseekharness import ( "context" "encoding/json" + "errors" "fmt" "strings" @@ -69,6 +70,11 @@ func (e *rpcError) Error() string { return msg } +func isSessionNotFound(err error) bool { + var rpcErr *rpcError + return errors.As(err, &rpcErr) && rpcErr.Code == "not-found" +} + // apiClient speaks the dsh gateway envelope over an enginehost transport. type apiClient struct { transport *enginehost.Client diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go index 688cc210..847e2800 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/fakegateway_test.go @@ -110,6 +110,7 @@ func (g *fakeGateway) handleUnary(w http.ResponseWriter, r *http.Request) { _ = json.Unmarshal(raw, &payload) g.prompts = append(g.prompts, promptCall{SessionID: payload.SessionID, Mode: payload.Mode, Content: payload.Content}) fail = g.promptErr + g.promptErr = nil value = map[string]any{"accepted": true} case methodSessionCancel: var payload struct { diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/prompt.go b/apps/parsar-daemon/internal/agent/deepseekharness/prompt.go new file mode 100644 index 00000000..0518bcd9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/prompt.go @@ -0,0 +1,49 @@ +package deepseekharness + +import ( + "errors" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func promptContent(req proto.PromptRequestPayload, resumeFallback string) ([]promptContentPart, error) { + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return nil, errors.New("deepseekharness: empty prompt") + } + sections := make([]string, 0, 3) + if system := systemPreamble(req.AgentOptions); system != "" { + sections = append(sections, system) + } + if resumeFallback = strings.TrimSpace(resumeFallback); resumeFallback != "" { + sections = append(sections, resumeFallback) + } + sections = append(sections, prompt) + parts := []promptContentPart{{Type: "text", Text: strings.Join(sections, "\n\n")}} + for _, att := range req.Attachments { + if !isSupportedImageMedia(att.MIME) { + continue + } + parts = append(parts, promptContentPart{Type: "image", MediaType: att.MIME, Data: att.DataBase64}) + } + return parts, nil +} + +var supportedImageMedia = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "image/gif": true, +} + +func isSupportedImageMedia(mime string) bool { + return supportedImageMedia[strings.ToLower(strings.TrimSpace(mime))] +} + +func systemPreamble(opts map[string]any) string { + if override := stringOpt(opts, "override_system_prompt"); override != "" { + return override + } + return stringOpt(opts, "system_prompt") +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go b/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go index 29499a9a..21669ab7 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/server_session.go @@ -138,68 +138,37 @@ func (s *serverSession) attachAndPrompt(ctx context.Context, req proto.PromptReq s.isNewSession = true } - content, err := promptContent(req) + content, err := promptContent(req, "") if err != nil { down.Close() return err } if err := s.api.Prompt(ctx, s.sessionID, content); err != nil { + if !s.isNewSession && isSessionNotFound(err) { + id, createErr := s.api.CreateSession(ctx, workDir) + if createErr != nil { + down.Close() + return createErr + } + s.sessionID = id + s.isNewSession = true + content, contentErr := promptContent(req, req.ResumeFallbackPrompt) + if contentErr != nil { + down.Close() + return contentErr + } + if retryErr := s.api.Prompt(ctx, s.sessionID, content); retryErr == nil { + return nil + } else { + err = retryErr + } + } down.Close() return err } return nil } -// promptContent renders the turn's text and image attachments into the -// gateway's content-part shape. dsh accepts a narrower set of media types -// than Parsar carries, so an attachment it cannot represent is dropped -// with a warning rather than failing the turn. -func promptContent(req proto.PromptRequestPayload) ([]promptContentPart, error) { - text := strings.TrimSpace(req.Prompt) - if text == "" { - return nil, errors.New("deepseekharness: empty prompt") - } - if system := systemPreamble(req.AgentOptions); system != "" { - // The gateway has no system-prompt seam, so an injected system - // prompt rides at the head of the turn text, as on the headless - // path. - text = system + "\n\n" + text - } - parts := []promptContentPart{{Type: "text", Text: text}} - for _, att := range req.Attachments { - if !isSupportedImageMedia(att.MIME) { - continue - } - parts = append(parts, promptContentPart{ - Type: "image", - MediaType: att.MIME, - Data: att.DataBase64, - }) - } - return parts, nil -} - -// supportedImageMedia is the gateway's accepted raster set. A media type -// outside it is rejected by the request schema, which would fail the whole -// turn over an attachment. -var supportedImageMedia = map[string]bool{ - "image/png": true, - "image/jpeg": true, - "image/webp": true, - "image/gif": true, -} - -func isSupportedImageMedia(mime string) bool { - return supportedImageMedia[strings.ToLower(strings.TrimSpace(mime))] -} - -func systemPreamble(opts map[string]any) string { - if override := stringOpt(opts, "override_system_prompt"); override != "" { - return override - } - return stringOpt(opts, "system_prompt") -} - // pump reads the downlink until the turn ends, translating events into // upstream frames, then emits the terminal frames and closes out. func (s *serverSession) pump() { diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go index f02b68e7..66b39830 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/server_session_test.go @@ -158,6 +158,51 @@ func TestServerSessionResumesTheGivenSessionWithoutCreating(t *testing.T) { } } +func TestServerSessionReplacesAStaleSessionWithServerHistory(t *testing.T) { + gateway := newFakeGateway(t) + gateway.promptErr = &rpcError{Code: "not-found", Message: "session missing"} + req := baseRequest() + req.AgentSessionID = "session-stale" + req.ResumeFallbackPrompt = "Earlier turns:\nUser: first\nAssistant: reply" + out := make(chan proto.Envelope, 16) + s := &serverSession{ + runID: req.RunID, + cfg: quietConfig(), + api: newAPIClient(enginehost.NewClient(gateway.srv.URL, 5*time.Second)), + out: out, + engineExited: make(chan struct{}), + release: func() {}, + diagnostics: func() string { return "" }, + } + if err := s.attachAndPrompt(context.Background(), req, "/tmp/x"); err != nil { + t.Fatalf("attachAndPrompt: %v", err) + } + conn := gateway.conn(t) + go s.pump() + + prompts := gateway.promptCalls() + if len(prompts) != 2 { + t.Fatalf("prompt calls = %d, want stale attempt and replacement", len(prompts)) + } + if prompts[0].SessionID != "session-stale" || strings.Contains(prompts[0].Content[0].Text, "Earlier turns") { + t.Fatalf("first prompt must attempt an unmodified resume: %+v", prompts[0]) + } + if prompts[1].SessionID != gateway.nextSessionID || !strings.Contains(prompts[1].Content[0].Text, req.ResumeFallbackPrompt) { + t.Fatalf("replacement prompt lost server history: %+v", prompts[1]) + } + + emitEvent(t, conn, gateway.nextSessionID, eventTurnEnd, 1, turnEnd("completed")) + var done proto.DonePayload + for env := range out { + if env.Type == proto.TypeDone { + done = decodeEnv[proto.DonePayload](t, env) + } + } + if done.Metadata[proto.DoneMetaAgentSessionID] != gateway.nextSessionID { + t.Fatalf("replacement session was not persisted: %#v", done.Metadata) + } +} + func TestServerSessionStreamsTextThinkingToolsAndUsage(t *testing.T) { h := newHarness(t, baseRequest()) sid := h.gateway.nextSessionID @@ -464,7 +509,7 @@ func TestPromptContentCarriesSystemPromptAndSupportedImages(t *testing.T) { {Kind: "image", MIME: "image/tiff", DataBase64: "BBB"}, } - parts, err := promptContent(req) + parts, err := promptContent(req, "") if err != nil { t.Fatalf("promptContent: %v", err) } @@ -482,7 +527,7 @@ func TestPromptContentCarriesSystemPromptAndSupportedImages(t *testing.T) { } req.AgentOptions = map[string]any{"system_prompt": "be terse", "override_system_prompt": "override wins"} - parts, err = promptContent(req) + parts, err = promptContent(req, "") if err != nil { t.Fatalf("promptContent: %v", err) } @@ -491,7 +536,7 @@ func TestPromptContentCarriesSystemPromptAndSupportedImages(t *testing.T) { } req.Prompt = " " - if _, err := promptContent(req); err == nil { + if _, err := promptContent(req, ""); err == nil { t.Error("an empty prompt must be rejected") } } diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go index 27615742..a22c6363 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost.go @@ -135,11 +135,11 @@ func probeReady(ctx context.Context, baseURL string) error { func serverIdleTimeout() time.Duration { raw := strings.TrimSpace(os.Getenv("PARSAR_DSH_SERVER_IDLE")) if raw == "" { - return enginehost.DefaultIdleTimeout + return time.Hour } d, err := time.ParseDuration(raw) if err != nil || d == 0 { - return enginehost.DefaultIdleTimeout + return time.Hour } return d } diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go index 508fe3be..d517c5f9 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/serverhost_test.go @@ -3,10 +3,26 @@ package deepseekharness import ( "strings" "testing" + "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) +func TestServerIdleTimeoutDefaultsToTheDaemonContract(t *testing.T) { + t.Setenv("PARSAR_DSH_SERVER_IDLE", "") + if got := serverIdleTimeout(); got != time.Hour { + t.Fatalf("idle timeout = %s, want 1h", got) + } + t.Setenv("PARSAR_DSH_SERVER_IDLE", "17m") + if got := serverIdleTimeout(); got != 17*time.Minute { + t.Fatalf("override = %s", got) + } + t.Setenv("PARSAR_DSH_SERVER_IDLE", "invalid") + if got := serverIdleTimeout(); got != time.Hour { + t.Fatalf("invalid fallback = %s", got) + } +} + func sampleLaunch() serverLaunch { return serverLaunch{ Home: "/state/home", diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session.go b/apps/parsar-daemon/internal/agent/deepseekharness/session.go index b5c289a7..270bf191 100644 --- a/apps/parsar-daemon/internal/agent/deepseekharness/session.go +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session.go @@ -1,12 +1,4 @@ -// Package deepseekharness is the agent_kind="deepseek_harness" adapter. -// It drives DeepSeek Harness through its one-shot surface, -// `dsh --profile headless `, which prints the final assistant text -// on stdout and exits non-zero for any turn that did not complete. -// -// The harness exposes no supported machine-readable event stream, resume -// flag, or approval channel for that surface, so this adapter advertises -// neither streaming, usage, resume nor permissions: one prompt is one -// fresh dsh session. +// Package deepseekharness drives headless DSH locally and its resident API in sandboxes. package deepseekharness import ( diff --git a/apps/parsar-daemon/internal/agent/skillinstall/archive.go b/apps/parsar-daemon/internal/agent/skillinstall/archive.go new file mode 100644 index 00000000..41b6210a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/skillinstall/archive.go @@ -0,0 +1,214 @@ +package skillinstall + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// maxSkillZipBytes mirrors the server-side upload cap as daemon-side defence in depth. +const maxSkillZipBytes int64 = 32 * 1024 * 1024 + +var skillsHTTPClient = &http.Client{Timeout: skillInstallTimeout + 10*time.Second} + +func fetchSkillZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { + parsed, err := url.Parse(downloadURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, errors.New("download_url must be http(s)") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, errors.New("build request failed") + } + resp, err := skillsHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + return nil, fmt.Errorf("get: status %d", resp.StatusCode) + } + + f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open dst: %w", err) + } + limited := io.LimitReader(resp.Body, maxSkillZipBytes+1) + written, err := io.Copy(f, limited) + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("copy body: %w", err) + } + if written > maxSkillZipBytes { + _ = f.Close() + return nil, fmt.Errorf("zip exceeds %d byte cap", maxSkillZipBytes) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = f.Close() + return nil, fmt.Errorf("seek after write: %w", err) + } + return f, nil +} + +// sanitizeHTTPClientError removes credentials embedded in presigned URLs. +func sanitizeHTTPClientError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + open := strings.Index(msg, `"`) + if open < 0 { + return msg + } + closeRel := strings.Index(msg[open+1:], `"`) + if closeRel < 0 { + return msg + } + closeAbs := open + 1 + closeRel + if closeAbs+2 > len(msg) { + return msg + } + return msg[:open] + "" + msg[closeAbs+1:] +} + +// verifySHA256FromFD keeps verification and extraction on the same inode. +func verifySHA256FromFD(fd *os.File, want string) error { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" { + return errors.New("verify: empty expected sha256") + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("verify: seek: %w", err) + } + h := sha256.New() + if _, err := io.Copy(h, fd); err != nil { + return fmt.Errorf("verify: hash: %w", err) + } + got := hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) + } + return nil +} + +func extractSkillZipFromFD(fd *os.File, size int64, dst string) error { + zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) + if err != nil { + return fmt.Errorf("extract: open zip: %w", err) + } + root := detectSingleZipRoot(zr.File) + absDst, err := filepath.Abs(dst) + if err != nil { + return fmt.Errorf("extract: abs dst: %w", err) + } + for _, f := range zr.File { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + mode := f.Mode() + // Symlinks and devices must never materialise from a capability archive. + if !f.FileInfo().IsDir() && !mode.IsRegular() { + continue + } + if root != "" { + if !strings.HasPrefix(name, root) { + continue + } + name = strings.TrimPrefix(name, root) + if name == "" { + continue + } + } + target := filepath.Join(absDst, name) + rel, err := filepath.Rel(absDst, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("extract: entry %q escapes target", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("extract: mkdir %s: %w", target, err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) + } + if err := writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + +func writeZipEntry(f *zip.File, target string) error { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("extract: open entry %s: %w", f.Name, err) + } + defer rc.Close() + mode := f.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return fmt.Errorf("extract: open target %s: %w", target, err) + } + defer out.Close() + if _, err := io.Copy(out, rc); err != nil { + return fmt.Errorf("extract: copy %s: %w", target, err) + } + return nil +} + +func detectSingleZipRoot(files []*zip.File) string { + var first string + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" || !strings.Contains(name, "/") { + continue + } + first = name + break + } + if first == "" { + return "" + } + idx := strings.Index(first, "/") + if idx <= 0 { + return "" + } + root := first[:idx+1] + if strings.HasPrefix(root, ".") { + return "" + } + for _, f := range files { + name := normaliseZipPath(f.Name) + if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { + continue + } + if name+"/" == root { + continue + } + if !strings.HasPrefix(name, root) { + return "" + } + } + return root +} + +func normaliseZipPath(name string) string { + return strings.TrimSuffix(strings.ReplaceAll(name, "\\", "/"), "/") +} diff --git a/apps/parsar-daemon/internal/agent/skillinstall/descriptor.go b/apps/parsar-daemon/internal/agent/skillinstall/descriptor.go new file mode 100644 index 00000000..a61dc7ba --- /dev/null +++ b/apps/parsar-daemon/internal/agent/skillinstall/descriptor.go @@ -0,0 +1,157 @@ +package skillinstall + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "strings" +) + +// Descriptor is one server-resolved Skill source. +type Descriptor struct { + Name string + Version string + DownloadURL string + SHA256 string + Content string +} + +// Decode converts agent_options skills into validated descriptors. +func Decode(raw any) ([]Descriptor, []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([]Descriptor, 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 := Descriptor{ + Name: stringField(obj, "name"), Version: stringField(obj, "version"), + DownloadURL: stringField(obj, "download_url"), SHA256: stringField(obj, "sha256"), + Content: stringField(obj, "content"), + } + 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 stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func (s Descriptor) 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) + } + hasInline := strings.TrimSpace(s.Content) != "" + hasArchive := strings.TrimSpace(s.DownloadURL) != "" || strings.TrimSpace(s.SHA256) != "" + if hasInline == hasArchive { + return errors.New("exactly one of content or download_url + sha256 is required") + } + if hasArchive { + 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)) + } + if _, err := hex.DecodeString(s.SHA256); err != nil { + return errors.New("sha256 must be hexadecimal") + } + } + return nil +} + +func (s Descriptor) cacheKey() string { + digest := strings.ToLower(s.SHA256) + if s.isInline() { + sum := sha256.Sum256([]byte(s.Content)) + digest = hex.EncodeToString(sum[:]) + } + return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), digest) +} + +func (s Descriptor) isInline() bool { return strings.TrimSpace(s.Content) != "" } + +// ResolveRoot returns a state-scoped Skill installation root. +func ResolveRoot(runtimeName, conversationID, runID string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("skill install: resolve home: %w", err) + } + runtimeName = strings.TrimSpace(runtimeName) + if runtimeName == "" || strings.ContainsAny(runtimeName, "/\\") || runtimeName == "." || runtimeName == ".." { + return "", fmt.Errorf("skill install: invalid runtime name %q", runtimeName) + } + base := filepath.Join(home, ".parsar", "runtime", runtimeName) + if id := strings.TrimSpace(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "skills"), nil + } + return filepath.Join(base, "run-"+strings.TrimSpace(runID), "skills"), nil +} + +// MergeDirs appends resolved paths to configured paths without duplicates. +func MergeDirs(existing any, resolved []string) []string { + preset := coerceStringSlice(existing) + seen := make(map[string]bool, len(preset)+len(resolved)) + out := make([]string, 0, len(preset)+len(resolved)) + for _, d := range append(append([]string{}, preset...), resolved...) { + if d == "" || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +func coerceStringSlice(v any) []string { + switch t := v.(type) { + case nil: + return nil + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// CloneOptions returns a shallow copy of agent options. +func CloneOptions(opts map[string]any) map[string]any { + if opts == nil { + return map[string]any{} + } + out := make(map[string]any, len(opts)) + maps.Copy(out, opts) + return out +} diff --git a/apps/parsar-daemon/internal/agent/skillinstall/install.go b/apps/parsar-daemon/internal/agent/skillinstall/install.go index 91f49fd0..4828bca4 100644 --- a/apps/parsar-daemon/internal/agent/skillinstall/install.go +++ b/apps/parsar-daemon/internal/agent/skillinstall/install.go @@ -1,19 +1,12 @@ -// Package skillinstall securely materialises server-resolved Skills for -// daemon-side agent engines. +// Package skillinstall securely materialises server-resolved Skills for daemon-side engines. package skillinstall import ( - "archive/zip" "context" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "io" "log/slog" - "maps" - "net/http" - "net/url" "os" "path/filepath" "strings" @@ -23,23 +16,7 @@ import ( "github.com/google/uuid" ) -// Descriptor is the daemon-side view of one server-sent skill entry -// under agent_options["skills"]: -// -// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } -// -// Markdown-only Skills use content instead of download_url + sha256. -type Descriptor struct { - Name string - Version string - DownloadURL string - SHA256 string - Content string -} - -// Result carries installed directories plus per-skill warnings. SkillDirs is -// populated on cache hits as well as fresh installs so engines with explicit -// skill path configuration can reuse it on every turn. +// Result reports the installed Skill directories and per-Skill warnings. type Result struct { SkillDirs []string Warnings []string @@ -47,28 +24,8 @@ type Result struct { const skillInstallTimeout = 60 * time.Second -// maxSkillZipBytes mirrors the server-side cap. Defense in depth. -const maxSkillZipBytes int64 = 32 * 1024 * 1024 - -var skillsHTTPClient = &http.Client{Timeout: skillInstallTimeout + 10*time.Second} - -// Install materialises every skill under // and returns -// the local paths. Per skill: -// -// 1. Cache hit (/.cache-key == name@sha256) returns the dir without -// a network round-trip — but still returns it, so --skill is injected -// on every turn. -// 2. Fetch → verify SHA-256 → extract (single wrapping dir stripped, -// __MACOSX/ ignored) → stamp .cache-key. -// -// Errors during fetch/verify/extract demote one skill to a warning and -// continue. A hard error means the root dir itself was uncreatable. -func Install( - ctx context.Context, - logger *slog.Logger, - root string, - skills []Descriptor, -) (Result, error) { +// Install materialises every descriptor beneath root. +func Install(ctx context.Context, logger *slog.Logger, root string, skills []Descriptor) (Result, error) { if logger == nil { logger = obslog.Bg() } @@ -81,7 +38,6 @@ func Install( if err := os.MkdirAll(root, 0o755); err != nil { return Result{}, fmt.Errorf("skill install: mkdir %s: %w", root, err) } - result := Result{} for _, s := range skills { if err := s.validate(); err != nil { @@ -89,17 +45,14 @@ func Install( logger.Warn("skill install: 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("skill install: cache hit", "name", s.Name, "version", s.Version, "dir", dir) result.SkillDirs = append(result.SkillDirs, dir) continue } - var err error if s.isInline() { err = installInlineSkill(dir, cacheKey, expectedKey, s.Content) @@ -137,8 +90,7 @@ func installInlineSkill(dir, cacheKey, expectedKey, content string) error { return nil } -// Prune removes installer-owned skill directories that are no longer present -// in the server-resolved descriptor set. +// Prune removes installer-owned directories absent from skills. func Prune(root string, skills []Descriptor) error { entries, err := os.ReadDir(root) if errors.Is(err, os.ErrNotExist) { @@ -172,32 +124,19 @@ func Prune(root string, skills []Descriptor) error { return nil } -func installOneSkill( - ctx context.Context, - logger *slog.Logger, - root, dir, cacheKey, expectedKey string, - s Descriptor, -) error { +func installOneSkill(ctx context.Context, logger *slog.Logger, root, dir, cacheKey, expectedKey string, s Descriptor) error { tmpDir := filepath.Join(root, ".tmp") if err := os.MkdirAll(tmpDir, 0o755); err != nil { return fmt.Errorf("mkdir tmp: %w", err) } - - // Per-call uuid so concurrent installs of the same (name, version) - // don't truncate each other's bytes, and nothing on disk between - // verify and extract can be a different file than the one hashed. + // A unique path prevents concurrent installs from truncating verified bytes. zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) defer func() { _ = os.Remove(zipPath) }() - fd, err := fetchSkillZip(ctx, s.DownloadURL, zipPath) if err != nil { return err } defer fd.Close() - - // Verify and extract BOTH read through the same FD (not the path): - // Unix file semantics pin the inode, so a swap on disk between - // hashing and extraction cannot change the bytes we use. if err := verifySHA256FromFD(fd, s.SHA256); err != nil { return err } @@ -208,7 +147,6 @@ func installOneSkill( if err != nil { return fmt.Errorf("stat: %w", err) } - if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("rm old dir: %w", err) } @@ -219,381 +157,8 @@ func installOneSkill( _ = os.RemoveAll(dir) return err } - if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { logger.Warn("skill install: write cache key failed", "path", cacheKey, "err", err.Error()) } return nil } - -// fetchSkillZip GETs url into dst, capping the body at maxSkillZipBytes. -// Returns an OPEN file descriptor at offset 0; the caller closes it. -// Holding the FD across verify + extract closes the TOCTOU between -// hashing the on-disk bytes and reading them for extract. -// -// Only http/https are accepted to defend against a future download_url -// reaching this code with file:// or http://internal-ip/... values. -func fetchSkillZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { - parsed, err := url.Parse(downloadURL) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return nil, errors.New("download_url must be http(s)") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if err != nil { - return nil, errors.New("build request failed") - } - resp, err := skillsHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) - return nil, fmt.Errorf("get: status %d", resp.StatusCode) - } - - f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("open dst: %w", err) - } - - limited := io.LimitReader(resp.Body, maxSkillZipBytes+1) - written, err := io.Copy(f, limited) - if err != nil { - _ = f.Close() - return nil, fmt.Errorf("copy body: %w", err) - } - if written > maxSkillZipBytes { - _ = f.Close() - return nil, fmt.Errorf("zip exceeds %d byte cap", maxSkillZipBytes) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - _ = f.Close() - return nil, fmt.Errorf("seek after write: %w", err) - } - return f, nil -} - -// sanitizeHTTPClientError strips the URL embedded by *url.Error so a -// presigned download_url (OSSAccessKeyId + Signature) never lands in the -// daemon log. Format is ` "": `. -func sanitizeHTTPClientError(err error) string { - if err == nil { - return "" - } - msg := err.Error() - open := strings.Index(msg, `"`) - if open < 0 { - return msg - } - closeRel := strings.Index(msg[open+1:], `"`) - if closeRel < 0 { - return msg - } - closeAbs := open + 1 + closeRel - if closeAbs+2 > len(msg) { - return msg - } - return msg[:open] + "" + msg[closeAbs+1:] -} - -func verifySHA256FromFD(fd *os.File, want string) error { - want = strings.ToLower(strings.TrimSpace(want)) - if want == "" { - return errors.New("verify: empty expected sha256") - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("verify: seek: %w", err) - } - h := sha256.New() - if _, err := io.Copy(h, fd); err != nil { - return fmt.Errorf("verify: hash: %w", err) - } - got := hex.EncodeToString(h.Sum(nil)) - if got != want { - return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) - } - return nil -} - -// extractSkillZipFromFD reads via io.NewSectionReader rather than -// re-opening the path so the byte stream stays identical to the verified -// one (TOCTOU defense). -func extractSkillZipFromFD(fd *os.File, size int64, dst string) error { - zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) - if err != nil { - return fmt.Errorf("extract: open zip: %w", err) - } - - root := detectSingleZipRoot(zr.File) - absDst, err := filepath.Abs(dst) - if err != nil { - return fmt.Errorf("extract: abs dst: %w", err) - } - - for _, f := range zr.File { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - // Skip non-regular entries (symlinks, devices). A symlink entry - // would otherwise be written as a plain file holding the link - // target string — an exfil vector. - mode := f.Mode() - if !f.FileInfo().IsDir() && !mode.IsRegular() { - continue - } - if root != "" { - if !strings.HasPrefix(name, root) { - continue - } - name = strings.TrimPrefix(name, root) - if name == "" { - continue - } - } - - target := filepath.Join(absDst, name) - rel, err := filepath.Rel(absDst, target) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("extract: entry %q escapes target", f.Name) - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { - return fmt.Errorf("extract: mkdir %s: %w", target, err) - } - continue - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) - } - if err := writeZipEntry(f, target); err != nil { - return err - } - } - return nil -} - -func writeZipEntry(f *zip.File, target string) error { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("extract: open entry %s: %w", f.Name, err) - } - defer rc.Close() - - mode := f.Mode().Perm() - if mode == 0 { - mode = 0o644 - } - out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { - return fmt.Errorf("extract: open target %s: %w", target, err) - } - defer out.Close() - if _, err := io.Copy(out, rc); err != nil { - return fmt.Errorf("extract: copy %s: %w", target, err) - } - return nil -} - -// detectSingleZipRoot returns the common wrapping directory (with -// trailing slash) shared by every non-MACOSX entry, or "" when there is -// none. Bare directory entries (no internal "/") are skipped when picking -// the first candidate so `zip -r skill skill/` doesn't short-circuit on -// its own leading directory entry. Hidden roots (".*") are NOT treated as -// wrappers. -func detectSingleZipRoot(files []*zip.File) string { - var first string - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if !strings.Contains(name, "/") { - continue - } - first = name - break - } - if first == "" { - return "" - } - idx := strings.Index(first, "/") - if idx <= 0 { - return "" - } - root := first[:idx+1] - if strings.HasPrefix(root, ".") { - return "" - } - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if name+"/" == root { - continue - } - if !strings.HasPrefix(name, root) { - return "" - } - } - return root -} - -func normaliseZipPath(name string) string { - p := strings.ReplaceAll(name, "\\", "/") - return strings.TrimSuffix(p, "/") -} - -// Decode converts agent_options["skills"] into typed -// descriptors. Entries that fail to decode are dropped with a warning; -// the rest may still be installable. -func Decode(raw any) ([]Descriptor, []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([]Descriptor, 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 := Descriptor{ - Name: stringField(obj, "name"), - Version: stringField(obj, "version"), - DownloadURL: stringField(obj, "download_url"), - SHA256: stringField(obj, "sha256"), - Content: stringField(obj, "content"), - } - 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 stringField(m map[string]any, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} - -func (s Descriptor) validate() error { - if strings.TrimSpace(s.Name) == "" { - return errors.New("name is required") - } - // Block path-traversal names before they hit filepath.Join. - if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { - return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) - } - hasInline := strings.TrimSpace(s.Content) != "" - hasArchive := strings.TrimSpace(s.DownloadURL) != "" || strings.TrimSpace(s.SHA256) != "" - if hasInline == hasArchive { - return errors.New("exactly one of content or download_url + sha256 is required") - } - if hasArchive { - 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)) - } - if _, err := hex.DecodeString(s.SHA256); err != nil { - return errors.New("sha256 must be hexadecimal") - } - } - return nil -} - -func (s Descriptor) cacheKey() string { - digest := strings.ToLower(s.SHA256) - if s.isInline() { - sum := sha256.Sum256([]byte(s.Content)) - digest = hex.EncodeToString(sum[:]) - } - return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), digest) -} - -func (s Descriptor) isInline() bool { - return strings.TrimSpace(s.Content) != "" -} - -// ResolveRoot returns the absolute directory under which managed -// skills install, one subdir per skill. Kept under ~/.parsar/ (runtime -// state lives there, not the user's project tree) and scoped per -// conversation so consecutive turns reuse .cache-key files without two -// conversations racing the same skill dir. runID scopes the one-shot -// fallback when there is no conversation. -func ResolveRoot(runtimeName, conversationID, runID string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("skill install: resolve home: %w", err) - } - runtimeName = strings.TrimSpace(runtimeName) - if runtimeName == "" || strings.ContainsAny(runtimeName, "/\\") || runtimeName == "." || runtimeName == ".." { - return "", fmt.Errorf("skill install: invalid runtime name %q", runtimeName) - } - base := filepath.Join(home, ".parsar", "runtime", runtimeName) - if id := strings.TrimSpace(conversationID); id != "" { - return filepath.Join(base, "conv-"+id, "skills"), nil - } - return filepath.Join(base, "run-"+strings.TrimSpace(runID), "skills"), nil -} - -// MergeDirs combines a caller-supplied skill_dirs override (accepted -// as []string OR []any) with the install-resolved list, preserving order -// and deduplicating. Override wins on collision. -func MergeDirs(existing any, resolved []string) []string { - preset := coerceStringSlice(existing) - seen := make(map[string]bool, len(preset)+len(resolved)) - out := make([]string, 0, len(preset)+len(resolved)) - for _, d := range append(append([]string{}, preset...), resolved...) { - if d == "" || seen[d] { - continue - } - seen[d] = true - out = append(out, d) - } - return out -} - -func coerceStringSlice(v any) []string { - switch t := v.(type) { - case nil: - return nil - case []string: - return t - case []any: - out := make([]string, 0, len(t)) - for _, item := range t { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - default: - return nil - } -} - -// CloneOptions returns a shallow copy so we never mutate the -// caller's map when overwriting the top-level "skill_dirs" key. -func CloneOptions(opts map[string]any) map[string]any { - if opts == nil { - return map[string]any{} - } - out := make(map[string]any, len(opts)) - maps.Copy(out, opts) - return out -} diff --git a/apps/parsar-daemon/internal/enginehost/client_test.go b/apps/parsar-daemon/internal/enginehost/client_test.go index 6a301a54..c4859400 100644 --- a/apps/parsar-daemon/internal/enginehost/client_test.go +++ b/apps/parsar-daemon/internal/enginehost/client_test.go @@ -96,6 +96,53 @@ func TestDialStreamsFramesAndClosesCleanly(t *testing.T) { } } +func TestDownlinkCloseUnblocksAFullFrameQueue(t *testing.T) { + upgrader := websocket.Upgrader{} + serverDone := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer close(serverDone) + defer func() { _ = conn.Close() }() + for i := 0; i < downlinkBuffer+32; i++ { + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"event":"burst"}`)); err != nil { + return + } + } + _, _, _ = conn.ReadMessage() + })) + defer srv.Close() + + down, err := NewClient(srv.URL, 5*time.Second).Dial(context.Background(), "/api/events.mux") + if err != nil { + t.Fatalf("Dial: %v", err) + } + deadline := time.Now().Add(5 * time.Second) + for len(down.frames) < downlinkBuffer && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if len(down.frames) != downlinkBuffer { + t.Fatalf("frame queue never filled: %d", len(down.frames)) + } + + down.Close() + select { + case <-down.readDone: + case <-time.After(5 * time.Second): + t.Fatal("Close did not unblock the saturated reader") + } + if err := down.Err(); err != nil { + t.Fatalf("local Close reported an error: %v", err) + } + select { + case <-serverDone: + case <-time.After(5 * time.Second): + t.Fatal("server did not observe the closed downlink") + } +} + func TestDialFailsOnNonUpgradePath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/apps/parsar-daemon/internal/enginehost/downlink.go b/apps/parsar-daemon/internal/enginehost/downlink.go index ff6a1a8a..e9fa1155 100644 --- a/apps/parsar-daemon/internal/enginehost/downlink.go +++ b/apps/parsar-daemon/internal/enginehost/downlink.go @@ -24,8 +24,10 @@ const downlinkBuffer = 256 // Lifetime: Frames is closed exactly once, after which Err reports why. // Close is idempotent and unblocks the reader. type Downlink struct { - frames chan []byte - conn *websocket.Conn + frames chan []byte + conn *websocket.Conn + closed chan struct{} + readDone chan struct{} closeOnce sync.Once errMu sync.Mutex @@ -57,7 +59,12 @@ func (c *Client) Dial(ctx context.Context, path string) (*Downlink, error) { _ = resp.Body.Close() } - d := &Downlink{frames: make(chan []byte, downlinkBuffer), conn: conn} + d := &Downlink{ + frames: make(chan []byte, downlinkBuffer), + conn: conn, + closed: make(chan struct{}), + readDone: make(chan struct{}), + } go d.read() return d, nil } @@ -75,14 +82,23 @@ func (d *Downlink) Err() error { // Close tears the connection down. Idempotent. func (d *Downlink) Close() { - d.closeOnce.Do(func() { _ = d.conn.Close() }) + d.closeOnce.Do(func() { + close(d.closed) + _ = d.conn.Close() + }) } func (d *Downlink) read() { + defer close(d.readDone) defer close(d.frames) for { msgType, payload, err := d.conn.ReadMessage() if err != nil { + select { + case <-d.closed: + return + default: + } if !isCleanClose(err) { d.setErr(err) } @@ -96,7 +112,11 @@ func (d *Downlink) read() { // alias mutated bytes. frame := make([]byte, len(payload)) copy(frame, payload) - d.frames <- frame + select { + case d.frames <- frame: + case <-d.closed: + return + } } } diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index 789340b1..fa63661f 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -54,9 +54,10 @@ func TestEnvelopeOmitsEmptyPayload(t *testing.T) { func TestPromptRequestAgentOptionsRoundTrip(t *testing.T) { in := PromptRequestPayload{ - AgentKind: "deepseek_harness", - RunID: "run-capabilities", - Prompt: "prove capabilities", + AgentKind: "deepseek_harness", + RunID: "run-capabilities", + Prompt: "prove capabilities", + ResumeFallbackPrompt: "bounded server history", AgentOptions: map[string]any{ "mcp_servers": map[string]any{ "proof": map[string]any{"command": "node", "args": []any{"proof.mjs"}}, @@ -86,6 +87,9 @@ func TestPromptRequestAgentOptionsRoundTrip(t *testing.T) { if skills, ok := got.AgentOptions["skills"].([]any); !ok || len(skills) != 1 { t.Fatalf("skills = %T (%v)", got.AgentOptions["skills"], got.AgentOptions["skills"]) } + if got.ResumeFallbackPrompt != in.ResumeFallbackPrompt { + t.Fatalf("resume fallback = %q", got.ResumeFallbackPrompt) + } } func TestDecodePayloadEmptyIsNoop(t *testing.T) { diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 609dddff..8f8ddec8 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -73,6 +73,10 @@ type PromptRequestPayload struct { // AgentSessionID is the upstream engine session id to resume. AgentSessionID string `json:"agent_session_id,omitempty"` + // ResumeFallbackPrompt is server-owned conversation context that an + // adapter may use only when the upstream session id is stale. + ResumeFallbackPrompt string `json:"resume_fallback_prompt,omitempty"` + // AgentStateKey is the stable daemon-side state directory key. AgentStateKey string `json:"agent_state_key,omitempty"` } diff --git a/server/internal/connector/agentdaemon/connector.go b/server/internal/connector/agentdaemon/connector.go index 83672ed9..4daa95ef 100644 --- a/server/internal/connector/agentdaemon/connector.go +++ b/server/internal/connector/agentdaemon/connector.go @@ -465,6 +465,7 @@ func (c *Connector) streamPrompt(ctx context.Context, in connector.PromptInput, // a property of the device that will execute the run, and the heartbeat // descriptor only exists once its session is resolved. c.applyConversationHistoryInjection(ctx, agentOptions, in, kindInfo) + resumeFallbackPrompt := c.resumeFallbackPrompt(ctx, in, kindInfo, bind.AgentSessionID, agentOptions) upstream, err := sess.Subscribe(in.RunID) if err != nil { @@ -473,15 +474,16 @@ func (c *Connector) streamPrompt(ctx context.Context, in connector.PromptInput, } req, err := proto.NewEnvelope(proto.TypePromptRequest, in.RunID, proto.PromptRequestPayload{ - AgentKind: agentKind, - ConversationID: in.ConversationID, - RunID: in.RunID, - Prompt: in.TriggerMessageContent, - Attachments: promptAttachmentsFromStore(in.TriggerAttachments), - WorkDir: bind.WorkDir, - AgentOptions: agentOptions, - AgentSessionID: bind.AgentSessionID, - AgentStateKey: bind.AgentStateKey, + AgentKind: agentKind, + ConversationID: in.ConversationID, + RunID: in.RunID, + Prompt: in.TriggerMessageContent, + Attachments: promptAttachmentsFromStore(in.TriggerAttachments), + WorkDir: bind.WorkDir, + AgentOptions: agentOptions, + AgentSessionID: bind.AgentSessionID, + ResumeFallbackPrompt: resumeFallbackPrompt, + AgentStateKey: bind.AgentStateKey, }) if err != nil { sess.Unsubscribe(in.RunID) diff --git a/server/internal/connector/agentdaemon/history_injection.go b/server/internal/connector/agentdaemon/history_injection.go index 4cd210e2..72eada7f 100644 --- a/server/internal/connector/agentdaemon/history_injection.go +++ b/server/internal/connector/agentdaemon/history_injection.go @@ -1,16 +1,11 @@ -// Server-side conversation history for engines that cannot resume. +// Server-side conversation history injection and stale-session recovery. // -// claude_code, codex and pi keep their own conversation state and get an -// upstream session id back through agent_engine_sessions, so the daemon -// replays nothing for them. opencode and deepseek_harness advertise -// Capabilities.Resume=false: every prompt is a fresh engine session, so -// without this injection turn two has no idea what turn one said. +// Engines without resume receive the transcript on every turn. Engines with +// resume receive the same bounded block separately, so an adapter can use it +// only if the upstream session has disappeared. // -// The transcript is folded into the system-prompt slot, which every adapter -// already forwards (as --append-system-prompt, or prepended to the task for -// the engines with no system-prompt flag). It is deliberately a bounded tail -// rather than the whole conversation: these engines have no prompt-cache -// reuse, so every injected byte is paid for on every turn. +// The transcript is a bounded tail rather than the whole conversation so a +// fallback does not turn one lost engine session into an unbounded prompt. package agentdaemon import ( @@ -76,13 +71,7 @@ func (c *Connector) applyConversationHistoryInjection( return } - messages, err := c.conversationHistory.ListRecentConversationHistory(ctx, in.ConversationID, historyTurnLimit) - if err != nil { - c.log.Warn("agent_daemon: conversation history read failed; proceeding without transcript", - "run_id", in.RunID, "conversation_id", in.ConversationID, "err", err.Error()) - return - } - block := renderConversationHistory(messages, in.TriggerMessageID, in.TriggerMessageContent) + block, count := c.readConversationHistoryBlock(ctx, in) if block == "" { return } @@ -95,10 +84,37 @@ func (c *Connector) applyConversationHistoryInjection( c.log.Info("agent_daemon: conversation history injected", "run_id", in.RunID, "agent_kind", info.Kind, - "turn_count", len(messages), + "turn_count", count, "block_bytes", len(block)) } +func (c *Connector) resumeFallbackPrompt( + ctx context.Context, + in connector.PromptInput, + info store.AgentDaemonSupportedAgentKind, + sessionID string, + opts map[string]any, +) string { + if !info.Capabilities.Resume || strings.TrimSpace(sessionID) == "" || stringFromMap(opts, "override_system_prompt") != "" { + return "" + } + block, _ := c.readConversationHistoryBlock(ctx, in) + return block +} + +func (c *Connector) readConversationHistoryBlock(ctx context.Context, in connector.PromptInput) (string, int) { + if c.conversationHistory == nil || strings.TrimSpace(in.ConversationID) == "" { + return "", 0 + } + messages, err := c.conversationHistory.ListRecentConversationHistory(ctx, in.ConversationID, historyTurnLimit) + if err != nil { + c.log.Warn("agent_daemon: conversation history read failed; proceeding without transcript", + "run_id", in.RunID, "conversation_id", in.ConversationID, "err", err.Error()) + return "", 0 + } + return renderConversationHistory(messages, in.TriggerMessageID, in.TriggerMessageContent), len(messages) +} + // renderConversationHistory renders stored turns oldest-first, excluding the // message that triggered this run. Returns "" when nothing is left to say. func renderConversationHistory(messages []store.ConversationHistoryMessage, triggerMessageID, triggerContent string) string { diff --git a/server/internal/connector/agentdaemon/history_injection_test.go b/server/internal/connector/agentdaemon/history_injection_test.go index 4370f528..924ede68 100644 --- a/server/internal/connector/agentdaemon/history_injection_test.go +++ b/server/internal/connector/agentdaemon/history_injection_test.go @@ -76,6 +76,40 @@ func TestApplyConversationHistoryInjection_ResumeCapableEngineSkipped(t *testing } } +func TestResumeFallbackPromptCarriesHistoryOnlyForAnExistingSession(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + + got := c.resumeFallbackPrompt(context.Background(), historyInput(), resumeKind(), "thread-1", map[string]any{}) + if !strings.Contains(got, "Assistant: added /healthz in api.go") { + t.Fatalf("fallback history = %q", got) + } + if strings.Contains(got, "now add a readiness probe") { + t.Fatalf("fallback repeated the trigger: %q", got) + } + if reader.calls != 1 { + t.Fatalf("history reads = %d, want 1", reader.calls) + } + + if got := c.resumeFallbackPrompt(context.Background(), historyInput(), resumeKind(), "", map[string]any{}); got != "" { + t.Fatalf("fresh session fallback = %q", got) + } + if got := c.resumeFallbackPrompt(context.Background(), historyInput(), noResumeKind(), "thread-1", map[string]any{}); got != "" { + t.Fatalf("non-resume fallback = %q", got) + } +} + +func TestResumeFallbackPromptRespectsOverrideSystemPrompt(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + got := c.resumeFallbackPrompt(context.Background(), historyInput(), resumeKind(), "thread-1", map[string]any{ + "override_system_prompt": "only this", + }) + if got != "" || reader.calls != 0 { + t.Fatalf("override fallback=%q reads=%d", got, reader.calls) + } +} + func TestApplyConversationHistoryInjection_NoResumeEngineGetsTranscript(t *testing.T) { reader := &fakeHistoryReader{messages: historyMessages()} c := &Connector{conversationHistory: reader, log: discardLogger()} From bf3f254ad4fb450c2f3842239d4eeec0082e6cef Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 21 Aug 2026 14:25:06 +0800 Subject: [PATCH 6/6] fix(runtime): scope injected conversation history --- .../agentdaemon/history_injection.go | 21 ++++++++++---- .../agentdaemon/history_injection_test.go | 29 +++++++++++++++---- server/internal/db/queries/store.sql | 7 +++++ server/internal/db/sqlc/store.sql.go | 9 ++++++ server/internal/store/conversation_history.go | 2 ++ .../store/conversation_history_test.go | 14 +++++++++ 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/server/internal/connector/agentdaemon/history_injection.go b/server/internal/connector/agentdaemon/history_injection.go index 72eada7f..c4ee2bcc 100644 --- a/server/internal/connector/agentdaemon/history_injection.go +++ b/server/internal/connector/agentdaemon/history_injection.go @@ -112,12 +112,12 @@ func (c *Connector) readConversationHistoryBlock(ctx context.Context, in connect "run_id", in.RunID, "conversation_id", in.ConversationID, "err", err.Error()) return "", 0 } - return renderConversationHistory(messages, in.TriggerMessageID, in.TriggerMessageContent), len(messages) + return renderConversationHistory(messages, in.AgentID, in.TriggerMessageID, in.TriggerMessageContent), len(messages) } // renderConversationHistory renders stored turns oldest-first, excluding the // message that triggered this run. Returns "" when nothing is left to say. -func renderConversationHistory(messages []store.ConversationHistoryMessage, triggerMessageID, triggerContent string) string { +func renderConversationHistory(messages []store.ConversationHistoryMessage, currentAgentID, triggerMessageID, triggerContent string) string { lines := make([]string, 0, len(messages)) for _, msg := range messages { if isTriggerMessage(msg, triggerMessageID, triggerContent) { @@ -127,7 +127,7 @@ func renderConversationHistory(messages []store.ConversationHistoryMessage, trig if content == "" { continue } - lines = append(lines, historySpeaker(msg.SenderType)+": "+truncateHistoryText(content, historyMessageBudgetBytes)) + lines = append(lines, historySpeaker(msg, currentAgentID)+": "+truncateHistoryText(content, historyMessageBudgetBytes)) } if len(lines) == 0 { return "" @@ -169,10 +169,19 @@ func isTriggerMessage(msg store.ConversationHistoryMessage, triggerMessageID, tr return stored == trigger || strings.HasSuffix(trigger, stored) } -func historySpeaker(senderType string) string { - switch strings.TrimSpace(senderType) { +func historySpeaker(msg store.ConversationHistoryMessage, currentAgentID string) string { + switch strings.TrimSpace(msg.SenderType) { case "agent": - return "Assistant" + if id := strings.TrimSpace(currentAgentID); id != "" && msg.SenderID == id { + return "Assistant" + } + if name := strings.TrimSpace(msg.SenderName); name != "" { + return "[Agent: " + name + "]" + } + if id := strings.TrimSpace(msg.SenderID); id != "" { + return "[Agent: " + id + "]" + } + return "[Other agent]" default: // user + external (unregistered IM sender) are both humans here. return "User" diff --git a/server/internal/connector/agentdaemon/history_injection_test.go b/server/internal/connector/agentdaemon/history_injection_test.go index 924ede68..fdde8bef 100644 --- a/server/internal/connector/agentdaemon/history_injection_test.go +++ b/server/internal/connector/agentdaemon/history_injection_test.go @@ -30,7 +30,7 @@ func historyMessages() []store.ConversationHistoryMessage { base := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) return []store.ConversationHistoryMessage{ {ID: "m1", SenderType: "user", Content: "add a health endpoint", CreatedAt: base}, - {ID: "m2", SenderType: "agent", Content: "added /healthz in api.go", CreatedAt: base.Add(time.Minute)}, + {ID: "m2", SenderType: "agent", SenderID: "agt-1", SenderName: "Builder", Content: "added /healthz in api.go", CreatedAt: base.Add(time.Minute)}, {ID: "m3", SenderType: "user", Content: "now add a readiness probe", CreatedAt: base.Add(2 * time.Minute)}, } } @@ -200,7 +200,7 @@ func TestRenderConversationHistory_OldestFirstAndBounded(t *testing.T) { {ID: "m3", SenderType: "agent", Content: long}, {ID: "m4", SenderType: "user", Content: " "}, } - block := renderConversationHistory(messages, "", "unrelated trigger") + block := renderConversationHistory(messages, "agt-1", "", "unrelated trigger") firstIdx := strings.Index(block, "first") guestIdx := strings.Index(block, "im guest asks") @@ -235,7 +235,7 @@ func TestRenderConversationHistory_DropsOldestUntilItFits(t *testing.T) { // Mark the newest turn so we can prove it survived the trim. messages[len(messages)-1].Content = "NEWEST " + chunk - block := renderConversationHistory(messages, "", "") + block := renderConversationHistory(messages, "agt-1", "", "") if len(block) > historyTotalBudgetBytes { t.Fatalf("block is %d bytes, over the %d budget", len(block), historyTotalBudgetBytes) } @@ -252,7 +252,7 @@ func TestRenderConversationHistory_TriggerFallbackHandlesQuotedPrefix(t *testing {ID: "m1", SenderType: "agent", Content: "earlier answer"}, {ID: "m2", SenderType: "user", Content: "please retry"}, } - block := renderConversationHistory(messages, "", "[Quoted message] ...\n\nplease retry") + block := renderConversationHistory(messages, "agt-1", "", "[Quoted message] ...\n\nplease retry") if strings.Contains(block, "please retry") { t.Fatalf("quoted-prefixed trigger must still be excluded: %q", block) } @@ -262,11 +262,30 @@ func TestRenderConversationHistory_TriggerFallbackHandlesQuotedPrefix(t *testing } func TestRenderConversationHistory_EmptyInputRendersNothing(t *testing.T) { - if got := renderConversationHistory(nil, "", ""); got != "" { + if got := renderConversationHistory(nil, "agt-1", "", ""); got != "" { t.Fatalf("expected empty render, got %q", got) } } +func TestRenderConversationHistory_AttributesOtherAgents(t *testing.T) { + messages := []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "agent", SenderID: "agt-current", SenderName: "Current", Content: "my answer"}, + {ID: "m2", SenderType: "agent", SenderID: "agt-other", SenderName: "Reviewer", Content: "their answer"}, + {ID: "m3", SenderType: "agent", SenderID: "agt-deleted", Content: "legacy answer"}, + } + + block := renderConversationHistory(messages, "agt-current", "", "") + for _, want := range []string{ + "Assistant: my answer", + "[Agent: Reviewer]: their answer", + "[Agent: agt-deleted]: legacy answer", + } { + if !strings.Contains(block, want) { + t.Fatalf("history missing %q: %q", want, block) + } + } +} + func TestTruncateHistoryTextKeepsValidUTF8(t *testing.T) { text := strings.Repeat("汉", 40) got := truncateHistoryText(text, 30) diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index 7d427755..04acddae 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -970,13 +970,20 @@ select m.id::text, m.sender_type, coalesce(m.sender_id::text, ''::text)::text as m_sender_id, + coalesce(sender_agent.name, ''::text)::text as sender_name, m.content, m.created_at from messages m join conversations c on c.id = m.conversation_id +left join agents sender_agent + on sender_agent.id = m.sender_id + and sender_agent.workspace_id = m.workspace_id + and m.sender_type = 'agent' + and sender_agent.deleted_at is null where m.conversation_id = @conversation_id::uuid and m.workspace_id = c.workspace_id and m.deleted_at is null + and c.status = 'active' and c.deleted_at is null and m.kind = 'message' and m.sender_type in ('user', 'agent', 'external') diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index eafc03ab..60451e4c 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -8740,13 +8740,20 @@ select m.id::text, m.sender_type, coalesce(m.sender_id::text, ''::text)::text as m_sender_id, + coalesce(sender_agent.name, ''::text)::text as sender_name, m.content, m.created_at from messages m join conversations c on c.id = m.conversation_id +left join agents sender_agent + on sender_agent.id = m.sender_id + and sender_agent.workspace_id = m.workspace_id + and m.sender_type = 'agent' + and sender_agent.deleted_at is null where m.conversation_id = $1::uuid and m.workspace_id = c.workspace_id and m.deleted_at is null + and c.status = 'active' and c.deleted_at is null and m.kind = 'message' and m.sender_type in ('user', 'agent', 'external') @@ -8763,6 +8770,7 @@ type ListRecentConversationMessagesRow struct { MID string `json:"m_id"` SenderType string `json:"sender_type"` MSenderID string `json:"m_sender_id"` + SenderName string `json:"sender_name"` Content string `json:"content"` CreatedAt pgtype.Timestamptz `json:"created_at"` } @@ -8784,6 +8792,7 @@ func (q *Queries) ListRecentConversationMessages(ctx context.Context, arg ListRe &i.MID, &i.SenderType, &i.MSenderID, + &i.SenderName, &i.Content, &i.CreatedAt, ); err != nil { diff --git a/server/internal/store/conversation_history.go b/server/internal/store/conversation_history.go index c5b6f9e3..86d65281 100644 --- a/server/internal/store/conversation_history.go +++ b/server/internal/store/conversation_history.go @@ -14,6 +14,7 @@ type ConversationHistoryMessage struct { ID string `json:"id"` SenderType string `json:"sender_type"` SenderID string `json:"sender_id"` + SenderName string `json:"sender_name"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` } @@ -43,6 +44,7 @@ func (s *Store) ListRecentConversationHistory(ctx context.Context, conversationI ID: row.MID, SenderType: row.SenderType, SenderID: row.MSenderID, + SenderName: row.SenderName, Content: row.Content, CreatedAt: row.CreatedAt.Time, }) diff --git a/server/internal/store/conversation_history_test.go b/server/internal/store/conversation_history_test.go index f57e7f3a..45ce6643 100644 --- a/server/internal/store/conversation_history_test.go +++ b/server/internal/store/conversation_history_test.go @@ -73,6 +73,9 @@ func TestListRecentConversationHistory(t *testing.T) { if history[0].SenderType != "user" || history[1].SenderType != "agent" { t.Fatalf("sender types = %q/%q, want user/agent", history[0].SenderType, history[1].SenderType) } + if history[1].SenderID != ids.ProductAgentID || history[1].SenderName == "" { + t.Fatalf("agent history must retain sender identity: %+v", history[1]) + } if history[0].ID == "" || history[0].CreatedAt.IsZero() { t.Fatalf("history rows must carry id + created_at: %+v", history[0]) } @@ -93,6 +96,17 @@ func TestListRecentConversationHistory(t *testing.T) { t.Fatalf("limit must keep the newest turns oldest-first, got %+v", tail) } + if _, err := db.Exec(ctx, `update conversations set status = 'archived' where id = $1::uuid`, ids.ConversationID); err != nil { + t.Fatalf("archive conversation: %v", err) + } + archived, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 10) + if err != nil { + t.Fatalf("list archived conversation history: %v", err) + } + if len(archived) != 0 { + t.Fatalf("archived conversation history must be hidden, got %+v", archived) + } + if zero, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 0); err != nil || zero != nil { t.Fatalf("non-positive limit must read nothing: rows=%+v err=%v", zero, err) }