fix(agents): launch opencode-acp binary-first (native binary on PATH), don't break on stale npm cache (#3836)

Co-authored-by: goose <goose@aaif.dev>
Co-authored-by: Benjamin <benjaminchamwb@gmail.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
Co-authored-by: João Salavisa <joao.salavisa@gmail.com>
This commit is contained in:
Jelloeater's Agent
2026-09-21 18:20:43 -04:00
committed by GitHub
parent cd08c50ca6
commit 65f27ecbff
22 changed files with 403 additions and 62 deletions
@@ -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.
@@ -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 ""
@@ -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)
}
}
@@ -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,
}
}
@@ -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
@@ -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{})
@@ -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{})
@@ -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,
@@ -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)
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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)
}
@@ -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()
+12 -2
View File
@@ -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
@@ -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}")`);
@@ -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<void>) | 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();
}
+1 -1
View File
@@ -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:
@@ -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
+7
View File
@@ -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,
+5 -5
View File
@@ -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: