fix: isolate managed npm config and report policy failures (#3902) (#3907)

This commit is contained in:
Carlos Florêncio
2026-09-24 19:08:13 +01:00
committed by GitHub
parent 8796d6948f
commit 200af92fbe
59 changed files with 2116 additions and 125 deletions
@@ -18,9 +18,13 @@ package/default generation resets it during startup.
| Muse | `@bex-co/muse-code-acp` | none |
Normal capability probes, sessions, container commands, and one-shot inference
use `npx --yes --prefer-offline package@<effective-version>` with the ACP
arguments above. For example, an unmodified Claude installation launches
`npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp@<effective-version>`.
use `npx --yes --prefer-offline --prefix ~/.kandev/managed-npm-runtime
package@<effective-version>` with the ACP arguments above. The prefix directory
is a canonical marker that the execution host replaces with a private,
user-scoped directory under its system temporary root before npm starts. It
stays outside the task workspace and mounted agent home. The agent process
still runs in the task workspace, but its project `.npmrc` does not control
managed runtime package resolution.
The `<effective-version>` placeholder resolves at launch to the exact Kandev
default or the exact operator selection. OpenCode's error-only log flags
are part of its managed command so agentctl can observe terminal provider
@@ -84,7 +84,9 @@ func (s ManagedNPMRuntimeSpec) ACPCommandWithNpmPreference(version string, prefe
if preferOnline {
preference = "--prefer-online"
}
args := []string{"npx", "--yes", preference, s.PackageSpec(version)}
args := []string{"npx", "--yes", preference}
args = append(args, managedruntime.NPMProjectPrefixArgs()...)
args = append(args, s.PackageSpec(version))
args = append(args, s.ACPArgs...)
return NewCommand(args...)
}
@@ -100,6 +102,8 @@ func (s ManagedNPMRuntimeSpec) CacheUpdateCommand(versions ...string) Command {
packageSpec := s.PackageSpec(firstVersion(versions))
return NewCommand(
"npm",
"--prefix",
managedruntime.NPMProjectPrefix,
"exec",
"--yes",
"--prefer-online",
@@ -2,6 +2,7 @@ package agents
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
@@ -46,7 +47,7 @@ func TestManagedNPMRuntimeContracts(t *testing.T) {
}
cached := spec.CachedACPCommand()
wantCached := append([]string{"npx", "--yes", "--prefer-offline", tt.wantPackage + "@" + wantDefault}, tt.wantACPArgs...)
wantCached := append([]string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", tt.wantPackage + "@" + wantDefault}, tt.wantACPArgs...)
if !slices.Equal(cached.Args(), wantCached) {
t.Fatalf("CachedACPCommand = %#v, want %#v", cached.Args(), wantCached)
}
@@ -56,7 +57,7 @@ func TestManagedNPMRuntimeContracts(t *testing.T) {
update := spec.CacheUpdateCommand()
wantUpdate := []string{
"npm", "exec", "--yes", "--prefer-online",
"npm", "--prefix", "~/.kandev/managed-npm-runtime", "exec", "--yes", "--prefer-online",
"--package=" + tt.wantPackage + "@" + wantDefault, "--", "node", "-e", "",
}
if !slices.Equal(update.Args(), wantUpdate) {
@@ -101,12 +102,12 @@ func TestManagedNPMRuntimeBuildsExactVersionCommandsAndCacheKey(t *testing.T) {
Package: "opencode-ai",
ACPArgs: []string{"acp", "--print-logs"},
}
wantACP := []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.18.5", "acp", "--print-logs"}
wantACP := []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "opencode-ai@1.18.5", "acp", "--print-logs"}
if got := spec.ACPCommand("1.18.5").Args(); !slices.Equal(got, wantACP) {
t.Fatalf("ACPCommand = %#v, want %#v", got, wantACP)
}
wantUpdate := []string{
"npm", "exec", "--yes", "--prefer-online",
"npm", "--prefix", "~/.kandev/managed-npm-runtime", "exec", "--yes", "--prefer-online",
"--package=opencode-ai@1.18.5", "--", "node", "-e", "",
}
if got := spec.CacheUpdateCommand("1.18.5").Args(); !slices.Equal(got, wantUpdate) {
@@ -124,7 +125,7 @@ func TestManagedNPMRuntimeOnlineCommandChangesOnlyNpmFreshnessFlag(t *testing.T)
}
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"}
want := []string{"npx", "--yes", "--prefer-online", "--prefix", "~/.kandev/managed-npm-runtime", "@scope/managed-acp@1.2.3", "--acp", "--model", "fast"}
if !reflect.DeepEqual(online, want) {
t.Fatalf("online argv = %#v, want %#v", online, want)
}
@@ -136,7 +137,7 @@ func TestManagedNPMRuntimeOnlineCommandChangesOnlyNpmFreshnessFlag(t *testing.T)
func TestManagedNPMRuntimeExactVersionSupportsScopedPackages(t *testing.T) {
spec := ManagedNPMRuntimeSpec{Package: "@scope/managed-acp", ACPArgs: []string{"--acp"}}
want := []string{"npx", "--yes", "--prefer-offline", "@scope/managed-acp@3.4.5", "--acp"}
want := []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "@scope/managed-acp@3.4.5", "--acp"}
if got := spec.ACPCommand("3.4.5").Args(); !slices.Equal(got, want) {
t.Fatalf("scoped ACPCommand = %#v, want %#v", got, want)
}
@@ -184,7 +185,7 @@ func TestManagedNPMRuntimeNativeBinaryPreference(t *testing.T) {
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"}
wantNpx := []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "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)
}
@@ -213,10 +214,79 @@ func TestManagedNPMRuntimeNativeBinaryPreference(t *testing.T) {
}
plain := ManagedNPMRuntimeSpec{Package: "@scope/custom", ACPArgs: []string{"acp"}}
if got := plain.RefreshCommand().Args(); !slices.Equal(got, []string{"npx", "--yes", "--prefer-offline", "@scope/custom", "acp"}) {
if got := plain.RefreshCommand().Args(); !slices.Equal(got, []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "@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)
}
}
func TestManagedNPMRuntimeLaunchIgnoresWorkspaceNpmrc(t *testing.T) {
npmPath, err := exec.LookPath("npm")
if err != nil {
t.Skip("npm is not installed")
}
home := t.TempDir()
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, ".npmrc"), []byte("min-release-age=87600\nregistry=https://registry.invalid/\n"), 0o600); err != nil {
t.Fatalf("write workspace npmrc: %v", err)
}
readConfig := func(args ...string) string {
t.Helper()
cmd := exec.Command(npmPath, args...)
cmd.Dir = workspace
cmd.Env = envWithHome(os.Environ(), home)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("npm %v: %v (%s)", args, err, output)
}
return strings.TrimSpace(string(output))
}
if got := readConfig("config", "get", "registry"); got != "https://registry.invalid/" {
t.Fatalf("workspace registry = %q, want configured registry", got)
}
workspaceReleaseAge := readConfig("config", "get", "min-release-age")
args := NewOpenCodeACP().ManagedNPMRuntime().ACPCommand("1.18.5").Args()
if !slices.Contains(args, "--prefix") {
t.Fatalf("managed launch args = %#v, missing isolated npm prefix", args)
}
prefixIndex := slices.Index(args, "--prefix")
if prefixIndex < 0 || prefixIndex+1 >= len(args) || args[prefixIndex+1] != "~/.kandev/managed-npm-runtime" {
t.Fatalf("managed launch prefix = %#v, want executor-local home prefix", args)
}
isolatedArgs := append([]string(nil), args[prefixIndex:prefixIndex+2]...)
if err := managedruntime.PrepareNPMProjectPrefix(isolatedArgs); err != nil {
t.Fatalf("prepare managed npm prefix: %v", err)
}
prefix := isolatedArgs[1]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
}
isolatedArgs = append(isolatedArgs, "config", "get", "min-release-age")
if got := readConfig(isolatedArgs...); workspaceReleaseAge == "87600" && got == workspaceReleaseAge {
t.Fatalf("managed npm still reads workspace min-release-age: %q", got)
}
isolatedArgs[len(isolatedArgs)-1] = "registry"
if got := readConfig(isolatedArgs...); got == "https://registry.invalid/" {
t.Fatalf("managed npm still reads workspace registry: %q", got)
}
if got := args[prefixIndex+2]; got != "opencode-ai@1.18.5" {
t.Fatalf("managed package spec shifted or changed: %q", got)
}
}
func envWithHome(env []string, home string) []string {
filtered := make([]string, 0, len(env)+1)
for _, item := range env {
if !strings.HasPrefix(item, "HOME=") {
filtered = append(filtered, item)
}
}
return append(filtered, "HOME="+home)
}
@@ -81,8 +81,8 @@ var newACPAgentSpecs = []struct {
}},
{func() Agent { return NewPiACP() }, acpAgentSpec{
id: "pi-acp", displayName: "Pi", detectBinaries: []string{"pi"},
expectedArgv: []string{"npx", "--yes", "--prefer-offline", "pi-acp@0.0.33"},
inferenceArgv: []string{"npx", "--yes", "--prefer-offline", "pi-acp@0.0.33"},
expectedArgv: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "pi-acp@0.0.33"},
inferenceArgv: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "pi-acp@0.0.33"},
passthroughArgv: []string{"pi"},
installViaNpm: true,
installScript: "npm install -g --ignore-scripts @earendil-works/pi-coding-agent",
@@ -173,8 +173,8 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
}},
{func() Agent { return NewMuseACP() }, acpAgentSpec{
id: "muse-acp", displayName: "Muse", detectBinaries: []string{"muse"},
expectedArgv: []string{"npx", "--yes", "--prefer-offline", "@bex-co/muse-code-acp@0.6.1"},
inferenceArgv: []string{"npx", "--yes", "--prefer-offline", "@bex-co/muse-code-acp@0.6.1"},
expectedArgv: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "@bex-co/muse-code-acp@0.6.1"},
inferenceArgv: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "@bex-co/muse-code-acp@0.6.1"},
passthroughArgv: []string{"muse"},
installViaNpm: false,
skipInstallBinaryCheck: true,
@@ -138,6 +138,45 @@ func TestManagerProbeDoesNotRetryManagedRuntimeRecoveryTwice(t *testing.T) {
}
}
func TestManagedRuntimeReleaseAgePolicySkipsCacheRepair(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) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/inference/probe":
probes++
_ = json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{
Success: false,
Error: "ACP initialize failed",
FailureCode: agentctlutil.ProbeFailureCode("managed_runtime_npm_policy"),
})
case "/api/v1/agent/managed-runtime/cache-repair":
repairs++
_ = json.NewEncoder(w).Encode(agentctlclient.RepairManagedRuntimeCacheResponse{Success: true})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
host, port := serverHostPort(t, server)
manager := &Manager{log: newTestLogger(t)}
inst := &instance{
agentType: agent.ID(),
workDir: t.TempDir(),
client: agentctlclient.NewClient(host, port, manager.log),
}
caps := manager.probe(context.Background(), inst, agent, true)
if caps.Status != StatusFailed {
t.Fatalf("probe status = %q, want %q", caps.Status, StatusFailed)
}
if probes != 1 || repairs != 0 {
t.Fatalf("attempts = (%d probes, %d repairs), want (1, 0)", probes, repairs)
}
}
// @covers AC-AGENTS-MANAGED-RUNTIME-RECOVERY-001.6
func TestManagedRuntimeProbeRecoveryStopsOnCancellation(t *testing.T) {
t.Setenv("PATH", t.TempDir())
@@ -754,10 +754,11 @@ func managedRuntimeProbeRetry(
spec agents.ManagedNPMRuntimeSpec,
) (agents.Command, string, bool) {
args := command.Args()
if len(args) < 4 || args[0] != "npx" || args[1] != "--yes" || args[2] != "--prefer-offline" {
if len(args) < 6 || args[0] != "npx" || args[1] != "--yes" || args[2] != "--prefer-offline" ||
args[3] != "--prefix" || args[4] != managedruntime.NPMProjectPrefix {
return agents.Command{}, "", false
}
packageSpec := args[3]
packageSpec := args[5]
if err := managedruntime.ValidateExactPackageSpec(packageSpec); err != nil {
return agents.Command{}, "", false
}
@@ -0,0 +1,73 @@
package managedruntime
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"os/user"
"path/filepath"
"strings"
)
// NPMProjectPrefix is the trusted command marker replaced with an isolated
// system-temporary project root before a managed npm process starts.
const NPMProjectPrefix = "~/.kandev/managed-npm-runtime"
// NPMProjectPrefixArgs returns the canonical npm project root arguments used
// by every managed runtime command. PrepareNPMProjectPrefix resolves the
// marker on the execution host before the command starts.
func NPMProjectPrefixArgs() []string {
return []string{"--prefix", NPMProjectPrefix}
}
// PrepareNPMProjectPrefix replaces the trusted marker with a private directory
// under the execution host's system temp root. The home directory can itself
// be a bind-mounted agent session, so managed npm project state must stay out
// of it as well as out of the task workspace.
func PrepareNPMProjectPrefix(args []string) error {
index := npmProjectPrefixArgumentIndex(args)
if index < 0 {
return nil
}
prefix, err := npmProjectPrefixPath()
if err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
if err := os.MkdirAll(prefix, 0o700); err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
info, err := os.Lstat(prefix)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("managed npm project prefix could not be prepared")
}
if err := os.Chmod(prefix, 0o700); err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
args[index+1] = prefix
return nil
}
func npmProjectPrefixArgumentIndex(args []string) int {
for index := 0; index+1 < len(args); index++ {
if args[index] == "--prefix" && args[index+1] == NPMProjectPrefix {
return index
}
}
return -1
}
func npmProjectPrefixPath() (string, error) {
tempRoot := os.TempDir()
if strings.TrimSpace(tempRoot) == "" || !filepath.IsAbs(tempRoot) {
return "", errors.New("system temporary root is unavailable")
}
current, err := user.Current()
if err != nil || current == nil || strings.TrimSpace(current.Uid) == "" {
return "", errors.New("execution user is unavailable")
}
userHash := sha256.Sum256([]byte(current.Uid))
scope := hex.EncodeToString(userHash[:8])
return filepath.Join(tempRoot, "kandev-managed-npm-runtime-"+scope), nil
}
@@ -0,0 +1,47 @@
package managedruntime
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestPrepareNPMProjectPrefixUsesPrivateTemporaryRoot(t *testing.T) {
tempRoot := t.TempDir()
if runtime.GOOS == "windows" {
t.Setenv("TEMP", tempRoot)
t.Setenv("TMP", tempRoot)
} else {
t.Setenv("TMPDIR", tempRoot)
}
agentHome := filepath.Join(t.TempDir(), "mounted-agent-home")
if err := os.MkdirAll(agentHome, 0o700); err != nil {
t.Fatalf("create mounted agent home: %v", err)
}
args := append([]string{"npx"}, NPMProjectPrefixArgs()...)
if err := PrepareNPMProjectPrefix(args); err != nil {
t.Fatalf("prepare npm project prefix: %v", err)
}
got := args[2]
if !filepath.IsAbs(got) || !strings.HasPrefix(got, filepath.Clean(tempRoot)+string(filepath.Separator)) {
t.Fatalf("npm project prefix = %q, want absolute path under system temp root %q", got, tempRoot)
}
info, err := os.Stat(got)
if err != nil || !info.IsDir() {
t.Fatalf("npm project prefix stat = (%v, %v), want an existing directory", info, err)
}
if runtime.GOOS != "windows" {
if gotMode := info.Mode().Perm(); gotMode != 0o700 {
t.Fatalf("npm project prefix permissions = %#o, want %#o", gotMode, 0o700)
}
}
legacyHomePrefix := filepath.Join(agentHome, ".kandev", "managed-npm-runtime")
if _, err := os.Stat(legacyHomePrefix); !os.IsNotExist(err) {
t.Fatalf("npm project prefix was created under mounted agent home, stat error = %v", err)
}
}
@@ -142,7 +142,10 @@ func TestResolveProviderCommandUsesActiveManagedRuntimeVersion(t *testing.T) {
if !ok {
t.Fatal("resolveProviderCommand returned !ok")
}
if got := args[3]; got != "opencode-ai@1.18.5" {
if len(args) < 6 || args[3] != "--prefix" || args[4] != managedruntime.NPMProjectPrefix {
t.Fatalf("command args = %#v, want managed npm project prefix", args)
}
if got := args[5]; got != "opencode-ai@1.18.5" {
t.Fatalf("package arg = %q, want exact active version", got)
}
if agent.opts.Runtime != agentruntime.RuntimeStandalone {
@@ -171,7 +171,7 @@ func TestRemotePreflightUsesResolvedManagedRuntimeVersion(t *testing.T) {
ManagedRuntimeVersion: "1.18.5",
}
got := buildRemotePreflightAgentCommand(req).Args()
want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.18.5", "acp", "--print-logs", "--log-level", "ERROR"}
want := []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "opencode-ai@1.18.5", "acp", "--print-logs", "--log-level", "ERROR"}
if !strings.EqualFold(strings.Join(got, " "), strings.Join(want, " ")) {
t.Fatalf("remote preflight command = %#v, want %#v", got, want)
}
@@ -18,14 +18,15 @@ func onlineManagedRuntimeArgs(args []string, spec agents.ManagedNPMRuntimeSpec)
}
for npxIndex, arg := range args {
if arg != "npx" || npxIndex+3 >= len(args) {
if arg != "npx" || npxIndex+5 >= len(args) {
continue
}
if args[npxIndex+1] != "--yes" || args[npxIndex+2] != "--prefer-offline" {
if args[npxIndex+1] != "--yes" || args[npxIndex+2] != "--prefer-offline" ||
args[npxIndex+3] != "--prefix" || args[npxIndex+4] != managedruntime.NPMProjectPrefix || npxIndex+5 >= len(args) {
continue
}
packageSpec := args[npxIndex+3]
packageSpec := args[npxIndex+5]
versionPrefix := packageName + "@"
if !strings.HasPrefix(packageSpec, versionPrefix) {
return nil, "", false
@@ -43,13 +43,15 @@ func (m *Manager) managedRuntimeNpmStartupFailure(
}
evidence := strings.Join(lines, "\n")
combined := strings.TrimSpace(evidence + "\n" + initErr.Error())
if !routingerr.ManagedRuntimeNpmResolutionMatchesPackage(combined, packageSpec) {
if !routingerr.ManagedRuntimeNpmResolutionMatchesPackage(combined, packageSpec) &&
!routingerr.ManagedRuntimeNpmReleaseAgePolicyMatchesPackage(combined, packageSpec) {
return nil
}
return routingerr.Classify(routingerr.Input{
Phase: routingerr.PhaseSessionInit,
ProviderID: execution.AgentID,
Stderr: combined,
Phase: routingerr.PhaseSessionInit,
ProviderID: execution.AgentID,
Stderr: combined,
ManagedRuntimePackageSpec: packageSpec,
})
}
@@ -93,6 +95,7 @@ func (m *Manager) publishManagedRuntimeStartupFailure(
type managedRuntimeStartupRetry struct {
preferOnlineArgs []string
packageSpec string
failureCode routingerr.Code
failureDetails string
}
@@ -122,12 +125,7 @@ func (m *Manager) prepareManagedRuntimeStartupRetry(
initErr error,
agentConfig agents.Agent,
) (*managedRuntimeStartupRetry, bool) {
if execution == nil || !supportsManagedRuntimeCacheRepair(execution.RuntimeName) {
return nil, false
}
client, releaseClient := execution.AcquireAgentCtlClient()
releaseClient()
if client == nil {
if execution == nil {
return nil, false
}
managed, ok := agentConfig.(agents.ManagedNPMRuntimeAgent)
@@ -139,12 +137,25 @@ func (m *Manager) prepareManagedRuntimeStartupRetry(
return nil, false
}
classification := m.managedRuntimeNpmStartupFailure(ctx, execution, initErr, packageSpec)
if classification == nil || classification.Code != routingerr.CodeManagedRuntimeNpmResolution {
if classification == nil {
return nil, false
}
if classification.Code == routingerr.CodeManagedRuntimeNpmPolicy {
return &managedRuntimeStartupRetry{
failureCode: classification.Code,
failureDetails: classification.RawExcerpt,
}, true
}
if classification.Code != routingerr.CodeManagedRuntimeNpmResolution {
return nil, false
}
if !supportsManagedRuntimeCacheRepair(execution.RuntimeName) {
return nil, false
}
return &managedRuntimeStartupRetry{
preferOnlineArgs: preferOnlineArgs,
packageSpec: packageSpec,
failureCode: classification.Code,
failureDetails: classification.RawExcerpt,
}, true
}
@@ -265,12 +276,24 @@ func (m *Manager) retryManagedRuntimeStartup(
if !ok {
return false, initErr
}
failureCode := routingerr.CodeManagedRuntimeNpmResolution
failureDetails := retry.failureDetails
var failureCause error
if err := managedRuntimeRecoveryAborted(ctx, m); err != nil {
return false, err
}
if retry.failureCode == routingerr.CodeManagedRuntimeNpmPolicy {
return true, m.publishManagedRuntimeStartupFailure(
execution,
"managed npm runtime blocked by npm release-date policy",
retry.failureCode,
retry.failureDetails,
initErr,
)
}
failureCode := retry.failureCode
if failureCode == "" {
failureCode = routingerr.CodeManagedRuntimeNpmResolution
}
failureDetails := retry.failureDetails
var failureCause error
retryGeneration, ok := execution.beginStartupRecovery()
if !ok {
return false, initErr
@@ -43,13 +43,13 @@ func TestOnlineManagedRuntimeArgsPreserveTrustedLaunchIdentity(t *testing.T) {
Package: "@scope/managed-acp",
ACPArgs: []string{"--acp", "--model", "fast"},
}
initial := []string{"greywall", "--", "npx", "--yes", "--prefer-offline", "@scope/managed-acp@1.2.3", "--acp", "--model", "fast"}
initial := []string{"greywall", "--", "npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "@scope/managed-acp@1.2.3", "--acp", "--model", "fast"}
got, packageSpec, ok := onlineManagedRuntimeArgs(initial, spec)
if !ok {
t.Fatal("expected managed runtime recovery command")
}
want := []string{"greywall", "--", "npx", "--yes", "--prefer-online", "@scope/managed-acp@1.2.3", "--acp", "--model", "fast"}
want := []string{"greywall", "--", "npx", "--yes", "--prefer-online", "--prefix", "~/.kandev/managed-npm-runtime", "@scope/managed-acp@1.2.3", "--acp", "--model", "fast"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("online args = %#v, want %#v", got, want)
}
@@ -62,8 +62,9 @@ func TestOnlineManagedRuntimeArgsRejectsNonManagedCommands(t *testing.T) {
spec := agents.ManagedNPMRuntimeSpec{Package: "managed-acp"}
for _, args := range [][]string{
{"native-agent", "--acp"},
{"npx", "--yes", "--prefer-offline", "other-agent@1.2.3"},
{"npx", "--yes", "--prefer-online", "managed-acp@1.2.3"},
{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "other-agent@1.2.3"},
{"npx", "--yes", "--prefer-online", "--prefix", "~/.kandev/managed-npm-runtime", "managed-acp@1.2.3"},
{"npx", "--yes", "--prefer-offline", "managed-acp@1.2.3"},
} {
if _, _, ok := onlineManagedRuntimeArgs(args, spec); ok {
t.Fatalf("command %#v should not be eligible for managed runtime recovery", args)
@@ -73,7 +74,7 @@ func TestOnlineManagedRuntimeArgsRejectsNonManagedCommands(t *testing.T) {
func TestOnlineManagedRuntimeArgsRejectsUnversionedPackage(t *testing.T) {
spec := agents.ManagedNPMRuntimeSpec{Package: "managed-acp"}
args := []string{"npx", "--yes", "--prefer-offline", "managed-acp", "--acp"}
args := []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "managed-acp", "--acp"}
if _, _, ok := onlineManagedRuntimeArgs(args, spec); ok {
t.Fatal("unversioned managed runtime command should not be eligible")
@@ -154,7 +155,7 @@ func newManagedRuntimeRetryFixture(t *testing.T, failSessionNew bool) (*Manager,
AgentID: agentConfig.ID(),
RuntimeName: agentruntime.RuntimeStandalone,
AgentCommand: "npx",
AgentArgs: []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.2.3", "acp", "--print-logs", "--log-level", "ERROR"},
AgentArgs: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "opencode-ai@1.2.3", "acp", "--print-logs", "--log-level", "ERROR"},
WorkspacePath: "/workspace",
Status: v1.AgentStatusStarting,
agentctl: client,
@@ -167,6 +168,96 @@ func newManagedRuntimeRetryFixture(t *testing.T, failSessionNew bool) (*Manager,
return mgr, execution, mock, agentConfig
}
func TestManagedRuntimeReleaseAgePolicySkipsCacheRepair(t *testing.T) {
initialErr := errors.New("ACP session initialization failed")
mgr, execution, mock, _ := newManagedRuntimeRetryFixture(t, false)
agentConfig := agents.NewClaudeACP()
execution.AgentID = agentConfig.ID()
execution.AgentArgs = []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@agentclientprotocol/claude-agent-acp@0.81.0", "acp",
}
mock.stderrLines = []string{
"npm error code ETARGET",
"npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM.",
}
attempted, err := mgr.retryManagedRuntimeStartup(
context.Background(), execution, initialErr, agentConfig, "", "", nil, nil,
)
if !attempted {
t.Fatal("policy failure should be handled as a managed runtime failure")
}
var startupErr *routingerr.ManagedRuntimeStartupError
if !errors.As(err, &startupErr) {
t.Fatalf("error = %v, want structured policy startup failure", err)
}
if got, want := startupErr.Code, routingerr.Code("managed_runtime_npm_policy"); got != want {
t.Fatalf("failure code = %q, want %q", got, want)
}
if !strings.Contains(startupErr.Details, "<release-date>") {
t.Fatalf("failure details = %q, want sanitized release-date marker", startupErr.Details)
}
if got := mock.getHTTPActions(); len(got) != 0 {
t.Fatalf("policy failure triggered repair or restart actions: %#v", got)
}
if execution.FailureCode != string(startupErr.Code) {
t.Fatalf("persisted failure code = %q, want %q", execution.FailureCode, startupErr.Code)
}
}
func TestManagedRuntimeReleaseAgePolicyIsClassifiedWithoutRepairSupport(t *testing.T) {
initialErr := errors.New("ACP session initialization failed")
for _, runtime := range []agentruntime.Runtime{agentruntime.RuntimeKubernetes, agentruntime.RuntimeSprites} {
t.Run(string(runtime), func(t *testing.T) {
mgr, execution, mock, _ := newManagedRuntimeRetryFixture(t, false)
agentConfig := agents.NewClaudeACP()
execution.AgentID = agentConfig.ID()
execution.RuntimeName = runtime
execution.AgentArgs = []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@agentclientprotocol/claude-agent-acp@0.81.0", "acp",
}
mock.stderrLines = []string{
"npm error code ETARGET",
"npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM.",
}
attempted, err := mgr.retryManagedRuntimeStartup(
context.Background(), execution, initialErr, agentConfig, "", "", nil, nil,
)
if !attempted {
t.Fatal("policy failure should be classified without cache-repair support")
}
var startupErr *routingerr.ManagedRuntimeStartupError
if !errors.As(err, &startupErr) || startupErr.Code != routingerr.CodeManagedRuntimeNpmPolicy {
t.Fatalf("error = %v, want structured npm policy startup failure", err)
}
if actions := mock.getHTTPActions(); len(actions) != 0 {
t.Fatalf("unsupported runtime policy failure called repair endpoint: %#v", actions)
}
genericMgr, genericExecution, genericMock, _ := newManagedRuntimeRetryFixture(t, false)
genericExecution.AgentID = agentConfig.ID()
genericExecution.RuntimeName = runtime
genericExecution.AgentArgs = append([]string(nil), execution.AgentArgs...)
genericMock.stderrLines = []string{
"npm error code ETARGET",
"npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0.",
}
attempted, err = genericMgr.retryManagedRuntimeStartup(
context.Background(), genericExecution, initialErr, agentConfig, "", "", nil, nil,
)
if attempted || !errors.Is(err, initialErr) {
t.Fatalf("ordinary resolution failure result = (%v, %v), want original generic error", attempted, err)
}
if actions := genericMock.getHTTPActions(); len(actions) != 0 {
t.Fatalf("unsupported runtime ordinary failure called repair endpoint: %#v", actions)
}
})
}
}
func TestRetryManagedRuntimeStartupLifecycle(t *testing.T) {
initialErr := errors.New("ACP session initialization failed")
@@ -30,6 +30,7 @@ func TestClassForCodeCoversProviderCatalogue(t *testing.T) {
{CodePermissionDeniedByUser, ClassUnclassified},
{CodeNpxCacheCorrupted, ClassUnclassified},
{CodeManagedRuntimeNpmResolution, ClassUnclassified},
{CodeManagedRuntimeNpmPolicy, ClassUnclassified},
{CodeResumeCorrupted, ClassUnclassified},
}
@@ -9,3 +9,23 @@ import "github.com/kandev/kandev/internal/common/npmresolution"
func ManagedRuntimeNpmResolutionMatchesPackage(stderr, packageSpec string) bool {
return npmresolution.MatchesExactPackage(stderr, packageSpec)
}
// ManagedRuntimeNpmReleaseAgePolicyMatchesPackage reports whether bounded
// ETARGET evidence names the exact selected package and carries a valid npm
// date-qualified notarget diagnostic.
func ManagedRuntimeNpmReleaseAgePolicyMatchesPackage(stderr, packageSpec string) bool {
return npmresolution.MatchesReleaseAgePolicy(stderr, packageSpec)
}
func classifyManagedRuntimeNpmPolicy(in Input, rawText string) *Error {
if in.ManagedRuntimePackageSpec == "" ||
!npmresolution.MatchesReleaseAgePolicy(rawText, in.ManagedRuntimePackageSpec) {
return nil
}
return &Error{
Code: CodeManagedRuntimeNpmPolicy,
Confidence: ConfHigh,
ClassifierRule: "npm.etarget.release_age_policy.v1",
RawExcerpt: "npm error code ETARGET\nnpm error notarget The selected managed runtime package was blocked by a date policy: <release-date>",
}
}
@@ -71,6 +71,36 @@ func TestManagedRuntimeNpmResolutionSanitizesDetails(t *testing.T) {
}
}
func TestManagedRuntimeReleaseAgePolicyHasDistinctClassification(t *testing.T) {
const packageSpec = "@agentclientprotocol/claude-agent-acp@0.81.0"
input := "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec + " with a date before 9/22/2026, 12:28:47 PM."
classified := Classify(Input{
Phase: PhaseSessionInit,
Stderr: input,
ManagedRuntimePackageSpec: packageSpec,
})
if got, want := classified.Code, Code("managed_runtime_npm_policy"); got != want {
t.Fatalf("code = %q, want distinct release-age policy code %q", got, want)
}
if !strings.Contains(classified.RawExcerpt, "<release-date>") || strings.Contains(classified.RawExcerpt, "9/22/2026") || strings.Contains(classified.RawExcerpt, packageSpec) {
t.Fatalf("excerpt = %q, want bounded canonical details without raw date or package", classified.RawExcerpt)
}
}
func TestManagedRuntimeReleaseAgePolicyRequiresExactTrustedPackage(t *testing.T) {
input := "npm error code ETARGET\n" +
"npm error notarget No matching version found for dependency@0.81.0 with a date before 9/22/2026, 12:28:47 PM."
classified := Classify(Input{
Phase: PhaseSessionInit,
Stderr: input,
ManagedRuntimePackageSpec: "@agentclientprotocol/claude-agent-acp@0.81.0",
})
if classified.Code == CodeManagedRuntimeNpmPolicy {
t.Fatalf("unrelated package was classified as a managed runtime policy failure: %#v", classified)
}
}
func TestManagedRuntimeNpmResolutionMatchesExactPackage(t *testing.T) {
const packageSpec = "@kandev/agent@1.2.3"
tests := []struct {
@@ -50,6 +50,7 @@ const (
CodePermissionDeniedByUser Code = "permission_denied_by_user"
CodeNpxCacheCorrupted Code = "npx_cache_corrupted"
CodeManagedRuntimeNpmResolution Code = "managed_runtime_npm_resolution"
CodeManagedRuntimeNpmPolicy Code = "managed_runtime_npm_policy"
CodeResumeCorrupted Code = "resume_corrupted"
CodeAgentTransportLost Code = "agent_transport_lost"
)
@@ -152,14 +153,15 @@ func (e *Error) ShouldShortRetry() bool {
// Input is the raw signal bundle adapters pass to Classify.
type Input struct {
Phase Phase
ProviderID string
ExitCode *int
StructuredErr error
HTTPStatus int
ResetHint *time.Time
Stderr string
Stdout string
Phase Phase
ProviderID string
ExitCode *int
StructuredErr error
HTTPStatus int
ResetHint *time.Time
Stderr string
Stdout string
ManagedRuntimePackageSpec string // trusted exact package from the managed runtime command
}
const exitCodeBinaryMissing = 127
@@ -181,6 +183,12 @@ func Classify(in Input) *Error {
e.ResetHint = in.ResetHint
return applyInvariants(e)
}
if e := classifyManagedRuntimeNpmPolicy(in, rawText); e != nil {
e.Phase = in.Phase
e.ExitCode = in.ExitCode
e.ResetHint = in.ResetHint
return applyInvariants(e)
}
if e, ok := matchProviderRules(in.ProviderID, excerpt); ok {
e.Phase = in.Phase
e.ExitCode = in.ExitCode
@@ -339,7 +347,7 @@ func applyInvariants(e *Error) *Error {
case CodeNpxCacheCorrupted:
e.AutoRetryable = true
e.FallbackAllowed = true
case CodeManagedRuntimeNpmResolution:
case CodeManagedRuntimeNpmResolution, CodeManagedRuntimeNpmPolicy:
e.UserAction = true
e.AutoRetryable = false
e.FallbackAllowed = false
@@ -381,8 +381,12 @@ func runDirectCommandOutput(ctx context.Context, command agents.Command) (string
if len(argv) == 0 {
return "", errors.New("runtime update command is empty")
}
env := filteredInstallEnv()
if err := managedruntime.PrepareNPMProjectPrefix(argv); err != nil {
return "", errors.New("managed npm project prefix could not be prepared")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Env = filteredInstallEnv()
cmd.Env = env
output, err := cmd.Output()
if err != nil {
return "", err
@@ -399,7 +403,8 @@ func (u *hostRuntimeUpdater) RunUpdate(
}
func (u *hostRuntimeUpdater) npxCacheRoot(ctx context.Context) (string, error) {
output, err := u.executor.Output(ctx, agents.NewCommand("npm", "config", "get", "cache"))
args := append(managedruntime.NPMProjectPrefixArgs(), "config", "get", "cache")
output, err := u.executor.Output(ctx, agents.NewCommand(append([]string{"npm"}, args...)...))
if err != nil {
return "", fmt.Errorf("resolve npm cache root: %w", err)
}
@@ -471,8 +476,12 @@ func runDirectCommand(ctx context.Context, command agents.Command, onChunk func(
if len(argv) == 0 {
return errors.New("runtime update command is empty")
}
env := filteredInstallEnv()
if err := managedruntime.PrepareNPMProjectPrefix(argv); err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Env = filteredInstallEnv()
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
@@ -14,6 +14,7 @@ import (
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/common/npmresolution"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
@@ -267,9 +268,20 @@ func (s *AgentUpdateJobStore) run(
if useNative {
prepareCommand = spec.NativeUpdateCommand(target)
}
err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append)
flusher.flush()
packageSpec := ""
if !useNative {
if exactTarget {
packageSpec = spec.PackageSpec(target)
} else {
packageSpec = spec.PackageSpec("")
}
}
err = runManagedRuntimeUpdate(ctx, s.updater, prepareCommand, packageSpec, flusher)
if err != nil && !useNative {
if isManagedRuntimeNpmPolicyError(err) {
s.finishFailed(job, ctx, err, ref)
return
}
flusher.append("managed runtime cache appears stale; repairing execution cache\n")
flusher.flush()
if repairErr := s.updater.InvalidateExecutionCache(ctx, spec.Package); repairErr != nil {
@@ -278,8 +290,7 @@ func (s *AgentUpdateJobStore) run(
}
flusher.append("retrying managed runtime update\n")
flusher.flush()
err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append)
flusher.flush()
err = runManagedRuntimeUpdate(ctx, s.updater, prepareCommand, packageSpec, flusher)
if err != nil {
s.finishFailed(job, ctx, fmt.Errorf("update runtime after cache repair: %w", err), ref)
return
@@ -351,9 +362,16 @@ func (s *AgentUpdateJobStore) runExactCandidate(
if useNative {
prepareCommand = spec.NativeUpdateCommand(target)
}
err := s.updater.RunUpdate(ctx, prepareCommand, flusher.append)
flusher.flush()
packageSpec := ""
if !useNative {
packageSpec = spec.PackageSpec(target)
}
err := runManagedRuntimeUpdate(ctx, s.updater, prepareCommand, packageSpec, flusher)
if err != nil && !useNative {
if isManagedRuntimeNpmPolicyError(err) {
s.finishFailed(job, ctx, err, ref)
return
}
flusher.append("managed runtime cache appears stale; repairing exact execution cache\n")
flusher.flush()
var repairErr error
@@ -368,8 +386,7 @@ func (s *AgentUpdateJobStore) runExactCandidate(
}
flusher.append("retrying managed runtime update\n")
flusher.flush()
err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append)
flusher.flush()
err = runManagedRuntimeUpdate(ctx, s.updater, prepareCommand, packageSpec, flusher)
if err != nil {
s.finishFailed(job, ctx, fmt.Errorf("update runtime after cache repair: %w", err), ref)
return
@@ -411,6 +428,45 @@ func (s *AgentUpdateJobStore) runExactCandidate(
s.finishActivated(job, target, ref)
}
const managedRuntimeUpdateDiagnosticLimit = 64 * 1024
type managedRuntimeNpmPolicyError struct {
packageSpec string
}
func (e *managedRuntimeNpmPolicyError) Error() string {
return fmt.Sprintf(
"managed runtime package %s blocked by npm release-age policy (%s)",
e.packageSpec,
npmresolution.ReleaseDateMarker,
)
}
func isManagedRuntimeNpmPolicyError(err error) bool {
var policyErr *managedRuntimeNpmPolicyError
return errors.As(err, &policyErr)
}
func runManagedRuntimeUpdate(
ctx context.Context,
updater RuntimeUpdater,
command agents.Command,
packageSpec string,
flusher *updateOutputFlusher,
) error {
diagnostic := newRingBuffer(managedRuntimeUpdateDiagnosticLimit)
err := updater.RunUpdate(ctx, command, func(chunk string) {
_, _ = diagnostic.Write([]byte(chunk))
flusher.append(chunk)
})
flusher.flush()
if err != nil && packageSpec != "" &&
npmresolution.MatchesRawReleaseAgePolicy(diagnostic.String(), packageSpec) {
return &managedRuntimeNpmPolicyError{packageSpec: packageSpec}
}
return err
}
func (s *AgentUpdateJobStore) setStatus(job *AgentUpdateJob, status dto.AgentUpdateJobStatus) {
s.mu.Lock()
job.Status = status
@@ -88,6 +88,7 @@ type recoveryRuntimeUpdater struct {
probeCaps hostutility.AgentCapabilities
probeErr error
runErrs []error
updateOutput string
runCalls int
prepare []string
probe []string
@@ -125,8 +126,12 @@ func (u *recoveryRuntimeUpdater) RunUpdate(
if index < len(u.runErrs) {
err = u.runErrs[index]
}
output := u.updateOutput
u.mu.Unlock()
onChunk("prepared\n")
if output == "" {
output = "prepared\n"
}
onChunk(output)
return err
}
@@ -314,6 +319,52 @@ func TestAgentUpdateExactTargetSkipsMetadataRefetch(t *testing.T) {
}
}
func TestAgentUpdateExactCandidateNpmReleaseAgePolicySkipsCacheRepair(t *testing.T) {
const packageName = "@agentclientprotocol/claude-agent-acp"
selectionStore := newRecoverySelectionStore()
selectionStore.values["claude-acp\x00"+packageName] = managedruntime.Selection{
Package: packageName,
Version: "0.80.0",
}
updater := &recoveryRuntimeUpdater{
metadata: RuntimeVersionMetadata{
Versions: []string{"0.80.0", "0.81.0"},
Latest: "0.81.0",
},
current: hostutility.AgentCapabilities{
Status: hostutility.StatusOK,
AgentVersion: "0.80.0",
},
currentFound: true,
runErrs: []error{errors.New("exit status 1")},
updateOutput: "npm error code ETARGET\nnpm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM.\n",
}
store, completed := newRecoveryStore(updater, selectionStore)
spec := agents.ManagedNPMRuntimeSpec{
Package: packageName,
DefaultVersion: "0.80.0",
ACPArgs: []string{"acp"},
}
job, err := store.Enqueue("claude-acp", spec, "0.81.0")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed)
if !strings.Contains(final.Error, "release-age policy") {
t.Fatalf("Error = %q, want the npm release-age policy failure", final.Error)
}
if strings.Contains(final.Error, "12:28:47 PM") {
t.Fatalf("Error leaked the raw npm date: %q", final.Error)
}
if updater.runCalls != 1 || len(updater.invalidate) != 0 || len(updater.probe) != 0 {
t.Fatalf("policy failure calls: update=%d invalidation=%#v probe=%#v, want 1, none, none", updater.runCalls, updater.invalidate, updater.probe)
}
selection, found, err := selectionStore.Get(context.Background(), "claude-acp", packageName)
if err != nil || !found || selection.Version != "0.80.0" {
t.Fatalf("selection after policy failure = %#v, found=%v, err=%v", selection, found, err)
}
}
func TestAgentUpdateFailedCandidatePreservesSelectionAndCapabilities(t *testing.T) {
selectionStore := newRecoverySelectionStore()
selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{
@@ -189,7 +189,7 @@ func TestHostRuntimeUpdaterInvalidatesOnlyManagedNPMExecutionTree(t *testing.T)
if _, err := os.Stat(other); err != nil {
t.Fatalf("unrelated cache entry was removed: %v", err)
}
want := []string{"npm", "config", "get", "cache"}
want := []string{"npm", "--prefix", "~/.kandev/managed-npm-runtime", "config", "get", "cache"}
if got := strings.Join(executor.outputCommand, "\x00"); got != strings.Join(want, "\x00") {
t.Fatalf("command = %v, want %v", executor.outputCommand, want)
}
@@ -344,12 +344,12 @@ func TestAgentUpdatePreviewResolvesTrustedCommandWithoutStartingAJob(t *testing.
t.Fatalf("versions = %q -> %q", preview.CurrentVersion, preview.TargetVersion)
}
wantCommand := []string{
"npm", "exec", "--yes", "--prefer-online", "--package=@example/managed-acp", "--", "node", "-e", "",
"npm", "--prefix", "~/.kandev/managed-npm-runtime", "exec", "--yes", "--prefer-online", "--package=@example/managed-acp", "--", "node", "-e", "",
}
if got := strings.Join(preview.Command, "\x00"); got != strings.Join(wantCommand, "\x00") {
t.Fatalf("command = %q, want %q", got, strings.Join(wantCommand, "\x00"))
}
if preview.CommandString != `npm exec --yes --prefer-online --package=@example/managed-acp -- node -e ""` {
if preview.CommandString != `npm --prefix ~/.kandev/managed-npm-runtime exec --yes --prefer-online --package=@example/managed-acp -- node -e ""` {
t.Fatalf("command string = %q", preview.CommandString)
}
@@ -596,11 +596,11 @@ func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) {
if updater.resolvedPackage != "@example/managed-acp" {
t.Fatalf("resolved package = %q", updater.resolvedPackage)
}
wantUpdate := "npm exec --yes --prefer-online --package=@example/managed-acp -- node -e "
wantUpdate := "npm --prefix ~/.kandev/managed-npm-runtime exec --yes --prefer-online --package=@example/managed-acp -- node -e "
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@1.1.0 --acp"
wantRefresh := "npx --yes --prefer-offline --prefix ~/.kandev/managed-npm-runtime @example/managed-acp@1.1.0 --acp"
if got := strings.Join(updater.refreshCommand, " "); got != wantRefresh {
t.Fatalf("refresh command = %q, want %q", got, wantRefresh)
}
@@ -715,6 +715,38 @@ func TestAgentUpdateRepairsExecutionCacheAndRetriesOnce(t *testing.T) {
}
}
func TestAgentUpdateNpmReleaseAgePolicySkipsCacheRepair(t *testing.T) {
updater := &fakeRuntimeUpdater{
current: hostutility.AgentCapabilities{AgentVersion: "0.80.0"},
currentFound: true,
target: "0.81.0",
runErr: errors.New("exit status 1"),
updateOutput: "npm error code ETARGET\nnpm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM.\n",
}
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
spec := agents.ManagedNPMRuntimeSpec{
Package: "@agentclientprotocol/claude-agent-acp",
DefaultVersion: "0.81.0",
ACPArgs: []string{"acp"},
}
job, err := store.Enqueue("claude-acp", spec)
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed)
if !strings.Contains(final.Error, "release-age policy") {
t.Fatalf("Error = %q, want the npm release-age policy failure", final.Error)
}
if strings.Contains(final.Error, "12:28:47 PM") {
t.Fatalf("Error leaked the raw npm date: %q", final.Error)
}
updater.mu.Lock()
defer updater.mu.Unlock()
if updater.runCalls != 1 || updater.invalidateCalls != 0 || updater.refreshCalls != 0 {
t.Fatalf("policy failure calls: update=%d invalidation=%d refresh=%d, want 1, 0, 0", updater.runCalls, updater.invalidateCalls, updater.refreshCalls)
}
}
func TestAgentUpdateHardFailuresRemainFailed(t *testing.T) {
tests := []struct {
name string
@@ -680,7 +680,7 @@ func TestController_PreviewAgentCommand_CopilotKeepsManagedPackage(t *testing.T)
t.Fatalf("PreviewAgentCommand() error = %v", err)
}
managed := agents.NewCopilotACP()
want := []string{"npx", "--yes", "--prefer-offline", managed.ManagedNPMRuntime().PackageSpec(""), "--acp"}
want := []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", managed.ManagedNPMRuntime().PackageSpec(""), "--acp"}
if got := res.Command; !slices.Equal(got, want) {
t.Errorf("preview with copilot on PATH = %v, want %v", got, want)
}
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/common/npmresolution"
)
const (
@@ -17,6 +18,12 @@ func safeManagedNpmStderrLine(raw string) (string, bool) {
if strings.EqualFold(line, npmETargetCodeLine) || strings.EqualFold(line, "npm ERR! code ETARGET") {
return npmETargetCodeLine, true
}
if packageSpec, ok := npmresolution.RawReleaseAgePolicyPackageSpec(line); ok {
if managedruntime.ValidateExactPackageSpec(packageSpec) != nil {
return "", false
}
return npmNotargetPrefix + packageSpec + " with a date before " + npmresolution.ReleaseDateMarker, true
}
for _, prefix := range []string{npmNotargetPrefix, npmLegacyNotargetPrefix} {
if !strings.HasPrefix(line, prefix) {
@@ -0,0 +1,50 @@
package process
import (
"strings"
"testing"
)
func TestSafeManagedNpmStderrLineKeepsOnlyCanonicalReleaseAgeMarker(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "RFC3339 date",
raw: "npm error notarget No matching version found for opencode-ai@1.18.18 with a date before 2026-09-24T11:00:00.000Z.",
want: "npm error notarget No matching version found for opencode-ai@1.18.18 with a date before <release-date>",
},
{
name: "npm 11.16 issue date",
raw: "npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM.",
want: "npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before <release-date>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, keep := safeManagedNpmStderrLine(tt.raw)
if !keep || got != tt.want {
t.Fatalf("safeManagedNpmStderrLine() = (%q, %v), want (%q, true)", got, keep, tt.want)
}
if strings.Contains(got, "9/22/2026") || strings.Contains(got, "12:28:47 PM") {
t.Fatalf("sanitized diagnostic leaked the raw date: %q", got)
}
})
}
}
func TestSafeManagedNpmStderrLineRejectsMalformedReleaseAgeDiagnostics(t *testing.T) {
for _, raw := range []string{
"npm error notarget No matching version found for opencode-ai@1.18.18 with a date before yesterday.",
"npm error notarget No matching version found for opencode-ai@1.18.18 with a date before 2026-02-30T11:00:00Z.",
"npm error notarget No matching version found for opencode-ai@1.18.18 with a date before 9/31/2026, 12:28:47 PM.",
"npm error notarget No matching version found for opencode-ai@1.18.18 with a date before 9/22/2026, 12:88:47 PM.",
"npm error notarget No matching version found for ../opencode@1.18.18 with a date before 2026-09-24T11:00:00Z.",
} {
if got, keep := safeManagedNpmStderrLine(raw); keep || got != "" {
t.Fatalf("safeManagedNpmStderrLine(%q) = (%q, %v), want empty and false", raw, got, keep)
}
}
}
@@ -31,9 +31,13 @@ func (m *Manager) RepairManagedRuntimeCacheWithEnvironment(
if err != nil {
return errors.New("resolve agent environment for managed runtime repair")
}
args := append(managedruntime.NPMProjectPrefixArgs(), "config", "get", "cache")
if err := managedruntime.PrepareNPMProjectPrefix(args); err != nil {
return errors.New("prepare managed npm project prefix")
}
output, err := m.Output(ctx, tools.CommandSpec{
Path: "npm",
Args: []string{"config", "get", "cache"},
Args: args,
Dir: m.cfg.WorkDir,
Env: env,
StripEnv: stripEnv,
@@ -2,9 +2,13 @@ package process
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agentctl/server/config"
)
@@ -65,3 +69,74 @@ func TestRepairManagedRuntimeCacheClearsPreviousStderr(t *testing.T) {
t.Fatalf("stderr after repair = %#v, want empty", got)
}
}
func TestRepairManagedRuntimeCacheUsesIsolatedNpmPrefix(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX npm fixture")
}
home := t.TempDir()
workDir := t.TempDir()
cacheRoot := t.TempDir()
binDir := t.TempDir()
argsFile := filepath.Join(t.TempDir(), "npm-args")
cwdFile := filepath.Join(t.TempDir(), "npm-cwd")
npmPath := filepath.Join(binDir, "npm")
fixture := "#!/bin/sh\n" +
"printf '%s\\n' \"$@\" > \"$MANAGED_NPM_ARGS_FILE\"\n" +
"printf '%s' \"$PWD\" > \"$MANAGED_NPM_CWD_FILE\"\n" +
"printf '%s\\n' \"$NPM_CONFIG_CACHE\"\n"
if err := os.WriteFile(npmPath, []byte(fixture), 0o755); err != nil {
t.Fatalf("write npm fixture: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
packageSpec := "@scope/managed-acp@1.2.3"
target := filepath.Join(cacheRoot, "_npx", managedruntime.NpxExecutionCacheKey(packageSpec))
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("create execution cache: %v", err)
}
manager := NewManager(&config.InstanceConfig{
WorkDir: workDir,
AgentEnv: []string{
"HOME=" + home,
"PATH=" + binDir + string(os.PathListSeparator) + "/usr/bin",
"NPM_CONFIG_CACHE=" + cacheRoot,
"MANAGED_NPM_ARGS_FILE=" + argsFile,
"MANAGED_NPM_CWD_FILE=" + cwdFile,
},
}, newTestLogger(t))
t.Cleanup(func() { _ = manager.StopForTeardown(context.Background()) })
if err := manager.RepairManagedRuntimeCache(context.Background(), packageSpec); err != nil {
t.Fatalf("RepairManagedRuntimeCache: %v", err)
}
args, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read npm arguments: %v", err)
}
argLines := strings.Split(strings.TrimSpace(string(args)), "\n")
if len(argLines) != 5 {
t.Fatalf("npm args = %q, want five arguments", args)
}
prefix := argLines[1]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("npm project prefix = %q, want an absolute path under %q", prefix, os.TempDir())
}
wantArgs := "--prefix\n" + prefix + "\nconfig\nget\ncache\n"
if string(args) != wantArgs {
t.Fatalf("npm args = %q, want %q", args, wantArgs)
}
cwd, err := os.ReadFile(cwdFile)
if err != nil || string(cwd) != workDir {
t.Fatalf("npm working directory = %q, error = %v, want workspace %q", cwd, err, workDir)
}
if info, err := os.Stat(prefix); err != nil || !info.IsDir() {
t.Fatalf("managed npm prefix was not provisioned: info=%v err=%v", info, err)
}
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
t.Fatalf("managed npm prefix was created under child HOME, stat error = %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("execution tree stat error = %v, want tree removed", err)
}
}
@@ -19,6 +19,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/shell"
@@ -1520,7 +1521,23 @@ func (m *Manager) buildFinalCommand() error {
cmdArgs = append(cmdArgs, m.cfg.AgentArgs[1:]...)
cmdArgs = append(cmdArgs, extraArgs...)
m.finalCommand = strings.Join(append([]string{m.cfg.AgentArgs[0]}, cmdArgs...), " ")
finalArgs := append([]string{m.cfg.AgentArgs[0]}, cmdArgs...)
if err := managedruntime.PrepareNPMProjectPrefix(finalArgs); err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
m.finalCommand = strings.Join(finalArgs, " ")
cmdArgs = finalArgs[1:]
if m.adapterCfg != nil && m.adapterCfg.OneShotConfig != nil {
oneShot := m.adapterCfg.OneShotConfig
oneShot.InitialArgs = append([]string(nil), oneShot.InitialArgs...)
oneShot.ContinueArgs = append([]string(nil), oneShot.ContinueArgs...)
if err := managedruntime.PrepareNPMProjectPrefix(oneShot.InitialArgs); err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
if err := managedruntime.PrepareNPMProjectPrefix(oneShot.ContinueArgs); err != nil {
return errors.New("managed npm project prefix could not be prepared")
}
}
m.logger.Debug("final agent command",
zap.String("binary", m.cfg.AgentArgs[0]),
@@ -1731,6 +1748,13 @@ func (m *Manager) buildProcessRequest(req StartProcessRequest) (StartProcessRequ
func (m *Manager) buildPipedProcessRequest(req PipedStartRequest) (PipedStartRequest, error) {
var err error
req.Env, err = mergeAgentEnvIntoShellConfigWithError(m.agentEnvSnapshot(), req.Env)
if err != nil {
return req, err
}
req.Args = append([]string(nil), req.Args...)
if err := managedruntime.PrepareNPMProjectPrefix(req.Args); err != nil {
return req, errors.New("managed npm project prefix could not be prepared")
}
return req, err
}
@@ -12,6 +12,8 @@ import (
"testing"
"time"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/shell"
"github.com/kandev/kandev/internal/githubauth"
@@ -90,6 +92,138 @@ func TestManager_BuildFinalCommandLeavesUnsetTempEnvironmentUnset(t *testing.T)
assertNoAgentTempRoot(t, serviceTemp)
}
func TestManager_BuildFinalCommandPreparesManagedNpmPrefixOutsideAgentHome(t *testing.T) {
home := t.TempDir()
workDir := t.TempDir()
manager := NewManager(&config.InstanceConfig{
WorkDir: workDir,
AgentArgs: []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@scope/managed-acp@1.2.3",
},
AgentEnv: []string{"HOME=" + home},
}, newTestLogger(t))
manager.adapter = newStubAdapter()
if err := manager.buildFinalCommand(); err != nil {
t.Fatalf("buildFinalCommand() error = %v", err)
}
prefix := manager.cmd.Args[4]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
}
info, err := os.Stat(prefix)
if err != nil || !info.IsDir() {
t.Fatalf("managed npm prefix stat = (%v, %v), want an existing directory", info, err)
}
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
t.Fatalf("managed npm prefix was created under agent home, stat error = %v", err)
}
if manager.cmd.Dir != workDir {
t.Fatalf("managed runtime working directory = %q, want workspace %q", manager.cmd.Dir, workDir)
}
}
func TestManager_BuildFinalCommandPreparesOneShotNpmPrefixesWithoutChangingArgs(t *testing.T) {
initialArgs := []string{"npx", "--prefix", managedruntime.NPMProjectPrefix, "pkg@1.2.3", "--initial-only"}
continueArgs := []string{"npx", "--prefix", managedruntime.NPMProjectPrefix, "pkg@1.2.3", "--continue-only"}
manager := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentArgs: []string{"persistent-agent", "--persistent-only"},
}, newTestLogger(t))
manager.adapter = newStubAdapter()
manager.adapterCfg = &adapter.Config{OneShotConfig: &adapter.OneShotConfig{
InitialArgs: initialArgs,
ContinueArgs: continueArgs,
}}
if err := manager.buildFinalCommand(); err != nil {
t.Fatalf("buildFinalCommand() error = %v", err)
}
oneShot := manager.adapterCfg.OneShotConfig
for name, args := range map[string][]string{
"initial": oneShot.InitialArgs,
"continue": oneShot.ContinueArgs,
} {
if len(args) != 5 || args[4] != "--"+name+"-only" {
t.Errorf("one-shot %s args = %#v, want its original command arguments", name, args)
continue
}
prefix := args[2]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Errorf("one-shot %s prefix = %q, want an absolute path under %q", name, prefix, os.TempDir())
}
}
if initialArgs[2] != managedruntime.NPMProjectPrefix || continueArgs[2] != managedruntime.NPMProjectPrefix {
t.Fatalf("preparing one-shot args mutated source command slices: initial=%#v continue=%#v", initialArgs, continueArgs)
}
}
func TestManager_BuildPipedProcessRequestPreparesManagedNpmPrefixOutsideAgentHome(t *testing.T) {
home := t.TempDir()
manager := NewManager(&config.InstanceConfig{
WorkDir: t.TempDir(),
AgentEnv: []string{"HOME=" + home},
}, newTestLogger(t))
args := append(managedruntime.NPMProjectPrefixArgs(), "config", "get", "cache")
req, err := manager.buildPipedProcessRequest(PipedStartRequest{Command: "npm", Args: args})
if err != nil {
t.Fatalf("buildPipedProcessRequest() error = %v", err)
}
prefix := req.Args[1]
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
}
if _, err := os.Stat(prefix); err != nil {
t.Fatalf("managed npm prefix was not provisioned: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
t.Fatalf("managed npm prefix was created under agent home, stat error = %v", err)
}
}
func TestManager_BuildFinalCommandFailsSafelyWhenManagedNpmPrefixIsUnavailable(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "not-a-temp-directory")
if err := os.WriteFile(tempFile, []byte("temp"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
if runtime.GOOS == "windows" {
t.Setenv("TEMP", tempFile)
t.Setenv("TMP", tempFile)
} else {
t.Setenv("TMPDIR", tempFile)
}
workDir := t.TempDir()
agentEnv := []string{"HOME=" + t.TempDir(), "KEEP_THIS=unchanged"}
manager := NewManager(&config.InstanceConfig{
WorkDir: workDir,
AgentArgs: []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@scope/managed-acp@1.2.3",
},
AgentEnv: agentEnv,
}, newTestLogger(t))
manager.adapter = newStubAdapter()
err := manager.buildFinalCommand()
if err == nil {
t.Fatal("buildFinalCommand() succeeded without an available npm prefix")
}
if strings.Contains(err.Error(), tempFile) {
t.Fatalf("prefix preparation error exposed temp path: %q", err)
}
if manager.cmd != nil {
t.Fatal("agent command was constructed despite unavailable npm prefix")
}
if got := envValue(manager.cfg.AgentEnv, "KEEP_THIS"); got != "unchanged" {
t.Fatalf("agent environment changed: KEEP_THIS=%q", got)
}
if _, err := os.Stat(filepath.Join(workDir, ".kandev")); !os.IsNotExist(err) {
t.Fatalf("workspace received npm state, stat error = %v", err)
}
}
func TestManager_StartShellInheritsAgentEnvironment(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("PTY-backed shell sessions are unsupported on Windows")
@@ -93,13 +93,17 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
cmdArgs = args[1:]
}
cmdArgs = append(cmdArgs, cfg.CLIFlags...)
env := sanitizeEnvForAgent(req.InferenceConfig)
if err := managedruntime.PrepareNPMProjectPrefix(cmdArgs); err != nil {
return &PromptResponse{Success: false, Error: "managed npm project prefix could not be prepared"}, nil
}
// Use resolvedCmd (not args[0]) so the executable name the taint tracker
// sees is an allow-list literal, a validated command prefix, or the
// operator-registered command resolveSpawnCommand documents.
//nolint:gosec // resolvedCmd is an allow-list literal, a validated prefix, or an operator-registered command
cmd := exec.CommandContext(ctx, resolvedCmd, cmdArgs...)
cmd.Dir = workDir
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
cmd.Env = env
configureACPCommand(cmd, e.logger)
// Same reasoning as the probe: without this the child's own account of why
@@ -490,6 +494,9 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
// Probes intentionally omit the model flag so session/new returns the agent's
// default model and the complete availableModels list.
args := buildACPCommand(cfg, "")
if err := managedruntime.PrepareNPMProjectPrefix(args); err != nil {
return &ProbeResponse{Success: false, Error: "managed npm project prefix could not be prepared"}, nil
}
e.logger.Info("starting ACP probe",
zap.String("agent_id", req.AgentID),
@@ -569,17 +576,25 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
func managedRuntimeProbeFailureCode(command []string, stderr string) ProbeFailureCode {
packageSpec, ok := managedRuntimeProbePackageSpec(command)
if !ok || !npmresolution.MatchesExactPackage(stderr, packageSpec) {
if !ok {
return ""
}
if npmresolution.MatchesRawReleaseAgePolicy(stderr, packageSpec) {
return ProbeFailureManagedRuntimeNPMPolicy
}
if !npmresolution.MatchesExactPackage(stderr, packageSpec) {
return ""
}
return ProbeFailureManagedRuntimeNPMResolution
}
func managedRuntimeProbePackageSpec(command []string) (string, bool) {
if len(command) < 4 || command[0] != "npx" || command[1] != "--yes" || command[2] != "--prefer-offline" {
// Accept only npx --yes --prefer-offline --prefix <fixed-prefix> <exact-spec>.
if len(command) < 6 || command[0] != "npx" || command[1] != "--yes" || command[2] != "--prefer-offline" ||
command[3] != "--prefix" || command[4] != managedruntime.NPMProjectPrefix {
return "", false
}
packageSpec := command[3]
packageSpec := command[5]
if err := managedruntime.ValidateExactPackageSpec(packageSpec); err != nil {
return "", false
}
@@ -138,6 +138,7 @@ func TestProbeClassifiesTrustedManagedRuntimeETarget(t *testing.T) {
binDir := t.TempDir()
npxPath := filepath.Join(binDir, "npx")
fixture := "#!/bin/sh\n" +
"printf '%s' \"$4\" > \"$NPM_PREFIX_FILE\"\n" +
"printf '%s\\n' 'npm error code ETARGET' " +
"'npm error notarget No matching version found for @scope/managed-acp@1.2.3.' >&2\n" +
"exit 1\n"
@@ -145,13 +146,17 @@ func TestProbeClassifiesTrustedManagedRuntimeETarget(t *testing.T) {
t.Fatalf("write npx fixture: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
home := t.TempDir()
workDir := t.TempDir()
prefixFile := filepath.Join(t.TempDir(), "npm-prefix")
executor := NewACPInferenceExecutor(zap.NewNop())
response, err := executor.Probe(context.Background(), &ProbeRequest{
AgentID: "managed-acp",
InferenceConfig: &InferenceConfigDTO{
Command: []string{"npx", "--yes", "--prefer-offline", "@scope/managed-acp@1.2.3"},
WorkDir: t.TempDir(),
Command: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "@scope/managed-acp@1.2.3"},
WorkDir: workDir,
Env: map[string]string{"HOME": home, "NPM_PREFIX_FILE": prefixFile},
},
})
if err != nil {
@@ -163,6 +168,20 @@ func TestProbeClassifiesTrustedManagedRuntimeETarget(t *testing.T) {
if strings.Contains(response.Error, "@scope/managed-acp") || strings.Contains(response.Error, "npm error") {
t.Fatalf("probe error exposed subprocess diagnostics: %q", response.Error)
}
actualPrefixBytes, err := os.ReadFile(prefixFile)
if err != nil {
t.Fatalf("read probe npm prefix: %v", err)
}
actualPrefix := string(actualPrefixBytes)
if !filepath.IsAbs(actualPrefix) || !strings.HasPrefix(filepath.Clean(actualPrefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
t.Fatalf("probe npm prefix = %q, want absolute path under %q", actualPrefix, os.TempDir())
}
if info, err := os.Stat(actualPrefix); err != nil || !info.IsDir() {
t.Fatalf("probe did not prepare managed npm prefix: info=%v err=%v", info, err)
}
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
t.Fatalf("probe created managed npm prefix under agent home, stat error = %v", err)
}
}
func TestManagedRuntimeProbeFailureCodeRejectsUntrustedEvidence(t *testing.T) {
@@ -174,24 +193,29 @@ func TestManagedRuntimeProbeFailureCodeRejectsUntrustedEvidence(t *testing.T) {
}{
{
name: "unversioned package",
command: []string{"npx", "--yes", "--prefer-offline", "managed-acp"},
command: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "managed-acp"},
stderr: matching,
},
{
name: "online command",
command: []string{"npx", "--yes", "--prefer-online", "managed-acp@1.2.3"},
command: []string{"npx", "--yes", "--prefer-online", "--prefix", "~/.kandev/managed-npm-runtime", "managed-acp@1.2.3"},
stderr: matching,
},
{
name: "different package",
command: []string{"npx", "--yes", "--prefer-offline", "managed-acp@1.2.3"},
command: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "managed-acp@1.2.3"},
stderr: "npm error code ETARGET\nnpm error notarget No matching version found for dependency@9.9.9.",
},
{
name: "different npm error",
command: []string{"npx", "--yes", "--prefer-offline", "managed-acp@1.2.3"},
command: []string{"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime", "managed-acp@1.2.3"},
stderr: "npm error code ECONNREFUSED",
},
{
name: "missing isolated prefix",
command: []string{"npx", "--yes", "--prefer-offline", "managed-acp@1.2.3"},
stderr: matching,
},
}
for _, test := range tests {
@@ -203,6 +227,18 @@ func TestManagedRuntimeProbeFailureCodeRejectsUntrustedEvidence(t *testing.T) {
}
}
func TestManagedRuntimeProbeClassifiesReleaseAgePolicyFailure(t *testing.T) {
command := []string{
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
"@agentclientprotocol/claude-agent-acp@0.81.0",
}
stderr := "npm error code ETARGET\n" +
"npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM."
if got, want := managedRuntimeProbeFailureCode(command, stderr), ProbeFailureCode("managed_runtime_npm_policy"); got != want {
t.Fatalf("failure code = %q, want %q", got, want)
}
}
func TestProbeConfigOptions_PreservesDescriptions(t *testing.T) {
t.Parallel()
@@ -144,6 +144,9 @@ const (
// ProbeFailureManagedRuntimeNPMResolution means the trusted top-level npm
// package failed exact-version resolution with ETARGET.
ProbeFailureManagedRuntimeNPMResolution ProbeFailureCode = "managed_runtime_npm_resolution"
// ProbeFailureManagedRuntimeNPMPolicy means npm rejected the trusted exact
// package version under a date-qualified release policy.
ProbeFailureManagedRuntimeNPMPolicy ProbeFailureCode = "managed_runtime_npm_policy"
)
// ProbeAuthMethod is a single advertised authentication method.
@@ -4,9 +4,21 @@ package npmresolution
import (
"regexp"
"strings"
"time"
)
var etargetCodePattern = regexp.MustCompile(`(?im)^\s*npm\s+(?:ERR!|error)\s+code\s+ETARGET\b`)
var releaseAgeNotargetPattern = regexp.MustCompile(
`(?i)^\s*npm\s+(?:ERR!|error)\s+notarget\s+No matching version found for\s+(\S+)\s+with a date before\s+(\S+)\s*$`,
)
var releaseAgeLocaleNotargetPattern = regexp.MustCompile(
`(?i)^\s*npm\s+(?:ERR!|error)\s+notarget\s+No matching version found for\s+(\S+)\s+with a date before\s+(\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2}:\d{2} [AP]M)\.?\s*$`,
)
var canonicalReleaseAgeNotargetPattern = regexp.MustCompile(
`(?i)^\s*npm\s+(?:ERR!|error)\s+notarget\s+No matching version found for\s+(\S+)\s+with a date before\s+<release-date>\.?\s*$`,
)
const ReleaseDateMarker = "<release-date>"
// MatchesExactPackage reports whether stderr contains npm ETARGET evidence for
// the exact top-level package specification supplied by a trusted caller.
@@ -20,3 +32,88 @@ func MatchesExactPackage(stderr, packageSpec string) bool {
)
return etargetCodePattern.MatchString(stderr) && notargetPattern.MatchString(stderr)
}
// ReleaseAgePolicyPackageSpec returns the exact package spec from a date-
// qualified npm notarget line. It accepts npm's supported date forms or the
// canonical marker used after agentctl removes the untrusted date value.
func ReleaseAgePolicyPackageSpec(line string) (string, bool) {
line = strings.TrimSpace(line)
if len(line) > 2048 {
return "", false
}
if match := canonicalReleaseAgeNotargetPattern.FindStringSubmatch(line); len(match) == 2 {
return match[1], true
}
return RawReleaseAgePolicyPackageSpec(line)
}
// RawReleaseAgePolicyPackageSpec returns the package from npm's original
// date-qualified line after validating the date.
func RawReleaseAgePolicyPackageSpec(line string) (string, bool) {
line = strings.TrimSpace(line)
if len(line) > 2048 {
return "", false
}
for _, pattern := range []*regexp.Regexp{releaseAgeNotargetPattern, releaseAgeLocaleNotargetPattern} {
match := pattern.FindStringSubmatch(line)
if len(match) != 3 || len(match[1]) > 512 || !validReleaseDate(match[2]) {
continue
}
return match[1], true
}
return "", false
}
func validReleaseDate(raw string) bool {
date := strings.TrimSuffix(raw, ".")
if _, err := time.Parse(time.RFC3339Nano, date); err == nil {
return true
}
_, err := time.Parse("1/2/2006, 3:04:05 PM", strings.ToUpper(date))
return err == nil
}
// ContainsReleaseAgePolicy reports whether the bounded diagnostic includes an
// ETARGET code and at least one valid date-qualified notarget line.
func ContainsReleaseAgePolicy(stderr string) bool {
if !etargetCodePattern.MatchString(stderr) {
return false
}
for _, line := range strings.Split(stderr, "\n") {
if _, ok := ReleaseAgePolicyPackageSpec(line); ok {
return true
}
}
return false
}
// MatchesReleaseAgePolicy requires the date-qualified ETARGET diagnostic to
// name the trusted top-level package spec exactly.
func MatchesReleaseAgePolicy(stderr, packageSpec string) bool {
if packageSpec == "" || strings.TrimSpace(packageSpec) != packageSpec ||
!etargetCodePattern.MatchString(stderr) {
return false
}
for _, line := range strings.Split(stderr, "\n") {
if got, ok := ReleaseAgePolicyPackageSpec(line); ok && got == packageSpec {
return true
}
}
return false
}
// MatchesRawReleaseAgePolicy requires the original npm date-qualified
// diagnostic for the exact trusted package spec. Use it at subprocess
// boundaries before a canonical marker has been projected.
func MatchesRawReleaseAgePolicy(stderr, packageSpec string) bool {
if packageSpec == "" || strings.TrimSpace(packageSpec) != packageSpec ||
!etargetCodePattern.MatchString(stderr) {
return false
}
for _, line := range strings.Split(stderr, "\n") {
if got, ok := RawReleaseAgePolicyPackageSpec(line); ok && got == packageSpec {
return true
}
}
return false
}
@@ -0,0 +1,97 @@
package npmresolution
import "testing"
func TestMatchesReleaseAgePolicyForExactTopLevelPackage(t *testing.T) {
const packageSpec = "@agentclientprotocol/claude-agent-acp@0.81.0"
tests := []struct {
name string
stderr string
match bool
}{
{
name: "RFC3339 date-qualified diagnostic",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec +
" with a date before 2026-09-24T11:00:00.000Z.",
match: true,
},
{
name: "npm 11.16 locale-formatted diagnostic from issue 3902",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for @agentclientprotocol/claude-agent-acp@0.81.0 with a date before 9/22/2026, 12:28:47 PM.",
match: true,
},
{
name: "canonical agentctl marker",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec +
" with a date before " + ReleaseDateMarker,
match: true,
},
{
name: "legacy npm prefix",
stderr: "npm ERR! code ETARGET\n" +
"npm ERR! notarget No matching version found for " + packageSpec +
" with a date before 2026-09-24T11:00:00Z.",
match: true,
},
{
name: "ordinary exact-package ETARGET",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec + ".",
},
{
name: "transitive package",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for dependency@9.9.9 with a date before 2026-09-24T11:00:00Z.",
},
{
name: "missing ETARGET code",
stderr: "npm error notarget No matching version found for " + packageSpec +
" with a date before 2026-09-24T11:00:00Z.",
},
{
name: "invalid date",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec +
" with a date before 2026-02-30T11:00:00Z.",
},
{
name: "malformed date text",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec + " with a date before yesterday.",
},
{
name: "invalid locale-formatted calendar date",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec + " with a date before 9/31/2026, 12:28:47 PM.",
},
{
name: "malformed locale-formatted time",
stderr: "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec + " with a date before 9/22/2026, 12:88:47 PM.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MatchesReleaseAgePolicy(tt.stderr, packageSpec); got != tt.match {
t.Fatalf("MatchesReleaseAgePolicy() = %v, want %v", got, tt.match)
}
})
}
}
func TestReleaseAgePolicyRemainsDistinctFromOrdinaryExactETarget(t *testing.T) {
const packageSpec = "managed-acp@1.2.3"
policy := "npm error code ETARGET\n" +
"npm error notarget No matching version found for " + packageSpec +
" with a date before 2026-09-24T11:00:00Z."
if MatchesExactPackage(policy, packageSpec) {
t.Fatal("date-qualified ETARGET matched ordinary stale-metadata recovery")
}
if !ContainsReleaseAgePolicy(policy) {
t.Fatal("date-qualified ETARGET was not detected")
}
}
@@ -3375,9 +3375,9 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
"is_auth_error": authErr,
"resume_corrupted": resumeCorrupted,
}
managedRuntimeNpmFailure := data.FailureCode == string(routingerr.CodeManagedRuntimeNpmResolution)
managedRuntimeNpmFailure := isManagedRuntimeNpmFailureCode(data.FailureCode)
if managedRuntimeNpmFailure {
meta["failure_kind"] = string(routingerr.CodeManagedRuntimeNpmResolution)
meta["failure_kind"] = data.FailureCode
}
// The validated remediation URL is carried independently of quota
// classification so the generic recoverable card can still show the link.
@@ -3557,7 +3557,7 @@ func (s *Service) handleAgentStartFailed(ctx context.Context, taskID, sessionID,
}
if classified := classifyManagedRuntimeNpmStartFailure(err); classified != nil {
failureData.ErrorMessage = "managed npm runtime failed to prepare"
failureData.FailureCode = string(routingerr.CodeManagedRuntimeNpmResolution)
failureData.FailureCode = string(classified.Code)
failureData.FailureDetails = classified.RawExcerpt
}
var unlockGuard func()
@@ -3613,7 +3613,7 @@ func (s *Service) handleAgentStartFailed(ctx context.Context, taskID, sessionID,
}
s.preserveWorkflowStartPromptAfterFailure(ctx, taskID, sessionID, agentExecutionID)
}
if failureData.FailureCode == string(routingerr.CodeManagedRuntimeNpmResolution) {
if isManagedRuntimeNpmFailureCode(failureData.FailureCode) {
s.logger.Info("managed npm runtime startup failure is recoverable",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
@@ -3649,7 +3649,8 @@ func classifyManagedRuntimeNpmStartFailure(err error) *routingerr.Error {
}
var structured *routingerr.ManagedRuntimeStartupError
if errors.As(err, &structured) {
if structured.Code != routingerr.CodeManagedRuntimeNpmResolution {
if structured.Code != routingerr.CodeManagedRuntimeNpmResolution &&
structured.Code != routingerr.CodeManagedRuntimeNpmPolicy {
return nil
}
return &routingerr.Error{
@@ -3662,6 +3663,11 @@ func classifyManagedRuntimeNpmStartFailure(err error) *routingerr.Error {
return nil
}
func isManagedRuntimeNpmFailureCode(code string) bool {
return code == string(routingerr.CodeManagedRuntimeNpmResolution) ||
code == string(routingerr.CodeManagedRuntimeNpmPolicy)
}
// actionMetaKey* are the shared keys of the frontend ActionMessage button
// descriptor (see apps/web .../messages/action-message.tsx). Defined as
// constants because the shape is built in more than one place in this package.
@@ -2990,6 +2990,14 @@ func TestClassifyManagedRuntimeNpmStartFailureUsesStructuredError(t *testing.T)
)) != nil {
t.Fatal("unstructured npm text must not select the managed runtime recovery card")
}
policy := classifyManagedRuntimeNpmStartFailure(fmt.Errorf("failed to initialize ACP: %w", &routingerr.ManagedRuntimeStartupError{
Code: routingerr.Code("managed_runtime_npm_policy"),
Details: "npm error code ETARGET\nnpm error notarget No matching version found with a date before <release-date>",
}))
if policy == nil || policy.Code != routingerr.Code("managed_runtime_npm_policy") {
t.Fatalf("policy startup failure = %#v, want managed runtime policy classification", policy)
}
}
func TestHandleAgentStartFailed(t *testing.T) {
@@ -123,6 +123,42 @@ func TestCreateRecoveryStatusMessage_ManagedRuntimeNpmUsesOneRetryAction(t *test
}
}
func TestCreateRecoveryStatusMessage_ManagedRuntimePolicyUsesOneRetryAction(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t-policy", "s-policy", "step1")
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{repoForExecutionLookup: repo})
mc := &mockMessageCreator{}
svc.messageCreator = mc
require.NoError(t, svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-policy",
SessionID: "s-policy",
ErrorMessage: "managed npm runtime failed to prepare",
FailureCode: "managed_runtime_npm_policy",
FailureDetails: "npm error code ETARGET\nnpm error notarget No matching version found with a date before <release-date>",
}, ""))
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected one recovery message, got %d", len(mc.sessionMessages))
}
meta := mc.sessionMessages[0].metadata
if meta["failure_kind"] != "managed_runtime_npm_policy" {
t.Fatalf("failure_kind = %#v, want policy code", meta["failure_kind"])
}
if meta["error_output"] != "npm error code ETARGET\nnpm error notarget No matching version found with a date before <release-date>" {
t.Fatalf("error_output = %#v, want sanitized policy details", meta["error_output"])
}
actions, ok := meta["actions"].([]map[string]interface{})
if !ok || len(actions) != 1 || actions[0]["test_id"] != "managed-runtime-npm-retry-button" {
t.Fatalf("actions = %#v, want one runtime retry", meta["actions"])
}
payload := actions[0]["params"].(map[string]interface{})["payload"].(map[string]interface{})
if payload["action"] != "runtime_retry" {
t.Fatalf("action payload = %#v, want runtime_retry", payload)
}
}
func TestCreateRecoveryStatusMessage_ResumeCorrupted(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -4,6 +4,8 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { StoreApi } from "zustand";
import { ActionMessage } from "./action-message";
const MANAGED_RUNTIME_RETRY_TEST_ID = "managed-runtime-npm-retry-button";
import {
sessionId as toSessionId,
taskId as toTaskId,
@@ -619,7 +621,7 @@ describe("ActionMessage — managed npm runtime recovery", () => {
{
type: "ws_request",
label: "backend label is ignored",
test_id: "managed-runtime-npm-retry-button",
test_id: MANAGED_RUNTIME_RETRY_TEST_ID,
params: {
method: SESSION_RECOVER_METHOD,
payload: {
@@ -641,11 +643,11 @@ describe("ActionMessage — managed npm runtime recovery", () => {
expect(card.textContent).not.toMatch(/ACP/i);
expect(screen.getByText(TECHNICAL_DETAILS).closest("details")?.open).toBe(false);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(screen.getByTestId("managed-runtime-npm-retry-button").textContent).toContain(
expect(screen.getByTestId(MANAGED_RUNTIME_RETRY_TEST_ID).textContent).toContain(
"Retry runtime",
);
fireEvent.click(screen.getByTestId("managed-runtime-npm-retry-button"));
fireEvent.click(screen.getByTestId(MANAGED_RUNTIME_RETRY_TEST_ID));
await waitFor(() =>
expect(requestMock).toHaveBeenCalledWith(SESSION_RECOVER_METHOD, {
task_id: TEST_TASK_ID,
@@ -654,6 +656,50 @@ describe("ActionMessage — managed npm runtime recovery", () => {
}),
);
});
it("explains when npm's release-age policy blocks the selected runtime", () => {
renderAction(
retryMessage({
content: "managed runtime is blocked by npm policy",
metadata: {
variant: "error",
recovery_actions: true,
failure_kind: "managed_runtime_npm_policy",
error_output:
"npm error notarget No matching version found for @example/agent@1.2.3. A minimum release age policy is in effect.\n @example/agent@1.2.3 release date: <release-date>",
actions: [
{
type: "ws_request",
label: "backend label is ignored",
test_id: MANAGED_RUNTIME_RETRY_TEST_ID,
params: {
method: SESSION_RECOVER_METHOD,
payload: {
task_id: TEST_TASK_ID,
session_id: TEST_SESSION_ID,
action: "runtime_retry",
},
},
},
],
},
} as Partial<Message>),
"WAITING_FOR_INPUT",
);
const card = screen.getByTestId("managed-runtime-npm-recovery");
expect(card.textContent).toContain("npm blocked this runtime version");
expect(card.textContent).toContain(
"Check npm's min-release-age or before setting. Wait until this version is eligible or select an older version, then retry.",
);
expect(card.textContent).not.toContain("refreshed package data");
expect(card.textContent).not.toContain("2026-09-20T10:30:00Z");
expect(screen.getByText(TECHNICAL_DETAILS).closest("details")?.open).toBe(false);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(screen.getByTestId(MANAGED_RUNTIME_RETRY_TEST_ID).textContent).toContain(
"Retry runtime",
);
});
});
describe("ActionMessage — remediation link", () => {
@@ -401,7 +401,10 @@ function renderSpecialRecovery({
/>
);
}
if (metadata?.failure_kind === "managed_runtime_npm_resolution") {
if (
metadata?.failure_kind === "managed_runtime_npm_resolution" ||
metadata?.failure_kind === "managed_runtime_npm_policy"
) {
return (
<ManagedRuntimeNpmRecovery
metadata={metadata}
@@ -423,6 +426,7 @@ function ManagedRuntimeNpmRecovery({
onRecoveryRequested: () => void;
}) {
const { t } = useTranslation();
const isPolicyFailure = metadata.failure_kind === "managed_runtime_npm_policy";
const actions = metadata.actions?.slice(0, 1) ?? [];
return (
<section
@@ -437,10 +441,12 @@ function ManagedRuntimeNpmRecovery({
/>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-foreground">
{t("chat:managedRuntimeNpmTitle")}
{t(
isPolicyFailure ? "chat:managedRuntimeNpmPolicyTitle" : "chat:managedRuntimeNpmTitle",
)}
</h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{t("chat:managedRuntimeNpmBody")}
{t(isPolicyFailure ? "chat:managedRuntimeNpmPolicyBody" : "chat:managedRuntimeNpmBody")}
</p>
<ActionMessageDetails metadata={metadata} />
{actions.length > 0 && (
@@ -20,6 +20,7 @@ export function ManagedRuntimeNpmRunError({
}) {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const isPolicyFailure = error.failureCode === "managed_runtime_npm_policy";
const technicalDetails = error.failureDetails;
return (
@@ -34,13 +35,17 @@ export function ManagedRuntimeNpmRunError({
<span className="text-sm font-medium">{agentName}</span>
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<IconAlertTriangle className="h-3.5 w-3.5" aria-hidden="true" />
{t("chat:managedRuntimeNpmTitle")}
{t(
isPolicyFailure ? "chat:managedRuntimeNpmPolicyTitle" : "chat:managedRuntimeNpmTitle",
)}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(error.failedAt)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{t("chat:managedRuntimeNpmBody")}</p>
<p className="mt-1 text-sm text-muted-foreground">
{t(isPolicyFailure ? "chat:managedRuntimeNpmPolicyBody" : "chat:managedRuntimeNpmBody")}
</p>
{technicalDetails && (
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-2">
<CollapsibleTrigger className="flex min-h-11 cursor-pointer items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground sm:min-h-8">
@@ -5,6 +5,7 @@ import { WebSocketRequestError } from "@/lib/ws/client";
import { RunErrorEntry } from "./run-error-entry";
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }));
const RUN_ERROR_RESUME_TEST_ID = "run-error-resume-button";
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector({}),
@@ -33,6 +34,28 @@ function runError(failureCode: string): RunError {
}
describe("RunErrorEntry", () => {
it("renders managed npm policy failures on the runtime recovery surface", () => {
render(
<RunErrorEntry
taskId="task-1"
workspaceId="workspace-1"
error={{
...runError("managed_runtime_npm_policy"),
failureDetails:
"npm error notarget No matching version found. Minimum release age policy applies.",
}}
/>,
);
const recovery = screen.getByTestId("run-error-managed-runtime-npm-recovery");
expect(recovery.textContent).toContain("npm blocked this runtime version");
expect(recovery.textContent).toContain(
"Check npm's min-release-age or before setting. Wait until this version is eligible or select an older version, then retry.",
);
expect(screen.getByTestId("run-error-managed-runtime-retry-button")).toBeTruthy();
expect(screen.queryByTestId(RUN_ERROR_RESUME_TEST_ID)).toBeNull();
});
it.each(["provider_auth_required", "model_capacity"])(
"keeps ordinary failure code %s on the resumable error surface",
(failureCode) => {
@@ -40,7 +63,7 @@ describe("RunErrorEntry", () => {
<RunErrorEntry taskId="task-1" workspaceId="workspace-1" error={runError(failureCode)} />,
);
expect(screen.getByTestId("run-error-resume-button")).toBeTruthy();
expect(screen.getByTestId(RUN_ERROR_RESUME_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("run-error-fresh-button")).toBeTruthy();
expect(screen.queryByTestId("run-error-raw-payload")).toBeNull();
expect(screen.getByTestId("remediation-link")).toBeTruthy();
@@ -82,7 +105,7 @@ describe("RunErrorEntry", () => {
/>,
);
expect(screen.queryByTestId("run-error-resume-button")).toBeNull();
expect(screen.queryByTestId(RUN_ERROR_RESUME_TEST_ID)).toBeNull();
expect(screen.queryByTestId("run-error-fresh-button")).toBeNull();
});
});
@@ -300,7 +300,10 @@ export function RunErrorEntry({
);
}
if (error.failureCode === "managed_runtime_npm_resolution") {
if (
error.failureCode === "managed_runtime_npm_resolution" ||
error.failureCode === "managed_runtime_npm_policy"
) {
return (
<ManagedRuntimeNpmRunError
error={error}
+16 -4
View File
@@ -4,12 +4,24 @@ set -eu
cache_root=${NPM_CONFIG_CACHE:-${npm_config_cache:-"$HOME/.npm"}}
package_spec=${3:-}
preference=${2:-}
shift_count=3
managed_package_name=opencode-ai
real_npx=${KANDEV_E2E_REAL_NPX:-/usr/bin/npx}
mock_agent=${KANDEV_E2E_MOCK_AGENT_PATH:-/usr/local/bin/mock-agent}
if [ "${3:-}" = "--prefix" ]; then
case "${4:-}" in
"~/.kandev/managed-npm-runtime"|*/kandev-managed-npm-runtime-????????????????) ;;
*)
exec "$real_npx" "$@"
;;
esac
package_spec=${5:-}
shift_count=5
fi
if [ "${KANDEV_E2E_NPX_BYPASS_FAILURE:-false}" = "true" ]; then
shift 3
shift "$shift_count"
exec "$mock_agent" "$@"
fi
@@ -20,7 +32,7 @@ case "$package_spec" in
"$managed_package_name"@*) ;;
*)
if [ "${KANDEV_E2E_NPX_MOCK_OTHERS:-false}" = "true" ]; then
shift 3
shift "$shift_count"
exec "$mock_agent" "$@"
fi
exec "$real_npx" "$@"
@@ -38,7 +50,7 @@ offline_invocations="$cache_root/offline-invocations"
if [ "$preference" = "--prefer-offline" ]; then
if [ -e "$target_dir/fresh-marker" ]; then
shift 3
shift "$shift_count"
exec "$mock_agent" "$@"
fi
mkdir -p "$target_dir" "$sibling_dir"
@@ -58,7 +70,7 @@ if [ "$preference" = "--prefer-online" ]; then
mkdir -p "$target_dir"
printf 'fresh\n' > "$target_dir/fresh-marker"
printf '%s\n' "$package_spec" >> "$online_invocations"
shift 3
shift "$shift_count"
exec "$mock_agent" "$@"
fi
@@ -53,3 +53,59 @@ test("keeps managed npm recovery visible in Office chat", async ({
.poll(() => sentFrames.find((frame) => frame.includes('"action":"session.recover"')) ?? "")
.toContain('"action":"runtime_retry"');
});
test("explains release-age policy failures in Office chat", async ({
testPage,
apiClient,
officeSeed,
}) => {
const sentFrames: string[] = [];
testPage.on("websocket", (ws) => {
if (!ws.url().endsWith("/ws")) return;
ws.on("framesent", (event) => {
if (typeof event.payload === "string") sentFrames.push(event.payload);
});
});
const task = await apiClient.createTask(officeSeed.workspaceId, "Office npm policy recovery", {
workflow_id: officeSeed.workflowId,
});
await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
assignee_agent_profile_id: officeSeed.agentId,
});
await apiClient.seedTaskSession(task.id, {
state: "FAILED",
agentProfileId: officeSeed.agentId,
completedAt: "2026-08-16T09:15:44Z",
metadata: {
last_agent_error: {
message: "managed npm runtime is blocked by release-age policy",
code: "managed_runtime_npm_policy",
details:
"npm error notarget No matching version found. Minimum release age policy applies.\n @example/agent@1.2.3 release date: <release-date>",
},
},
});
await testPage.goto(`/office/tasks/${task.id}`);
await expect(testPage.getByRole("heading", { name: "Office npm policy recovery" })).toBeVisible();
const recovery = testPage.getByTestId("run-error-managed-runtime-npm-recovery");
await expect(recovery).toBeVisible();
await expect(recovery).toContainText("npm blocked this runtime version");
await expect(recovery).toContainText(
"Check npm's min-release-age or before setting. Wait until this version is eligible or select an older version, then retry.",
);
await expect(recovery.getByTestId("run-error-managed-runtime-retry-button")).toHaveCount(1);
await expect(recovery.getByTestId("run-error-resume-button")).toHaveCount(0);
await expect(recovery.getByTestId("run-error-fresh-button")).toHaveCount(0);
await expect(recovery.getByRole("button", { name: "Technical details" })).toHaveAttribute(
"aria-expanded",
"false",
);
await recovery.getByTestId("run-error-managed-runtime-retry-button").click();
await expect
.poll(() => sentFrames.find((frame) => frame.includes('"action":"session.recover"')) ?? "")
.toContain('"action":"runtime_retry"');
});
@@ -4,7 +4,13 @@ import type { SeedData } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
async function seedManagedRuntimeFailure(apiClient: ApiClient, seedData: SeedData) {
async function seedManagedRuntimeFailure(
apiClient: ApiClient,
seedData: SeedData,
failureKind:
| "managed_runtime_npm_resolution"
| "managed_runtime_npm_policy" = "managed_runtime_npm_resolution",
) {
const task = await apiClient.createTask(seedData.workspaceId, "Managed npm runtime recovery", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
@@ -18,8 +24,11 @@ async function seedManagedRuntimeFailure(apiClient: ApiClient, seedData: SeedDat
content: "managed npm runtime failed to prepare",
metadata: {
recovery_actions: true,
failure_kind: "managed_runtime_npm_resolution",
error_output: "npm error code ETARGET\nnpm error notarget No matching version found",
failure_kind: failureKind,
error_output:
failureKind === "managed_runtime_npm_policy"
? "npm error notarget No matching version found. Minimum release age policy applies.\n @example/agent@1.2.3 release date: <release-date>"
: "npm error code ETARGET\nnpm error notarget No matching version found",
actions: [
{
type: "ws_request",
@@ -40,7 +49,6 @@ test("renders managed npm recovery with one retry action", async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const sentFrames: string[] = [];
testPage.on("websocket", (ws) => {
@@ -73,11 +81,6 @@ test("renders managed npm recovery with one retry action", async ({
path: testInfo.outputPath("managed-runtime-npm-recovery-desktop.png"),
fullPage: true,
});
await prCapture.screenshot("managed-runtime-npm-recovery-desktop", {
caption: "Managed npm runtime recovery card",
fullPage: true,
});
await recovery.getByText("Technical details", { exact: true }).click();
await expect(recovery.locator("pre")).toBeVisible();
await expect(recovery.locator("pre")).toContainText("ETARGET");
@@ -87,3 +90,32 @@ test("renders managed npm recovery with one retry action", async ({
.poll(() => sentFrames.find((frame) => frame.includes('"action":"session.recover"')) ?? "")
.toContain('"action":"runtime_retry"');
});
test("explains release-age policy failures with one retry action", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const task = await seedManagedRuntimeFailure(apiClient, seedData, "managed_runtime_npm_policy");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const recovery = session.activeChat().getByTestId("managed-runtime-npm-recovery");
await expect(recovery).toBeVisible();
await expect(
recovery.getByRole("heading", { name: "npm blocked this runtime version" }),
).toBeVisible();
await expect(recovery).toContainText(
"Check npm's min-release-age or before setting. Wait until this version is eligible or select an older version, then retry.",
);
await expect(recovery).not.toContainText("refreshed package data");
await expect(recovery.locator("details")).not.toHaveAttribute("open");
await expect(recovery.getByRole("button")).toHaveCount(1);
await expect(recovery.getByTestId("managed-runtime-npm-retry-button")).toBeVisible();
await prCapture.screenshot("managed-runtime-npm-release-age-policy-desktop", {
caption: "The recovery card explains an npm release-age policy block",
fullPage: true,
});
});
@@ -5,7 +5,13 @@ import type { SeedData } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
async function seedManagedRuntimeFailure(apiClient: ApiClient, seedData: SeedData) {
async function seedManagedRuntimeFailure(
apiClient: ApiClient,
seedData: SeedData,
failureKind:
| "managed_runtime_npm_resolution"
| "managed_runtime_npm_policy" = "managed_runtime_npm_resolution",
) {
const task = await apiClient.createTask(seedData.workspaceId, "Mobile managed npm recovery", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
@@ -19,8 +25,11 @@ async function seedManagedRuntimeFailure(apiClient: ApiClient, seedData: SeedDat
content: "managed npm runtime failed to prepare",
metadata: {
recovery_actions: true,
failure_kind: "managed_runtime_npm_resolution",
error_output: "npm error code ETARGET\nnpm error notarget No matching version found",
failure_kind: failureKind,
error_output:
failureKind === "managed_runtime_npm_policy"
? "npm error notarget No matching version found. Minimum release age policy applies.\n @example/agent@1.2.3 release date: <release-date>"
: "npm error code ETARGET\nnpm error notarget No matching version found",
actions: [
{
type: "ws_request",
@@ -41,7 +50,6 @@ test("keeps managed npm recovery touch-safe on mobile", async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const sentFrames: string[] = [];
testPage.on("websocket", (ws) => {
@@ -78,8 +86,37 @@ test("keeps managed npm recovery touch-safe on mobile", async ({
path: testInfo.outputPath("managed-runtime-npm-recovery-mobile.png"),
fullPage: true,
});
await prCapture.screenshot("managed-runtime-npm-recovery-mobile", {
caption: "Managed npm runtime recovery remains touch-safe on mobile",
});
test("keeps release-age policy guidance touch-safe on mobile", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const task = await seedManagedRuntimeFailure(apiClient, seedData, "managed_runtime_npm_policy");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const recovery = session.activeChat().getByTestId("managed-runtime-npm-recovery");
await expect(recovery).toBeVisible();
await expect(
recovery.getByRole("heading", { name: "npm blocked this runtime version" }),
).toBeVisible();
await expect(recovery).toContainText(
"Check npm's min-release-age or before setting. Wait until this version is eligible or select an older version, then retry.",
);
await expect(recovery.locator("details")).not.toHaveAttribute("open");
const retry = recovery.getByTestId("managed-runtime-npm-retry-button");
await expect(retry).toBeVisible();
await prCapture.screenshot("managed-runtime-npm-release-age-policy-mobile", {
caption: "Mobile recovery guidance for an npm release-age policy block",
fullPage: true,
});
const box = await retry.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
await retry.tap();
await expect(testPage.getByRole("dialog")).toHaveCount(0);
});
+2
View File
@@ -139,6 +139,8 @@
"technicalDetails": "Technical details",
"managedRuntimeNpmTitle": "npm could not prepare the runtime",
"managedRuntimeNpmBody": "Kandev refreshed package data and retried the same runtime once.",
"managedRuntimeNpmPolicyTitle": "npm blocked this runtime version",
"managedRuntimeNpmPolicyBody": "Check npm's min-release-age or before setting. Wait until this version is eligible or select an older version, then retry.",
"managedRuntimeRetry": "Retry runtime",
"theAssociatedWorktree": "The associated worktree",
"theTaskAndItsData": "The task and its data",
+2
View File
@@ -139,6 +139,8 @@
"technicalDetails": "技術的な詳細",
"managedRuntimeNpmTitle": "npm がランタイムを準備できませんでした",
"managedRuntimeNpmBody": "Kandev はパッケージデータを更新し、同じランタイムをもう一度試しました。",
"managedRuntimeNpmPolicyTitle": "npm がこのランタイムバージョンをブロックしました",
"managedRuntimeNpmPolicyBody": "npm の min-release-age または before 設定を確認してください。このバージョンが利用可能になるまで待つか、古いバージョンを選んで再試行してください。",
"managedRuntimeRetry": "ランタイムを再試行",
"theAssociatedWorktree": "関連付けられたワークツリー",
"theTaskAndItsData": "タスクとそのデータ",
+2
View File
@@ -139,6 +139,8 @@
"technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś",
"managedRuntimeNpmTitle": "ńƥḿ ćōũĺď ńōţ ƥŕēƥàŕē ţĥē ŕũńţĩḿē",
"managedRuntimeNpmBody": "Ķàńďēv ŕēƒŕēśĥēď ƥàćķàĝē ďàţà àńď ŕēţŕĩēď ţĥē śàḿē ŕũńţĩḿē ōńćē.",
"managedRuntimeNpmPolicyTitle": "ńƥḿ ƀĺōćķēď ţĥĩś ŕũńţĩḿē vēŕśĩōń",
"managedRuntimeNpmPolicyBody": "Ćĥēćķ ńƥḿ'ś ḿĩń-ŕēĺēàśē-àĝē ōŕ ƀēƒōŕē śēţţĩńĝ. Ŵàĩţ ũńţĩĺ ţĥĩś vēŕśĩōń ĩś ēĺĩĝĩƀĺē ōŕ śēĺēćţ àń ōĺďēŕ vēŕśĩōń, ţĥēń ŕēţŕŷ.",
"managedRuntimeRetry": "Ŕēţŕŷ ŕũńţĩḿē",
"theAssociatedWorktree": "Ţĥē àśśōćĩàţēď ŵōŕķţŕēē",
"theTaskAndItsData": "Ţĥē ţàśķ àńď ĩţś ďàţà",
+2
View File
@@ -123,6 +123,8 @@
"technicalDetails": "Detalhes técnicos",
"managedRuntimeNpmTitle": "O npm não conseguiu preparar o runtime",
"managedRuntimeNpmBody": "O Kandev atualizou os dados do pacote e repetiu o mesmo runtime uma vez.",
"managedRuntimeNpmPolicyTitle": "O npm bloqueou esta versão do runtime",
"managedRuntimeNpmPolicyBody": "Verifique a definição min-release-age ou before do npm. Aguarde até esta versão ficar elegível ou escolha uma versão mais antiga e tente novamente.",
"managedRuntimeRetry": "Repetir runtime",
"theAssociatedWorktree": "O worktree associado",
"theTaskAndItsData": "A tarefa e os respetivos dados",
+2
View File
@@ -63,6 +63,8 @@
"technicalDetails": "技术详情",
"managedRuntimeNpmTitle": "npm 无法准备运行时",
"managedRuntimeNpmBody": "Kandev 已刷新软件包数据,并重试了同一运行时一次。",
"managedRuntimeNpmPolicyTitle": "npm 阻止了此运行时版本",
"managedRuntimeNpmPolicyBody": "请检查 npm 的 min-release-age 或 before 设置。请等待此版本符合条件,或选择较旧版本后重试。",
"managedRuntimeRetry": "重试运行时",
"providerQuotaTitle": "{{provider}} 使用额度已达上限",
"newChatTab": "新建聊天",
+2
View File
@@ -63,6 +63,8 @@
"technicalDetails": "技術詳情",
"managedRuntimeNpmTitle": "npm 無法準備執行時",
"managedRuntimeNpmBody": "Kandev 已刷新軟件包數據,並重試了同一執行時一次。",
"managedRuntimeNpmPolicyTitle": "npm 阻止了此執行時版本",
"managedRuntimeNpmPolicyBody": "請檢查 npm 的 min-release-age 或 before 設定。請等待此版本符合條件,或選擇較舊版本後重試。",
"managedRuntimeRetry": "重試執行時",
"providerQuotaTitle": "{{provider}} 使用額度已達上限",
"newChatTab": "新建聊天",
+2
View File
@@ -63,6 +63,8 @@
"technicalDetails": "技術詳情",
"managedRuntimeNpmTitle": "npm 無法準備執行時",
"managedRuntimeNpmBody": "Kandev 已重新整理軟體包資料,並重試了同一執行時一次。",
"managedRuntimeNpmPolicyTitle": "npm 阻止了此執行時版本",
"managedRuntimeNpmPolicyBody": "請檢查 npm 的 min-release-age 或 before 設定。請等待此版本符合條件,或選擇較舊版本後重試。",
"managedRuntimeRetry": "重試執行時",
"providerQuotaTitle": "{{provider}} 使用額度已達上限",
"newChatTab": "新建聊天",