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:
committed by
GitHub
parent
cd08c50ca6
commit
65f27ecbff
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user