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": "新建聊天",
@@ -0,0 +1,50 @@
# ADR-2026-09-24-isolate-managed-npm-project-config: Isolate managed npm runtime project configuration
**Status:** accepted
**Date:** 2026-09-24
**Area:** backend, agentctl
## Context
Managed ACP processes start in a task workspace so the agent can work on the
repository. npm also treats that directory as its project configuration root.
A repository `.npmrc` with `min-release-age` can therefore reject a runtime
version that Kandev already prepared and validated on the host. Moving the
whole process out of the workspace would change agent behavior.
## Decision
Kandev uses the canonical npm marker `--prefix
~/.kandev/managed-npm-runtime` for every built-in managed npm command. Before
the command starts, its execution host replaces the marker with a private,
user-scoped directory under the system temporary root and creates it. This
keeps the prefix local to the npm process without placing it in the task
workspace or the agent home, which may be a host-mounted session directory.
The subprocess still starts in the workspace. Cache discovery and recovery use
the same resolved prefix and effective environment as the failed command. The
prefix is independent of an optional `KANDEV_HOME_DIR` override for state and
logs.
The repository's project `.npmrc` does not govern Kandev's managed runtime
resolution. Explicit environment overrides and user/global npm configuration
continue to apply. npm release-date policy failures that remain after this
isolation are reported distinctly and do not trigger stale-cache repair.
## Consequences
Every managed npm execution path must resolve and provision the canonical
prefix before invoking npm. Command validation and cache repair use the
canonical marker; the launched npm process receives the resolved temp path.
The cache key remains based on the exact package specification. Native and
passthrough commands retain their current working-directory and npm behavior.
## Alternatives Considered
- Changing the agent process working directory would break workspace-relative
behavior and ACP consumers of `process.cwd()`.
- Setting `npm_config_min_release_age=0` would leak into agent shell commands
and disable the repository's own supply-chain policy.
- Relying on the existing online retry would still apply npm's release-age
filter and could delete a healthy execution tree.
- Embedding the backend's absolute Kandev-home path in command metadata would
fail on Docker and SSH hosts with different filesystem roots.
@@ -0,0 +1,193 @@
---
created: 2026-09-24
status: draft
requirements:
- REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003
system_design:
- ../../specs/agents/system-design/managed-npm-runtime-recovery.md
legacy_specs: []
---
# Implementation Plan: Isolate Managed npm Runtime Configuration
## Overview
Issue [#3902](https://github.com/kdlbs/kandev/issues/3902) reports that a
recent managed ACP runtime starts successfully in other repositories but fails
in a task workspace whose `.npmrc` sets `min-release-age`. The agent process
uses that workspace as its working directory, so npm applies the repository's
project policy to Kandev's runtime package. Agentctl also drops npm's
date-qualified `notarget` line, leaving the failure as a generic ACP disconnect.
First isolate npm's project configuration for every managed runtime command.
Then classify any remaining release-date policy failure and show the correct
recovery explanation. These are sequential work orders so the first result can
be verified independently of the new error presentation.
## Scope
### In scope
- Managed npm preparation, probes, one-shot prompts, task launches, cache
discovery, and online-preferred retries on supported execution hosts.
- Strict date-qualified `ETARGET` classification, no automatic cache repair for
that class, and localized desktop, phone, and Office error presentation.
- Public troubleshooting text for repository `.npmrc` and remaining npm policy.
### Out of scope
- Native binaries, passthrough commands, dependency resolution policy inside
the agent's own shell, version rollback, or changing the npm registry.
- The broader managed installation redesign in issue #2419.
## Confirmed root cause and reproduction
- `process.Manager.buildFinalCommand` assigns `m.cfg.WorkDir` to the child. The
managed command from `ManagedNPMRuntimeSpec` is `npx --yes --prefer-offline
package@version`, so npm reads the workspace's project `.npmrc`.
- npm's date-qualified line does not match `npmresolution.MatchesExactPackage`.
`safeManagedNpmStderrLine` drops that line before the lifecycle manager reads
bounded stderr. The online retry therefore does not run; it would also be
ineffective while the age policy remains in force.
- The issue records the failing exact command and successful prefixed command.
A local npm 11.13.0 check from a project with an invalid registry found that
`--prefix` selects the other project's configuration. A real `npx` launch of
`cowsay@1.6.0` succeeded from that project with the prefix, retained the
workspace cwd, and used the same expected `_npx` key. A missing prefix
directory failed with `ENOENT`; provisioning is part of Task 01.
## Technical approach
### Managed command and executor boundary
- `apps/backend/internal/agent/agents/managed_npm_runtime.go` supplies the
trusted `--prefix ~/.kandev/managed-npm-runtime` argument for
`ACPCommandWithNpmPreference` and `CacheUpdateCommand`. Keep exact
package/version and ACP argument order.
- Treat that value as a canonical marker. Resolve it on the npm execution host
to a private, user-scoped directory under the system temporary root and
create it before invoking npm. Keep the resolved directory outside mounted
agent session homes as well as outside the task workspace. Cover the backend
host update runner,
`agentctl/server/process.Manager`, and `agentctl/server/utility.ACPInferenceExecutor`
probe and prompt paths. Never use a backend-host path for a remote task.
Preserve the child `cmd.Dir` and session cwd.
- Update `onlineManagedRuntimeArgs`, `managedRuntimeProbeRetry`, and
`managedRuntimeProbePackageSpec` to accept only the new trusted command shape.
Continue rejecting unversioned, foreign, native, and passthrough commands.
- `process.Manager.RepairManagedRuntimeCacheWithEnvironment` resolves npm cache
with the same prefix and effective environment as the failed launch. The
deterministic key stays derived from the exact package spec.
### Date-qualified failure
- `agentctl/server/process/safeManagedNpmStderrLine` retains a canonical,
bounded release-date marker without copying the raw date or unrelated stderr.
- `common/npmresolution` distinguishes ordinary exact-package `ETARGET` from
date-qualified `ETARGET`. Lifecycle and agentctl probe classification use the
same shared matcher. A policy failure bypasses cache invalidation and the
online retry.
- Add a stable policy failure code to `routingerr` and the probe contract.
Carry sanitized details into the existing failure record and recovery entry.
Kanban and Office render a policy-specific explanation through their current
inline recovery surfaces. Keep the current retry request for use after a
policy change or after the version becomes old enough.
- Add new copy to all required locale catalogs. Update
`docs/public/agents-and-profiles.md` as a how-to/troubleshooting section.
## ASCII UI preview
`UI-01: Managed runtime startup error`, entry point: task or Office chat after
an exact-package npm release-date failure. The existing inline card owns the
transcript position and details disclosure on both viewports. Text below is
illustrative; the policy cause, lack of cache-repair claim, and recovery action
are required by AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.3 and .4.
```text
Before (desktop and phone)
+--------------------------------------------------+
| Agent startup failed: The agent could not start. |
| [Retry] |
+--------------------------------------------------+
After (desktop)
+-------------------------------------------------------------+
| ! npm blocked the selected runtime version |
| Check min-release-age or before in npm settings. Wait for |
| the version to become eligible or select an older version. |
| Technical details > |
| [Retry runtime] |
+-------------------------------------------------------------+
After (phone)
+-------------------------------------+
| ! npm blocked this runtime |
| Check min-release-age or before. |
| Wait or select an older version. |
| Technical details > |
| [ Retry runtime (touch target) ] |
+-------------------------------------+
```
The phone card stays in the existing chat scroll owner. Its action remains at
least 44 px high and technical details stay collapsed until selected. No new
overlay or navigation is introduced. The nearest mobile exemplar is the
existing `mobile-managed-runtime-npm-recovery.spec.ts` card.
## Tests
| Acceptance | Regression evidence |
| --- | --- |
| 003.1, 003.2 | `TestManagedNPMRuntimeLaunchIgnoresWorkspaceNpmrc` plus command, prefix-provisioning, cache-key, and executor-local command tests in the managed runtime, process, utility, lifecycle, hostutility, and update-runner packages. This test must fail before the correction. |
| 003.3 | `TestManagedRuntimeReleaseAgePolicySkipsCacheRepair` in lifecycle and hostutility; exact-package/date matcher and safe stderr tests. |
| 003.4 | Orchestrator recovery metadata tests, Kanban/Office component tests, localization checks, and focused desktop/phone Playwright flows. |
## E2E tests
- AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.4: extend
`apps/web/e2e/tests/session/managed-runtime-npm-recovery.spec.ts` and
`mobile-managed-runtime-npm-recovery.spec.ts` with the policy failure's
distinct title/body, collapsed details, and retry action. Extend the Office
managed-runtime recovery spec for the same classification.
- AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.1: targeted Go process integration
evidence exercises the actual npm project-config selection. UI seeding alone
cannot prove npm's resolution behavior.
## Work orders
- [x] [Task 01: Isolate managed npm commands](task-01-isolate-managed-npm-commands.md)
- [x] [Task 02: Report release-age policy failures](task-02-report-release-age-policy-failures.md)
## Verification results
Task 01 and Task 02 targeted Go package suites passed, and
`make -C apps/backend build` passed. Focused web tests, typecheck, changed-file
ESLint, i18n check, production build, and desktop, mobile, and Office E2E checks
passed. The review correction was revalidated with the Task 02 Go suite and
backend build. Public documentation/spec validation and `git diff --check`
passed.
The PR fixup found that OpenCode mounts its full `/root` directory from the
host's per-container session root. Creating the original home-based npm prefix
inside that mount left root-owned files that blocked the E2E worker's temp-root
cleanup. The final implementation resolves the canonical marker to a
user-scoped directory under the execution host's system temp root. Unit tests
cover command preparation outside the agent home, and the focused Docker and
SSH recovery E2E checks passed for the full startup and cleanup paths.
Fixup validation passed: the affected managed-runtime, lifecycle, settings,
agentctl, and npm-resolution Go suites; the complete agentctl process package;
`make -C apps/backend build`; Docker and SSH recovery E2E (2 passed); public-doc
and specification validators; `bash -n` for the E2E npx fixture; Windows
amd64 test-binary compilation for the managed-runtime and process packages; and
`git diff --check`.
## Risks
- Every execution host must have a writable system temporary root; prefix
provisioning fails safely before npm starts if it does not.
- Command-shape guards, cache lookup, and update preparation must agree on the
same prefix. A mismatch could disable stale-metadata recovery or target the
wrong cache.
- Explicit user/global npm policy remains effective. Its date-qualified
failure must not be mistaken for stale metadata.
@@ -0,0 +1,109 @@
---
id: "01-isolate-managed-npm-commands"
title: "Isolate managed npm commands"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003
acceptance_criteria:
- AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.1
- AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.2
system_design:
- ../../specs/agents/system-design/managed-npm-runtime-recovery.md
---
# Task 01: Isolate managed npm commands
## Summary
Give built-in managed runtime npm commands a canonical project-prefix marker
that resolves on the execution host to a private system-temporary directory.
Keep that directory outside the task workspace and mounted agent session home.
Keep the agent process in its task workspace and apply the same resolved root
to preparation, probes, launches, retries, and cache discovery.
## In scope
- Start with a failing `TestManagedNPMRuntimeLaunchIgnoresWorkspaceNpmrc`
regression that demonstrates the reported project-config leak.
- Add the trusted prefix marker to managed launch and update command
construction and resolve and provision it under the execution host's system
temporary root on every host execution path. Preserve `cmd.Dir` and ACP
session cwd.
- Adjust exact-command recognizers and cache discovery to use the same prefix.
Confirm the exact package's `_npx` cache key remains unchanged.
## Out of scope
- Release-age error classification or presentation; that belongs to Task 02.
- Native, passthrough, and agent-invoked npm commands.
## Acceptance
- A repository `.npmrc` with `min-release-age` or a different registry does not
govern a managed runtime's npm resolution on local PC, Docker, or SSH; host
update/probe commands use the same isolation rule.
- A missing prefix is created before npm starts, and an unavailable prefix
fails safely without changing the task workspace or npm environment.
- Recovery still accepts the trusted exact package and rejects foreign command
shapes; cache repair resolves the same cache used by the failed command.
## Verification
```bash
(cd apps/backend && go test ./internal/agent/managedruntime ./internal/agent/agents ./internal/agent/runtime/lifecycle ./internal/agent/hostutility ./internal/agentctl/server/process ./internal/agentctl/server/utility ./internal/agent/settings/controller -count=1)
```
## Files likely touched
- `apps/backend/internal/agent/agents/managed_npm_runtime.go`
- `apps/backend/internal/agent/agents/managed_npm_runtime_test.go`
- `apps/backend/internal/agent/runtime/lifecycle/managed_runtime_recovery.go`
- `apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup_recovery_test.go`
- `apps/backend/internal/agent/hostutility/manager.go`
- `apps/backend/internal/agentctl/server/process/manager.go`
- `apps/backend/internal/agentctl/server/process/managed_runtime.go`
- `apps/backend/internal/agentctl/server/utility/acp_executor.go`
- `apps/backend/internal/agent/settings/controller/agent_update.go`
- `apps/backend/internal/agent/settings/controller/agent_update_job.go`
- Targeted adjacent tests for process, utility, hostutility, and update jobs.
## Dependencies
None.
## Risks
- The prefix path must be available on the execution host; backend-host paths
cannot be sent to Docker or SSH as if they were local.
- A prefix flag at the wrong side of npm's `--` separator would become an
agent argument instead of npm configuration.
## Parallelism
`sequential`
## Inputs
- `REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003`, the paired system design, and
ADR-2026-09-24-isolate-managed-npm-project-config.
- Existing command-shape and stale-cache recovery tests.
## Results
Done. Managed `npx` launch and `npm exec` preparation commands carry one
trusted prefix marker. The execution host resolves it to a private,
user-scoped directory under the system temporary root before launches, probes,
one-shot prompts, and cache discovery. The host update runner applies the same
rule. The task workspace remains the command working directory, and the exact
package spec still determines the same `_npx` cache key. Tests cover npm's
workspace-config isolation, prefix provisioning outside mounted agent home,
and unavailable-prefix handling.
Verification passed:
```bash
(cd apps/backend && go test ./internal/agent/agents ./internal/agent/runtime/lifecycle ./internal/agent/hostutility ./internal/agentctl/server/process ./internal/agentctl/server/utility ./internal/agent/settings/controller -count=1)
```
@@ -0,0 +1,156 @@
---
id: "02-report-release-age-policy-failures"
title: "Report release-age policy failures"
status: done
wave: 2
depends_on:
- "01-isolate-managed-npm-commands"
plan: "plan.md"
requirements:
- REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003
acceptance_criteria:
- AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.3
- AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.4
system_design:
- ../../specs/agents/system-design/managed-npm-runtime-recovery.md
---
# Task 02: Report release-age policy failures
## Summary
Classify npm's date-qualified exact-package `ETARGET` as a policy failure and
skip stale-cache recovery for it. Present one actionable, localized explanation
in task and Office chat while preserving sanitized technical details.
## In scope
- Start with a failing `TestManagedRuntimeReleaseAgePolicySkipsCacheRepair`
regression, plus a safe-stderr projection test for the issue's npm line.
- Extend the exact-package matcher and probe/lifecycle failure contract with a
distinct code. Check only the trusted top-level package and reject generic
disconnects, transitive failures, and malformed suffixes.
- Persist the code and safe details in the existing recovery entry. Show the
policy-specific copy and current retry action in Kanban and Office. Translate
new copy in all required locales and document troubleshooting.
## Out of scope
- Changing the selected runtime version, npm settings, or package registry.
- A second automatic retry or global npm cache cleanup.
## Acceptance
- Date-qualified `ETARGET` for the exact package produces the stable policy
error without cache invalidation or online-preferred retry.
- The desktop and phone recovery cards explain `min-release-age`/`before`, do
not claim cache refresh, and keep technical details collapsed and sanitized.
- Other npm errors retain their current classification and recovery behavior.
## ASCII UI preview
`UI-01: Managed runtime startup error` applies to the inline Kanban and Office
chat entry. See the [full preview](plan.md#ascii-ui-preview). The text is
illustrative; copy is localized. AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.4
requires the policy explanation and the existing recovery path.
```text
Desktop: [!] npm blocked the selected runtime version
Check min-release-age or before. Wait or select an older version.
Technical details > [Retry runtime]
Phone: [!] npm blocked this runtime
Check min-release-age or before.
Wait or select an older version.
Technical details >
[ Retry runtime (at least 44 px) ]
```
The existing transcript remains the only scroll owner. Details start closed.
No new overlay or navigation is introduced.
## Verification
```bash
(cd apps/backend && go test ./internal/common/npmresolution ./internal/agent/runtime/routingerr ./internal/agent/runtime/lifecycle ./internal/agent/hostutility ./internal/agentctl/server/process ./internal/agentctl/server/utility ./internal/orchestrator -count=1)
(cd apps/web && pnpm exec vitest run components/task/chat/messages/action-message.test.tsx components/task/simple/components/run-error-entry.test.tsx)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run --project chromium tests/session/managed-runtime-npm-recovery.spec.ts tests/office/managed-runtime-npm-recovery.spec.ts)
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/session/mobile-managed-runtime-npm-recovery.spec.ts)
```
## Files likely touched
- `apps/backend/internal/common/npmresolution/matcher.go`
- `apps/backend/internal/agentctl/server/process/managed_npm_stderr.go`
- `apps/backend/internal/agentctl/server/utility/acp_executor.go`
- `apps/backend/internal/agent/runtime/routingerr/routingerr.go`
- `apps/backend/internal/agent/runtime/routingerr/runtime_rules.go`
- `apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup.go`
- `apps/backend/internal/agent/hostutility/manager.go`
- `apps/backend/internal/orchestrator/event_handlers_agent.go`
- `apps/web/components/task/chat/messages/action-message.tsx`
- `apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx`
- `apps/web/src/locales/*/chat.json`
- `apps/web/e2e/tests/session/managed-runtime-npm-recovery.spec.ts`
- `apps/web/e2e/tests/session/mobile-managed-runtime-npm-recovery.spec.ts`
- `apps/web/e2e/tests/office/managed-runtime-npm-recovery.spec.ts`
- `docs/public/agents-and-profiles.md`
- Targeted adjacent backend and component tests.
## Dependencies
Task 01. Classification and probe tests use its trusted command shape.
## Risks
- The stderr sanitizer must retain only a safe marker, not npm log paths or
raw provider stderr.
- The policy-specific code must not select the stale-metadata card, whose
existing copy says Kandev already repaired the cache.
## Parallelism
`sequential`
## Inputs
- `REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003`, the paired system design, and
existing Kanban, Office, and mobile recovery examples.
## Results
Implemented strict release-date-qualified classification for the exact managed
package, retained only a canonical date marker in sanitized details, and skips
cache repair and online retry for a policy failure. Task and Office recovery
surfaces show localized `min-release-age` / `before` guidance with one retry;
the public runtime recovery guide documents project `.npmrc` isolation and
remaining user/global policy.
Review correction: the parser accepts npm 11.16's observed
`M/D/YYYY, h:mm:ss AM/PM` date as well as RFC3339-shaped dates for
compatibility. Lifecycle classification checks the raw bounded diagnostic
against the trusted exact package before generic redaction, then persists a
fixed policy excerpt with the canonical date marker. Regression tests include
the exact issue line at parser, sanitizer, lifecycle, routing-classifier, and
agentctl probe boundaries, plus malformed-date and mismatched-package cases.
Review correction: classification runs before the runtime cache-repair support
gate, so Kubernetes and Sprites surface the exact trusted policy failure while
ordinary resolution errors on those runtimes remain on the generic path.
Settings update jobs also classify a bounded 64 KiB output tail against the
trusted update spec before cache invalidation in both legacy and exact-candidate
flows. A matching failure returns a safe typed policy error without retrying;
ordinary update failures retain their existing repair behavior. Prefix
provisioning resolves the canonical marker to a private user-scoped directory
under the execution host's system temporary root, outside mounted agent homes.
Public Muse and Pi descriptions name the managed packages without exposing
this internal path.
The targeted Go suites passed, including lifecycle, host probe, agentctl, npm
matcher, routing, orchestrator, agent registration, and settings packages.
`make -C apps/backend build` passed. Focused web tests, typecheck, changed-file
ESLint, i18n check, production build, desktop/Office and mobile E2E, public-doc
validators, spec lint, and `git diff --check` passed after the review correction.
The direct `build:vite` script does not run generated-file prebuild hooks; use
`pnpm --filter @kandev/web build`.
+12 -4
View File
@@ -38,8 +38,8 @@ Muse Code has no native ACP server, so Kandev runs it through the community
[`@bex-co/muse-code-acp`](https://github.com/bex-co/muse-code-acp) adapter,
which is not affiliated with Meta:
- Structured ACP sessions and one-shot inference use
`npx --yes --prefer-offline @bex-co/muse-code-acp@<effective-version>`, which
- Structured ACP sessions and one-shot inference use the managed
`@bex-co/muse-code-acp` package at the selected effective version. This
needs Node.js 22 or later.
- The adapter drives the native `muse` executable through `muse serve`; CLI
Passthrough starts `muse` directly.
@@ -67,8 +67,8 @@ pin one model for both, set it there, for example
Pi uses separate executables for its two Kandev modes:
- Structured ACP sessions and one-shot inference use
`npx --yes --prefer-offline pi-acp@<effective-version>`.
- Structured ACP sessions and one-shot inference use the managed `pi-acp`
package at the selected effective version.
- CLI Passthrough starts the globally installed `pi` executable.
- The Pi install action runs `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`.
@@ -177,6 +177,14 @@ silently select another version. When the retry succeeds, no recovery card is
shown. When it fails again, Kandev and Office show one **Retry runtime** action
with collapsed technical details.
Managed `npx` runtimes use a Kandev-owned npm project directory, so an `.npmrc`
in the task repository does not change the managed runtime's package lookup.
The task repository remains the command's working directory. User and global npm
settings still apply, including `min-release-age` and `before` policies. If an
age policy blocks the selected version, Kandev does not repair the npm cache or
repeat the lookup online. Wait until the version meets the policy, or select an
older trusted version, then choose **Retry runtime**.
Do not use `npm cache clean --force` as the normal recovery step. It removes
unrelated npm data and does not target the stale execution tree. If the
specialized retry cannot resolve the runtime, check that the Kandev service
@@ -17,6 +17,10 @@ Kandev repairs this error without requiring the end user to operate npm. The
same behavior applies to host capability probes and to agent launches on local
PC, local Docker, and remote SSH executors.
Managed runtime package resolution is independent of the task repository's
project npm configuration. A repository release-age policy must not prevent an
operator-selected, successfully validated runtime from starting.
## Terminology
- **Managed npm runtime:** A built-in ACP runtime that Kandev launches through an exact npm package version.
@@ -54,9 +58,20 @@ PC, local Docker, and remote SSH executors.
- **AC-AGENTS-MANAGED-RUNTIME-RECOVERY-002.4:** Cache repair shall reject broad roots, path-like package values, symbolic links, and paths outside the npm execution cache.
- **AC-AGENTS-MANAGED-RUNTIME-RECOVERY-002.5:** Cancellation and backend shutdown shall stop repair before a replacement process starts.
### REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003: Project-independent resolution and policy errors
**Intent:** A task repository shall not control how Kandev resolves its managed agent runtime, and a remaining npm release-date restriction shall have an actionable failure.
#### Acceptance criteria
- **AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.1:** When Kandev prepares, probes, or launches a built-in managed npm runtime, the task repository's project `.npmrc`, including `min-release-age` and `before`, shall not affect runtime package resolution. The npm project root shall stay outside the task workspace and mounted agent session state. This shall hold for host utility probes and local PC, local Docker, and remote SSH launches.
- **AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.2:** Isolation shall preserve the agent's workspace working directory, exact selected package and version, configured registry, explicit npm environment overrides, ACP arguments, and existing npm execution-cache identity. Native and passthrough commands shall remain unchanged.
- **AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.3:** When npm reports a release-date-qualified `ETARGET` for the exact selected managed package from configuration that still applies, during startup, a capability probe, or a Settings update, Kandev shall report a distinct, actionable npm policy failure. It shall not invalidate the execution cache or attempt an online-preferred retry for that failure.
- **AC-AGENTS-MANAGED-RUNTIME-RECOVERY-003.4:** The release-date failure shall retain bounded, sanitized technical details and one recovery explanation on desktop and phone. The explanation shall identify `min-release-age` or `before` as settings to check without claiming that cache repair was attempted. A Settings update error shall identify the policy without exposing the raw npm date. Unrelated packages, malformed diagnostics, and generic ACP disconnects shall not receive this classification.
## Out of scope
- Automatic version rollback or selection of another package version.
- Registry replacement, dependency substitution, or global npm cache cleanup.
- Native runtimes, passthrough commands, unrelated npm errors, and a second online retry.
- Sprites, remote Docker, Kubernetes, and future executors without the same authenticated executor-local repair contract.
- Automatic cache repair and retry for Sprites, remote Docker, Kubernetes, and future executors without the same authenticated executor-local repair contract. Kandev still classifies a policy failure when bounded diagnostics are available for the exact trusted package.
@@ -4,6 +4,7 @@ system: agents
requirements:
- REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-001
- REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-002
- REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003
---
# Managed npm runtime recovery system design
@@ -25,6 +26,7 @@ Kandev backend.
| --- | --- |
| `REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-001` | [Recovery flow](#recovery-flow) |
| `REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-002` | [Executor-local cache contract](#executor-local-cache-contract) |
| `REQ-AGENTS-MANAGED-RUNTIME-RECOVERY-003` | [Project-independent npm configuration](#project-independent-npm-configuration); [Release-date policy failure](#release-date-policy-failure) |
## Components and responsibilities
@@ -39,6 +41,40 @@ Kandev backend.
- `agentctl/server/process.Manager` runs `npm config get cache` with the configured agent environment.
- `agent/managedruntime` validates the package specification and removes one deterministic `_npx` tree.
## Project-independent npm configuration
Managed npm command construction adds the canonical marker
`--prefix ~/.kandev/managed-npm-runtime`. Before the command starts, the
execution host replaces that marker with a private, user-scoped directory
under its system temporary root and creates the directory. The path is
resolved where npm runs, so the same trusted command works on the backend
host, in Docker, and over SSH without embedding a backend-host path.
The project root must stay outside both the task workspace and the mounted
agent session home. Some container agents, including OpenCode, mount their
whole home from host-managed session state. Creating the project root under
that home can leave root-owned files in a host temporary directory. The
system temporary root avoids that mount while remaining local to the npm
execution host. A missing or unavailable prefix fails safely before npm
starts.
Apply the same prefix to exact-version cache preparation, capability probes,
one-shot prompts, normal task launches, and online-preferred retries. The
Kandev-owned prefix is independent of both the task workspace and an optional
`KANDEV_HOME_DIR` override for Kandev state and logs.
The child process still starts in the task workspace, and ACP receives that
workspace as its session cwd. Only npm's project configuration root changes.
User-level and global npm configuration, the selected registry, and explicit
environment overrides remain in force. The command's top-level package spec
and npm cache remain the same. Cache discovery for a failed launch must use the
same prefix and effective environment as that launch; otherwise a repository
`.npmrc` can direct repair to a different cache. A prefix preparation failure
stops startup with a sanitized runtime error before launching npm.
Keep exact-command recognition in lifecycle recovery, host utility recovery,
and agentctl probe classification aligned with the new argument shape. Their
trusted package and version checks continue to reject arbitrary commands.
## Probe failure contract
`ProbeResponse` can carry a stable managed-runtime npm-resolution failure code.
@@ -109,15 +145,49 @@ second npm attempt fails, Kandev emits `managed_runtime_npm_resolution`.
Both errors contain bounded sanitized details. The UI keeps the existing
single **Retry runtime** action. Kandev does not change the active version.
Unsupported runtime types do not call the repair endpoint. Native commands,
passthrough commands, unrelated npm errors, and repeated failures remain on the
normal terminal error path.
Unsupported runtime types do not call the repair endpoint for ordinary npm
resolution failures. Kandev classifies an exact release-date policy failure
before checking repair support when the bounded diagnostic is available.
Native commands, passthrough commands, unrelated npm errors, and repeated
failures remain on the normal terminal error path.
A host capability probe that cannot repair or fails its online retry publishes
the final failure normally. Kandev does not hide a runtime that still cannot
start, change its version, or substitute a stale capability catalogue from a
different runtime generation.
## Release-date policy failure
The agentctl stderr projection recognizes npm's exact top-level `notarget`
line with the bounded `with a date before` suffix. It accepts the observed npm
11.16 date form (`M/D/YYYY, h:mm:ss AM/PM`) and retains RFC3339-shaped dates
for compatibility. Invalid calendar or time values are rejected. Agentctl
preserves a canonical date-qualified marker without copying the raw date or
any other stderr line.
For task startup, the lifecycle manager passes the trusted package spec with
the bounded diagnostic to `routingerr`. Classification checks the raw line
against that exact spec before generic diagnostic redaction can treat the
scoped package or locale-formatted date as a path or opaque token. The persisted
excerpt contains only a fixed policy message and the canonical date marker.
Ordinary exact-version `ETARGET` retains the stale metadata repair path. The
same distinction applies to host capability probes.
Settings update jobs classify bounded output against the trusted exact package
spec before invalidating the execution cache. Both legacy and exact-candidate
updates fail with a safe policy error when the diagnostic matches; they do not
retry or expose the raw locale-formatted date in the job error. Ordinary update
failures retain the existing cache-repair path.
The lifecycle manager publishes a stable policy failure code before cache
repair. The orchestrator persists one sanitized recovery entry with a localized
policy explanation and an ordinary retry action. The entry must not use the
existing stale-metadata card, whose copy says Kandev repaired the cache.
Office consumes the same code and explanation. Desktop and phone reuse the
current inline recovery presentation, collapsed details, transcript scroll
owner, and phone touch targets from the session recovery design. No new page,
dialog, or storage table is needed.
## Observability
Structured recovery logs include the recovery scope, agent ID, attempt, outcome,
@@ -132,3 +202,4 @@ No database migration is necessary.
- [Validate and persist managed runtime version selection](../../../decisions/2026-08-12-validated-managed-runtime-version-selection.md)
- [Run cache repair where npm runs](../../../decisions/2026-08-24-agentctl-local-managed-runtime-cache-repair.md)
- [Recover host capability probes before publishing failure](../../../decisions/2026-09-07-host-utility-managed-runtime-recovery.md)
- [Isolate managed npm runtime project configuration](../../../decisions/2026-09-24-isolate-managed-npm-project-config.md)