From 65f27ecbffea8767928df2e13fe278cef408b170 Mon Sep 17 00:00:00 2001 From: Jelloeater's Agent Date: Mon, 21 Sep 2026 18:20:43 -0400 Subject: [PATCH] fix(agents): launch opencode-acp binary-first (native binary on PATH), don't break on stale npm cache (#3836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: goose Co-authored-by: Benjamin Co-authored-by: Carlos Florencio Co-authored-by: João Salavisa --- apps/backend/internal/agent/agents/agent.go | 8 +++ .../agent/agents/managed_npm_runtime.go | 57 +++++++++++++++- .../agent/agents/managed_npm_runtime_test.go | 53 ++++++++++++++- .../internal/agent/agents/opencode_acp.go | 54 ++++++++++++--- .../agent/agents/opencode_acp_test.go | 66 ++++++++++++++++++- .../managed_runtime_recovery_test.go | 5 ++ .../agent/hostutility/managed_runtime_test.go | 2 + .../internal/agent/hostutility/manager.go | 16 ++++- .../agent/hostutility/manager_test.go | 21 ++++++ .../internal/agent/hostutility/public.go | 6 +- .../runtime/lifecycle/manager_launch_test.go | 20 ++++++ .../agent/settings/controller/agent_update.go | 4 +- .../settings/controller/agent_update_job.go | 20 +++--- .../settings/controller/agent_update_test.go | 49 +++++++++++++- .../settings/controller/controller_test.go | 1 + apps/backend/internal/common/config/config.go | 14 +++- .../kubernetes-failure-recovery.spec.ts | 5 +- ...t-utility-managed-runtime-recovery.spec.ts | 39 +++++------ docs/configuration.md | 2 +- ...7-26-user-managed-agent-runtime-updates.md | 6 ++ docs/public/agents-and-profiles.md | 7 ++ docs/public/configuration.md | 10 +-- 22 files changed, 403 insertions(+), 62 deletions(-) diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index b4055717c4..63c8dded2c 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -87,6 +87,14 @@ type InferenceAgent interface { InferenceConfig() *InferenceConfig } +// HostUtilityInferenceAgent is an optional capability for agents whose host +// utility command differs from the command used by a task executor. The +// default InferenceConfig remains executor-safe; host utility callers can use +// this capability when they run on the backend host. +type HostUtilityInferenceAgent interface { + HostUtilityInferenceConfig() *InferenceConfig +} + // ManagedNPMRuntimeAgent is an optional capability for built-in agents whose // ACP runtime is resolved through npm. Package names and ACP arguments are // defined by the agent implementation rather than caller-provided input. diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime.go b/apps/backend/internal/agent/agents/managed_npm_runtime.go index 5dfe1cce56..ba1191b9c6 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime.go @@ -2,6 +2,7 @@ package agents import ( "fmt" + "os/exec" "strings" "github.com/kandev/kandev/internal/agent/managedruntime" @@ -10,10 +11,16 @@ import ( // ManagedNPMRuntimeSpec defines a built-in npm-distributed ACP runtime. // Package and ACPArgs must come from trusted agent metadata, never request // input, because update jobs execute them directly. +// +// NativeBinary optionally names a standalone CLI that ships the same ACP +// interface as the npm package. When that binary is resolvable on PATH, +// host-side launch, probe, refresh, and update commands use it consistently. +// The managed npm runtime remains the fallback for containers and remotes. type ManagedNPMRuntimeSpec struct { Package string DefaultVersion string ACPArgs []string + NativeBinary string } // DefaultVersionOrPinned returns the reviewed default version, including the @@ -58,9 +65,7 @@ func (s ManagedNPMRuntimeSpec) PackageSpec(version string) string { } // ExecutionCacheKey returns npm's deterministic _npx execution-tree key for -// this trusted package spec. npm derives it from the full package string using -// SHA-512 and the first 16 lowercase hexadecimal characters. The optional -// argument uses the reviewed default when omitted. +// this trusted package spec. The optional version uses the reviewed default. func (s ManagedNPMRuntimeSpec) ExecutionCacheKey(versions ...string) string { return managedruntime.NpxExecutionCacheKey(s.PackageSpec(firstVersion(versions))) } @@ -106,6 +111,52 @@ func (s ManagedNPMRuntimeSpec) CacheUpdateCommand(versions ...string) Command { ) } +// NativeCommand returns the direct-binary launch command. Callers must gate it +// on NativeBinaryOnPath so remotes and containers use the managed runtime. +func (s ManagedNPMRuntimeSpec) NativeCommand() Command { + args := []string{s.NativeBinary} + args = append(args, s.ACPArgs...) + return NewCommand(args...) +} + +// NativeBinaryOnPath reports whether the optional native binary is resolvable +// from the host PATH. +func (s ManagedNPMRuntimeSpec) NativeBinaryOnPath() bool { + if s.NativeBinary == "" { + return false + } + _, err := exec.LookPath(s.NativeBinary) + return err == nil +} + +// NativeUpdateCommand updates the npm package that owns the native binary. +// The optional version keeps exact-version updates on the same runtime. +func (s ManagedNPMRuntimeSpec) NativeUpdateCommand(versions ...string) Command { + return NewCommand("npm", "install", "-g", s.PackageSpec(firstVersion(versions))) +} + +// UpdateCommand selects the update recipe for the runtime that launches on +// this host. Native binaries use the global npm install recipe; other hosts +// keep the managed execution-cache update. +func (s ManagedNPMRuntimeSpec) UpdateCommand(versions ...string) Command { + if s.NativeBinaryOnPath() { + return s.NativeUpdateCommand(versions...) + } + return s.CacheUpdateCommand(versions...) +} + +// RefreshCommand selects the command used to probe the runtime after an +// update. Native hosts probe the same binary that the update installs. +func (s ManagedNPMRuntimeSpec) RefreshCommand(versions ...string) Command { + if s.NativeBinaryOnPath() { + return s.NativeCommand() + } + if len(versions) > 0 && versions[0] != "" { + return s.ACPCommand(versions[0]) + } + return s.CachedACPCommand() +} + func firstVersion(versions []string) string { if len(versions) == 0 { return "" diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go index 60b8c1ba02..b13d3625b5 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -1,7 +1,10 @@ package agents import ( + "os" + "path/filepath" "reflect" + "runtime" "slices" "strings" "testing" @@ -119,7 +122,6 @@ func TestManagedNPMRuntimeOnlineCommandChangesOnlyNpmFreshnessFlag(t *testing.T) Package: "@scope/managed-acp", ACPArgs: []string{"--acp", "--model", "fast"}, } - offline := spec.ACPCommand("1.2.3").Args() online := spec.ACPCommandWithNpmPreference("1.2.3", true).Args() want := []string{"npx", "--yes", "--prefer-online", "@scope/managed-acp@1.2.3", "--acp", "--model", "fast"} @@ -169,3 +171,52 @@ func TestManagedAgentsHonorExactVersionCommandOption(t *testing.T) { }) } } + +func TestManagedNPMRuntimeNativeBinaryPreference(t *testing.T) { + nativeBin := "opencode" + ab := ManagedNPMRuntimeSpec{ + Package: "opencode-ai", + DefaultVersion: "1.2.3", + ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"}, + NativeBinary: nativeBin, + } + t.Setenv("PATH", t.TempDir()) + if ab.NativeBinaryOnPath() { + t.Fatal("NativeBinaryOnPath() = true with empty PATH dir, want false") + } + wantNpx := []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.2.3", "acp", "--print-logs", "--log-level", "ERROR"} + if got := ab.RefreshCommand().Args(); !slices.Equal(got, wantNpx) { + t.Fatalf("RefreshCommand (absent binary) = %#v, want %#v", got, wantNpx) + } + + dir := t.TempDir() + name := nativeBin + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(dir, name), []byte("fake"), 0o755); err != nil { + t.Fatalf("write fake binary: %v", err) + } + t.Setenv("PATH", dir) + if !ab.NativeBinaryOnPath() { + t.Fatal("NativeBinaryOnPath() = false with binary on PATH, want true") + } + wantNative := []string{"opencode", "acp", "--print-logs", "--log-level", "ERROR"} + if got := ab.NativeCommand().Args(); !slices.Equal(got, wantNative) { + t.Fatalf("NativeCommand() = %#v, want %#v", got, wantNative) + } + if got := ab.UpdateCommand("2.0.0").Args(); !slices.Equal(got, []string{"npm", "install", "-g", "opencode-ai@2.0.0"}) { + t.Fatalf("UpdateCommand() = %#v, want native npm install", got) + } + if got := ab.RefreshCommand().Args(); !slices.Equal(got, wantNative) { + t.Fatalf("RefreshCommand (present binary) = %#v, want %#v", got, wantNative) + } + + plain := ManagedNPMRuntimeSpec{Package: "@scope/custom", ACPArgs: []string{"acp"}} + if got := plain.RefreshCommand().Args(); !slices.Equal(got, []string{"npx", "--yes", "--prefer-offline", "@scope/custom", "acp"}) { + t.Fatalf("RefreshCommand (no native) = %#v, want npx", got) + } + if got := ab.ExecutionCacheKey(); got != managedruntime.NpxExecutionCacheKey("opencode-ai@1.2.3") { + t.Fatalf("ExecutionCacheKey with NativeBinary = %q, want package key", got) + } +} diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 8c7904d829..361f2fd76b 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -18,11 +18,19 @@ var opencodeACPLogoDark []byte const opencodeACPPackage = "opencode-ai" +// opencodeNativeBinary is the standalone opencode CLI. When it is installed on +// PATH we launch it directly — mirroring CodeNomad's binary-first launch — so +// agent startups and refreshes never depend on per-launch `npx --prefer- +// offline` resolution (see ManagedNPMRuntimeSpec.NativeBinary). +const opencodeNativeBinary = "opencode" + var ( - _ Agent = (*OpenCodeACP)(nil) - _ PassthroughAgent = (*OpenCodeACP)(nil) - _ InferenceAgent = (*OpenCodeACP)(nil) - _ ManagedNPMRuntimeAgent = (*OpenCodeACP)(nil) + _ Agent = (*OpenCodeACP)(nil) + _ PassthroughAgent = (*OpenCodeACP)(nil) + _ InferenceAgent = (*OpenCodeACP)(nil) + _ HostUtilityInferenceAgent = (*OpenCodeACP)(nil) + _ ManagedNPMRuntimeAgent = (*OpenCodeACP)(nil) + _ NativeBinaryAgent = (*OpenCodeACP)(nil) ) // OpenCodeACP is the ACP protocol variant of OpenCode. @@ -84,15 +92,27 @@ func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) return result, nil } +// NativeBinaryName returns the standalone opencode CLI name probed for in the +// execution environment. See NativeBinaryAgent. +func (a *OpenCodeACP) NativeBinaryName() string { return opencodeNativeBinary } + func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command { + // Prefer the standalone opencode binary when the lifecycle probe found it + // on PATH. The managed npm runtime remains the fallback for containerized + // and remote runtimes. + if opts.PreferNativeBinary { + return a.ManagedNPMRuntime().NativeCommand() + } return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion) } func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return newManagedNPMRuntimeSpec( - opencodeACPPackage, - "acp", "--print-logs", "--log-level", "ERROR", - ) + return ManagedNPMRuntimeSpec{ + Package: opencodeACPPackage, + DefaultVersion: MustDefaultManagedNPMRuntimeVersion(opencodeACPPackage), + ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"}, + NativeBinary: opencodeNativeBinary, + } } func (a *OpenCodeACP) Runtime() *RuntimeConfig { @@ -171,10 +191,26 @@ func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting { return emptyPermSettings } -// InferenceConfig returns configuration for one-shot inference using ACP. +// InferenceConfig returns the executor-safe configuration for one-shot +// inference. Session inference can run inside a container or on SSH, so it +// keeps the managed npm command unless the executor selects native use. func (a *OpenCodeACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: a.ManagedNPMRuntime().CachedACPCommand(), } } + +// HostUtilityInferenceConfig prefers the native binary because host utility +// instances run on the backend host and do not cross an executor boundary. +func (a *OpenCodeACP) HostUtilityInferenceConfig() *InferenceConfig { + spec := a.ManagedNPMRuntime() + command := spec.CachedACPCommand() + if spec.NativeBinaryOnPath() { + command = spec.NativeCommand() + } + return &InferenceConfig{ + Supported: true, + Command: command, + } +} diff --git a/apps/backend/internal/agent/agents/opencode_acp_test.go b/apps/backend/internal/agent/agents/opencode_acp_test.go index 80726e8a9d..24a581df0c 100644 --- a/apps/backend/internal/agent/agents/opencode_acp_test.go +++ b/apps/backend/internal/agent/agents/opencode_acp_test.go @@ -4,11 +4,15 @@ import ( "context" "os" "path/filepath" + "runtime" "slices" "testing" ) func TestOpenCodeACPUsesManagedRuntime(t *testing.T) { + // Pin PATH to an empty dir so the executor-safe configuration is + // deterministic. + t.Setenv("PATH", t.TempDir()) a := NewOpenCodeACP() want := a.ManagedNPMRuntime().CachedACPCommand().Args() @@ -26,7 +30,57 @@ func TestOpenCodeACPUsesManagedRuntime(t *testing.T) { } } +// TestOpenCodeACPBuildCommandPrefersNativeBinary is the CodeNomad-style +// binary-first launch: when the lifecycle probe finds `opencode` on PATH, +// BuildCommand emits the direct binary instead of the per-launch npx +// resolution. It is the regression test for "ACP initialize failed: peer +// disconnected before response", which happened because npx exited with +// empty stdout on a stale packument cache / missing postinstall bootstrap. +func TestOpenCodeACPBuildCommandPrefersNativeBinary(t *testing.T) { + a := NewOpenCodeACP() + + if name := a.NativeBinaryName(); name != "opencode" { + t.Fatalf("NativeBinaryName() = %q, want %q", name, "opencode") + } + + wantNative := []string{"opencode", "acp", "--print-logs", "--log-level", "ERROR"} + if got := a.BuildCommand(CommandOptions{PreferNativeBinary: true}).Args(); !slices.Equal(got, wantNative) { + t.Fatalf("BuildCommand(PreferNativeBinary) = %#v, want %#v", got, wantNative) + } + + // Default (probe absent / containers / remotes) keeps the managed npx runtime. + if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, a.ManagedNPMRuntime().CachedACPCommand().Args()) { + t.Fatalf("BuildCommand(default) = %#v, want npx fallback", got) + } +} + +// TestOpenCodeACPInferenceConfigPrefersNativeBinaryOnPath pins the host-utility +// bootstrap to the standalone binary when it is installed, so the ACP probe +// never depends on npm cache state. +func TestOpenCodeACPInferenceConfigPrefersNativeBinaryOnPath(t *testing.T) { + writeOpenCodeTestBinary(t, "exit 0") + + a := NewOpenCodeACP() + want := []string{"opencode", "acp", "--print-logs", "--log-level", "ERROR"} + if got := a.HostUtilityInferenceConfig().Command.Args(); !slices.Equal(got, want) { + t.Fatalf("HostUtilityInferenceConfig().Command = %#v, want %#v", got, want) + } +} + +func TestOpenCodeACPInferenceConfigStaysExecutorSafeOnHostPath(t *testing.T) { + writeOpenCodeTestBinary(t, "exit 0") + + a := NewOpenCodeACP() + want := a.ManagedNPMRuntime().CachedACPCommand().Args() + if got := a.InferenceConfig().Command.Args(); !slices.Equal(got, want) { + t.Fatalf("InferenceConfig().Command = %#v, want executor-safe npx command %#v", got, want) + } +} + func TestOpenCodeACPDiscoveryRecognizesAuthenticationHelper(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is not executable on Windows") + } binaryPath := writeOpenCodeTestBinary(t, "exit 7") a := NewOpenCodeACP() @@ -43,6 +97,9 @@ func TestOpenCodeACPDiscoveryRecognizesAuthenticationHelper(t *testing.T) { } func TestOpenCodeACPDiscoveryDoesNotRunVersionCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is not executable on Windows") + } writeOpenCodeTestBinary(t, "exit 7") result, err := NewOpenCodeACP().IsInstalled(context.Background()) @@ -57,7 +114,7 @@ func TestOpenCodeACPDiscoveryDoesNotRunVersionCommand(t *testing.T) { func writeOpenCodeTestBinary(t *testing.T, body string) string { t.Helper() dir := t.TempDir() - binaryPath := filepath.Join(dir, "opencode") + binaryPath := filepath.Join(dir, agentTestExecutableName("opencode")) contents := "#!/bin/sh\n" + body + "\n" if err := os.WriteFile(binaryPath, []byte(contents), 0o755); err != nil { t.Fatalf("write fake opencode: %v", err) @@ -66,6 +123,13 @@ func writeOpenCodeTestBinary(t *testing.T, body string) string { return binaryPath } +func agentTestExecutableName(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + // TestOpenCodeACPRuntime_RequiresProcessKill is the regression test for GH // issue #1247: opencode acp keeps its HTTP server + MCP child tree alive // when stdin closes, so its RuntimeConfig must signal that the process diff --git a/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go b/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go index 6fa7dd6ab0..2927719055 100644 --- a/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go +++ b/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go @@ -19,6 +19,7 @@ import ( // @covers AC-AGENTS-MANAGED-RUNTIME-RECOVERY-001.6 // @covers AC-AGENTS-MANAGED-RUNTIME-RECOVERY-001.8 func TestManagerProbeRecoversManagedRuntimeETarget(t *testing.T) { + t.Setenv("PATH", t.TempDir()) const version = "1.18.29" agent := agents.NewOpenCodeACP() var commands [][]string @@ -98,6 +99,7 @@ func TestManagerProbeRecoversManagedRuntimeETarget(t *testing.T) { // @covers AC-AGENTS-MANAGED-RUNTIME-RECOVERY-001.6 func TestManagerProbeDoesNotRetryManagedRuntimeRecoveryTwice(t *testing.T) { + t.Setenv("PATH", t.TempDir()) agent := agents.NewOpenCodeACP() var probes, repairs int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -138,6 +140,7 @@ func TestManagerProbeDoesNotRetryManagedRuntimeRecoveryTwice(t *testing.T) { // @covers AC-AGENTS-MANAGED-RUNTIME-RECOVERY-001.6 func TestManagedRuntimeProbeRecoveryStopsOnCancellation(t *testing.T) { + t.Setenv("PATH", t.TempDir()) agent := agents.NewOpenCodeACP() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("cancelled recovery must not reach agentctl") @@ -176,6 +179,7 @@ func TestManagedRuntimeProbeRecoveryStopsOnCancellation(t *testing.T) { } func TestResolveModelConfigRecoversManagedRuntimeETarget(t *testing.T) { + t.Setenv("PATH", t.TempDir()) const version = "1.18.29" agent := agents.NewOpenCodeACP() log := newTestLogger(t) @@ -254,6 +258,7 @@ func TestResolveModelConfigRecoversManagedRuntimeETarget(t *testing.T) { } func TestManagedRuntimeRepairWaitsForConcurrentProbe(t *testing.T) { + t.Setenv("PATH", t.TempDir()) agent := agents.NewOpenCodeACP() blockerStarted := make(chan struct{}) releaseBlocker := make(chan struct{}) diff --git a/apps/backend/internal/agent/hostutility/managed_runtime_test.go b/apps/backend/internal/agent/hostutility/managed_runtime_test.go index bb4d9f8dde..c1b468550d 100644 --- a/apps/backend/internal/agent/hostutility/managed_runtime_test.go +++ b/apps/backend/internal/agent/hostutility/managed_runtime_test.go @@ -24,6 +24,7 @@ func (r managedRuntimeSelectionReader) Get( } func TestResolveInferenceCommandUsesActiveExactVersion(t *testing.T) { + t.Setenv("PATH", t.TempDir()) manager := &Manager{managedRuntimeSelections: managedRuntimeSelectionReader{ selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, found: true, @@ -41,6 +42,7 @@ func TestResolveInferenceCommandUsesActiveExactVersion(t *testing.T) { } func TestResolveInferenceCommandPreservesLegacyAndReportsSelectionErrors(t *testing.T) { + t.Setenv("PATH", t.TempDir()) agent := agents.NewOpenCodeACP() manager := &Manager{managedRuntimeSelections: managedRuntimeSelectionReader{}} command, err := manager.resolveInferenceCommand(context.Background(), agent.ID(), agent, agents.Command{}) diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 68dcb0af1e..9b1622d58d 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -339,7 +339,7 @@ func (m *Manager) bootstrapAgent(ctx context.Context, ia agents.InferenceAgent) LastCheckedAt: time.Now(), }) - cfg := ia.InferenceConfig() + cfg := inferenceConfigForHostUtility(ia) if cfg == nil || !cfg.Supported { m.cache.set(AgentCapabilities{ AgentType: agentType, @@ -831,7 +831,7 @@ func (m *Manager) resolveInferenceCommand( if !override.IsEmpty() { return override, nil } - cfg := ia.InferenceConfig() + cfg := inferenceConfigForHostUtility(ia) if cfg == nil || !cfg.Supported { return agents.Command{}, errors.New("inference config not available") } @@ -845,6 +845,9 @@ func (m *Manager) resolveInferenceCommand( return command, nil } spec := managed.ManagedNPMRuntime() + if spec.NativeBinaryOnPath() { + return spec.NativeCommand(), nil + } selection, found, err := m.managedRuntimeSelections.Get(ctx, agentType, spec.Package) if err != nil { return agents.Command{}, fmt.Errorf("resolve active managed runtime version for %s: %w", agentType, err) @@ -863,7 +866,7 @@ func buildProbeRequest( refresh bool, command agents.Command, ) *agentctlutil.ProbeRequest { - cfg := ia.InferenceConfig() + cfg := inferenceConfigForHostUtility(ia) probeCommand := cfg.Command if !command.IsEmpty() { probeCommand = command @@ -881,6 +884,13 @@ func buildProbeRequest( } } +func inferenceConfigForHostUtility(ia agents.InferenceAgent) *agents.InferenceConfig { + if hostAgent, ok := ia.(agents.HostUtilityInferenceAgent); ok { + return hostAgent.HostUtilityInferenceConfig() + } + return ia.InferenceConfig() +} + func probeFailureCapabilities( agentType string, status Status, diff --git a/apps/backend/internal/agent/hostutility/manager_test.go b/apps/backend/internal/agent/hostutility/manager_test.go index 54f86c04c3..fbdcccb977 100644 --- a/apps/backend/internal/agent/hostutility/manager_test.go +++ b/apps/backend/internal/agent/hostutility/manager_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strconv" "strings" "sync" @@ -52,6 +53,26 @@ func TestBuildProbeRequestIncludesRuntimeEnv(t *testing.T) { require.Equal(t, "1", decoded.InferenceConfig.Env[envKey]) } +func TestBuildProbeRequestUsesHostUtilityInferenceConfig(t *testing.T) { + dir := t.TempDir() + name := "opencode" + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(dir, name), []byte("native"), 0o755); err != nil { + t.Fatalf("write native executable: %v", err) + } + t.Setenv("PATH", dir) + + req := buildProbeRequest( + &instance{agentType: "opencode-acp", workDir: t.TempDir()}, + agents.NewOpenCodeACP(), + false, + agents.Command{}, + ) + require.Equal(t, []string{"opencode", "acp", "--print-logs", "--log-level", "ERROR"}, req.InferenceConfig.Command) +} + func TestExecutePromptWithMCPIncludesRuntimeEnv(t *testing.T) { const envKey = "HERMES_ACP_SKIP_CONFIGURED_MCP" log := newTestLogger(t) diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index 1cd6591d5e..0c72c9d482 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -64,7 +64,7 @@ func (m *Manager) ExecuteProfilePrompt(ctx context.Context, profileID, prompt st if err != nil { return nil, err } - cfg := ia.InferenceConfig() + cfg := inferenceConfigForHostUtility(ia) command, err := m.resolveInferenceCommand(ctx, profile.AgentID, ia, agents.Command{}) if err != nil { return nil, err @@ -195,7 +195,7 @@ func (m *Manager) resolveModelConfigFlight( if err != nil { return nil, err } - cfg := ia.InferenceConfig() + cfg := inferenceConfigForHostUtility(ia) if cfg == nil || !cfg.Supported { return nil, errors.New("inference config not available") } @@ -341,7 +341,7 @@ func (m *Manager) ExecutePromptWithMCP( if err != nil { return nil, err } - cfg := ia.InferenceConfig() + cfg := inferenceConfigForHostUtility(ia) command, err := m.resolveInferenceCommand(ctx, agentType, ia, agents.Command{}) if err != nil { return nil, err diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index 6db11fd9c0..489ae4d2ac 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -132,6 +132,7 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) { } func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) { + t.Setenv("PATH", t.TempDir()) mgr := newTestManager(t) tests := []struct { name string @@ -162,11 +163,30 @@ func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { want := strings.Join(tt.agent.(agents.ManagedNPMRuntimeAgent).ManagedNPMRuntime().CachedACPCommand().Args(), " ") + if tt.name == "opencode" { + want = strings.Join(tt.agent.(agents.ManagedNPMRuntimeAgent).ManagedNPMRuntime().NativeCommand().Args(), " ") + } cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, tt.agent, true) require.NoError(t, err) require.Equal(t, want, cmds.initial) }) } + + // opencode-acp opts into NativeBinaryAgent: when the lifecycle probe finds + // the standalone binary on PATH the launch uses it directly (the same + // binary-first pattern as CodeNomad), and falls back to the managed npx + // runtime when it is absent (containers, remotes, fresh hosts). + t.Run("opencode-native", func(t *testing.T) { + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, agents.NewOpenCodeACP(), true) + require.NoError(t, err) + require.Equal(t, "opencode acp --print-logs --log-level ERROR", cmds.initial) + }) + t.Run("opencode-npx-fallback", func(t *testing.T) { + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, agents.NewOpenCodeACP(), false) + require.NoError(t, err) + want := strings.Join(agents.NewOpenCodeACP().ManagedNPMRuntime().CachedACPCommand().Args(), " ") + require.Equal(t, want, cmds.initial) + }) } // cliFlagTestAgent is a minimal BuildCommand that produces a stable prefix diff --git a/apps/backend/internal/agent/settings/controller/agent_update.go b/apps/backend/internal/agent/settings/controller/agent_update.go index 2f592b4d7d..c175f44d93 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update.go +++ b/apps/backend/internal/agent/settings/controller/agent_update.go @@ -102,12 +102,12 @@ func (c *Controller) previewAgentUpdate( if err != nil { return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) } - command := spec.CacheUpdateCommand(target).Args() + command := spec.UpdateCommand(target).Args() if !exactCatalogue && targetVersion == "" && !useDefault { // Keep the compatibility preview for embedders that provide only the // legacy latest-version seam. Production uses the catalogue resolver and // always previews an exact package@version command. - command = spec.CacheUpdateCommand().Args() + command = spec.UpdateCommand().Args() } return &dto.AgentUpdatePreviewDTO{ AgentName: name, diff --git a/apps/backend/internal/agent/settings/controller/agent_update_job.go b/apps/backend/internal/agent/settings/controller/agent_update_job.go index 0447881381..29621d2b44 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_job.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_job.go @@ -259,13 +259,17 @@ func (s *AgentUpdateJobStore) run( s.setStatus(job, dto.AgentUpdateJobStatusUpdating) flusher := newUpdateOutputFlusher(s, job) + useNative := spec.NativeBinaryOnPath() prepareCommand := spec.CacheUpdateCommand() if exactTarget { prepareCommand = spec.CacheUpdateCommand(target) } + if useNative { + prepareCommand = spec.NativeUpdateCommand(target) + } err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append) flusher.flush() - if err != nil { + if err != nil && !useNative { flusher.append("managed runtime cache appears stale; repairing execution cache\n") flusher.flush() if repairErr := s.updater.InvalidateExecutionCache(ctx, spec.Package); repairErr != nil { @@ -283,11 +287,7 @@ func (s *AgentUpdateJobStore) run( } s.setStatus(job, dto.AgentUpdateJobStatusRefreshing) - refreshCommand := spec.CachedACPCommand() - if exactTarget { - refreshCommand = spec.ACPCommand(target) - } - caps, refreshErr := s.updater.Refresh(ctx, job.AgentName, refreshCommand) + caps, refreshErr := s.updater.Refresh(ctx, job.AgentName, spec.RefreshCommand(target)) s.finishRefresh(job, ctx, caps, refreshErr, ref) } @@ -346,10 +346,14 @@ func (s *AgentUpdateJobStore) runExactCandidate( ) { s.setStatus(job, dto.AgentUpdateJobStatusUpdating) flusher := newUpdateOutputFlusher(s, job) + useNative := spec.NativeBinaryOnPath() prepareCommand := spec.CacheUpdateCommand(target) + if useNative { + prepareCommand = spec.NativeUpdateCommand(target) + } err := s.updater.RunUpdate(ctx, prepareCommand, flusher.append) flusher.flush() - if err != nil { + if err != nil && !useNative { flusher.append("managed runtime cache appears stale; repairing exact execution cache\n") flusher.flush() var repairErr error @@ -373,7 +377,7 @@ func (s *AgentUpdateJobStore) runExactCandidate( } s.setStatus(job, dto.AgentUpdateJobStatusRefreshing) - caps, probeErr := candidate.Probe(ctx, job.AgentName, spec.ACPCommand(target)) + caps, probeErr := candidate.Probe(ctx, job.AgentName, spec.RefreshCommand(target)) if probeErr != nil { s.finishFailed(job, ctx, fmt.Errorf("probe runtime candidate: %w", probeErr), ref) return diff --git a/apps/backend/internal/agent/settings/controller/agent_update_test.go b/apps/backend/internal/agent/settings/controller/agent_update_test.go index ef37f14945..f4ce69d9ee 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_test.go @@ -6,6 +6,8 @@ import ( "errors" "os" "path/filepath" + "reflect" + "runtime" "strings" "sync" "testing" @@ -42,6 +44,51 @@ type fakeRuntimeUpdater struct { resolvedPackage string } +func TestAgentUpdateJobUsesNativeInstallAndRefresh(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, testExecutableName("opencode")), []byte("native"), 0o755); err != nil { + t.Fatalf("write native executable: %v", err) + } + t.Setenv("PATH", dir) + + updater := &fakeRuntimeUpdater{ + current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, + currentFound: true, + target: "1.1.0", + refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.1.0"}, + } + store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil) + spec := agents.ManagedNPMRuntimeSpec{ + Package: "opencode-ai", + ACPArgs: []string{"acp"}, + NativeBinary: "opencode", + } + job, err := store.Enqueue("opencode-acp", spec) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + + updater.mu.Lock() + defer updater.mu.Unlock() + if got, want := updater.runCommand, []string{"npm", "install", "-g", "opencode-ai@1.1.0"}; !reflect.DeepEqual(got, want) { + t.Fatalf("native update command = %#v, want %#v", got, want) + } + if got, want := updater.refreshCommand, []string{"opencode", "acp"}; !reflect.DeepEqual(got, want) { + t.Fatalf("native refresh command = %#v, want %#v", got, want) + } + if updater.invalidateCalls != 0 { + t.Fatalf("native update invalidated npm cache %d times, want 0", updater.invalidateCalls) + } +} + +func testExecutableName(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + type sequencedVersionUpdater struct { fakeRuntimeUpdater metadata RuntimeVersionMetadata @@ -553,7 +600,7 @@ func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) { if got := strings.Join(updater.runCommand, " "); got != wantUpdate { t.Fatalf("update command = %q, want %q", got, wantUpdate) } - wantRefresh := "npx --yes --prefer-offline @example/managed-acp --acp" + wantRefresh := "npx --yes --prefer-offline @example/managed-acp@1.1.0 --acp" if got := strings.Join(updater.refreshCommand, " "); got != wantRefresh { t.Fatalf("refresh command = %q, want %q", got, wantRefresh) } diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 886ebc47b8..3b4fabff84 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -174,6 +174,7 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { } func TestController_PreviewAgentCommandUsesActiveManagedRuntimeVersion(t *testing.T) { + t.Setenv("PATH", t.TempDir()) agent := agents.NewOpenCodeACP() controller := newTestController(map[string]agents.Agent{agent.ID(): agent}) selectionStore := newRecoverySelectionStore() diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 0775967ee4..5238658e8a 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -563,7 +563,7 @@ type DebugConfig struct { // The Standalone runtime (agentctl) always runs as a core service. // Docker runtime is available when docker.enabled=true. type AgentConfig struct { - // StandaloneHost is the host where standalone agentctl is running (default: localhost) + // StandaloneHost is the host where standalone agentctl is running (default: 127.0.0.1) StandaloneHost string `mapstructure:"standaloneHost"` // StandalonePort is the control port for standalone agentctl (default: 39429) @@ -667,7 +667,17 @@ func setDefaults(v *viper.Viper) { v.SetDefault("docker.volumeBasePath", defaultDockerVolumePath()) // Agent defaults (runtime selection is now per-task based on executor type) - v.SetDefault("agent.standaloneHost", "localhost") + // + // 127.0.0.1 instead of "localhost": localhost resolution can select an IPv6 + // loopback address before an IPv4 address. The explicit IPv4 loopback avoids + // address-resolution variance when host utility health checks run against + // agentctl bound IPv4-only and fail with + // "dial tcp [::1]:41001: connect: connection refused" whenever agentctl is + // bound IPv4-only (e.g. auth-disabled loopback binds), spinning the + // "host utility instance unhealthy; recreating" loop. The loopback + // address is explicit and unambiguous; operators with a non-loopback + // control plane can still override via KANDEV_AGENT_STANDALONE_HOST. + v.SetDefault("agent.standaloneHost", "127.0.0.1") v.SetDefault("agent.standalonePort", ports.AgentCtl) // Auth defaults. auth.cookieName defaults to empty on purpose: the auth diff --git a/apps/web/e2e/tests/kubernetes/kubernetes-failure-recovery.spec.ts b/apps/web/e2e/tests/kubernetes/kubernetes-failure-recovery.spec.ts index 560d234fa4..cbbd60adc3 100644 --- a/apps/web/e2e/tests/kubernetes/kubernetes-failure-recovery.spec.ts +++ b/apps/web/e2e/tests/kubernetes/kubernetes-failure-recovery.spec.ts @@ -88,7 +88,10 @@ for (const restart of [false, true]) { await session.waitForLoad(); await expect(session.recoveryResumeButton()).toBeVisible({ timeout: 30_000 }); await session.recoveryResumeButton().click({ timeout: 30_000 }); - await session.waitForChatIdle({ timeout: 90_000, requireEditable: true }); + const editor = session.activeChat().getByTestId("chat-input-editor"); + await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 90_000 }); + await expect(session.submitButton()).toBeEnabled({ timeout: 90_000 }); + await expect(session.recoveryResumeButton()).toHaveCount(0); await waitForTaskSessionState(apiClient, task.id, sessionId, "WAITING_FOR_INPUT", 90_000); const reply = restart ? "recovered-after-restart" : "recovered-after-failure"; await apiClient.addUserMessage(task.id, sessionId, `e2e:message("${reply}")`); diff --git a/apps/web/e2e/tests/settings/host-utility-managed-runtime-recovery.spec.ts b/apps/web/e2e/tests/settings/host-utility-managed-runtime-recovery.spec.ts index 7856081d61..19753d03dc 100644 --- a/apps/web/e2e/tests/settings/host-utility-managed-runtime-recovery.spec.ts +++ b/apps/web/e2e/tests/settings/host-utility-managed-runtime-recovery.spec.ts @@ -7,34 +7,37 @@ import { KanbanPage } from "../../pages/kanban-page"; const AGENT_NAME = "opencode-acp"; const PROFILE_NAME = "Recovered OpenCode profile"; -test.describe("host utility managed runtime recovery", () => { - test("recovers the model catalogue before task creation", async ({ +test.describe("host utility native runtime", () => { + test("uses the native binary for the model catalogue before task creation", async ({ apiClient, backend, testPage, }) => { - test.skip(process.platform === "win32", "the managed npm fixture uses a POSIX launcher"); + test.skip(process.platform === "win32", "the native fixture uses a POSIX npx sentinel"); test.setTimeout(120_000); - const wrapperSource = path.resolve(__dirname, "../../fixtures/managed-runtime-npx.sh"); - const wrapperPath = path.join(backend.tmpDir, "bin", "npx"); + const npxPath = path.join(backend.tmpDir, "bin", "npx"); const discoveryPath = path.join(backend.tmpDir, "bin", "opencode"); const cacheRoot = path.join(backend.tmpDir, "managed-npm-cache"); const mockAgentPath = path.resolve(__dirname, "../../../../backend/bin/mock-agent"); - fs.copyFileSync(wrapperSource, wrapperPath); - fs.chmodSync(wrapperPath, 0o755); - fs.writeFileSync(discoveryPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const npxInvocationPath = path.join(cacheRoot, "npx-invocations"); + fs.writeFileSync( + npxPath, + `#!/bin/sh\nprintf 'unexpected npx invocation\\n' >> '${npxInvocationPath}'\nexit 1\n`, + { mode: 0o755 }, + ); + fs.copyFileSync(mockAgentPath, discoveryPath); + fs.chmodSync(discoveryPath, 0o755); const runtimeEnv = { KANDEV_MOCK_AGENT: "true", KANDEV_E2E_MOCK_AGENT_PATH: mockAgentPath, - KANDEV_E2E_NPX_MOCK_OTHERS: "true", NPM_CONFIG_CACHE: cacheRoot, }; let releaseEnv: (() => Promise) | undefined; let profileId = ""; try { - await backend.restart({ ...runtimeEnv, KANDEV_E2E_NPX_BYPASS_FAILURE: "true" }); + await backend.restart(runtimeEnv); const { agents: persistedAgents } = await apiClient.listAgents(); const persistedAgent = persistedAgents.find((candidate) => candidate.name === AGENT_NAME); expect(persistedAgent).toBeDefined(); @@ -64,23 +67,15 @@ test.describe("host utility managed runtime recovery", () => { }, { timeout: 60_000, - message: "OpenCode host capabilities should recover before task creation", + message: "OpenCode host capabilities should load before task creation", }, ) .toBe("ok:true:true:"); const target = path.join(cacheRoot, "_npx", managedRuntimeExecutionCacheKey(packageSpec)); expect(fs.existsSync(path.join(target, "stale-marker"))).toBe(false); - expect(fs.readFileSync(path.join(target, "fresh-marker"), "utf8")).toBe("fresh\n"); - expect( - fs.readFileSync(path.join(cacheRoot, "_npx", "0123456789abcdef", "sibling-marker"), "utf8"), - ).toBe("sibling\n"); - expect(fs.readFileSync(path.join(cacheRoot, "offline-invocations"), "utf8").trim()).toBe( - packageSpec, - ); - expect(fs.readFileSync(path.join(cacheRoot, "online-invocations"), "utf8").trim()).toBe( - packageSpec, - ); + expect(fs.existsSync(path.join(target, "fresh-marker"))).toBe(false); + expect(fs.existsSync(npxInvocationPath)).toBe(false); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -101,7 +96,7 @@ test.describe("host utility managed runtime recovery", () => { expect(stored.model).toBe("mock-fast"); } finally { if (profileId) await apiClient.deleteAgentProfile(profileId, true).catch(() => undefined); - fs.rmSync(wrapperPath, { force: true }); + fs.rmSync(npxPath, { force: true }); fs.rmSync(discoveryPath, { force: true }); if (releaseEnv) await releaseEnv(); } diff --git a/docs/configuration.md b/docs/configuration.md index 4346221458..63eea12716 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,7 +88,7 @@ docker: volumeBasePath: "" # empty = /var/lib/kandev/volumes (Linux/macOS) agent: - standaloneHost: "localhost" + standaloneHost: "127.0.0.1" standalonePort: 39429 # agentctl control port auth: diff --git a/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md b/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md index b125865965..27dcd3a3c3 100644 --- a/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md +++ b/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md @@ -42,6 +42,12 @@ containers use their own unversioned runtime resolution when they launch. Native-only distribution channels and separately distributed passthrough or authentication helper packages remain outside this boundary. +When a managed package also provides a declared native executable, the host +update path uses one runtime consistently. For OpenCode, a host `opencode` +executable uses the selected global `opencode-ai` package version for update +and the same executable for the capability probe. Executor launches keep their +executor-safe managed command unless that executor selects a native binary. + ## Consequences Operators can opt into newly released models without waiting for a Kandev diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 9e676f481c..450ded61e2 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -123,6 +123,13 @@ the control has no dot but remains usable. 4. Select **Update runtime**, **Roll back runtime**, or **Repair runtime**. 5. Wait for the exact version to prepare and pass its ACP capability probe. +OpenCode has one additional host-runtime rule. When the `opencode` executable +is on the Kandev host `PATH`, the update installs the selected `opencode-ai` +package version globally and the follow-up capability probe runs that same +executable. Containers, SSH executors, and hosts without that executable keep +using the managed `npx` runtime. This keeps the update result aligned with the +runtime that Kandev will use for host utility calls. + Kandev enables the action only after the backend validates the selected version against the trusted package catalogue. It does not accept package names, npm tags, prereleases, registry URLs, or command text. When the active version, diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 7a8ca4f62b..018c0bd859 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -147,10 +147,10 @@ The Docker socket is effectively root-equivalent on many hosts. Do not publish i ### Core agent service -| YAML key | Environment variable | Default | Current behavior | -| ---------------------- | ------------------------------------------------- | ----------- | ---------------------------------------------------------------- | -| `agent.standaloneHost` | `KANDEV_AGENT_STANDALONE_HOST` | `localhost` | Host of the core `agentctl` control server. | -| `agent.standalonePort` | `AGENTCTL_PORT` or `KANDEV_AGENT_STANDALONE_PORT` | `39429` | Preferred control port. The launcher may supply a free fallback. | +| YAML key | Environment variable | Default | Current behavior | +| ---------------------- | ------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- | +| `agent.standaloneHost` | `KANDEV_AGENT_STANDALONE_HOST` | `127.0.0.1` | Host of the core `agentctl` control server. The literal avoids IPv6 loopback resolution variance. | +| `agent.standalonePort` | `AGENTCTL_PORT` or `KANDEV_AGENT_STANDALONE_PORT` | `39429` | Preferred control port. The launcher may supply a free fallback. | The launcher starts `agentctl`, performs a one-time nonce handshake, and supplies the resulting per-launch token internally. Do not persist or proxy its bootstrap/auth state. Agent command, model, environment, permission, and MCP configuration belongs in agent profiles rather than this section. @@ -453,7 +453,7 @@ docker: volumeBasePath: "/var/lib/kandev/volumes" # compatibility-only today agent: - standaloneHost: "localhost" + standaloneHost: "127.0.0.1" standalonePort: 39429 tasks: