diff --git a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md index 9405d57847..88494e0758 100644 --- a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md +++ b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md @@ -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@` with the ACP -arguments above. For example, an unmodified Claude installation launches -`npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp@`. +use `npx --yes --prefer-offline --prefix ~/.kandev/managed-npm-runtime +package@` 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 `` 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 diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime.go b/apps/backend/internal/agent/agents/managed_npm_runtime.go index ba1191b9c6..a41c4c1c53 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime.go @@ -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", diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go index b13d3625b5..95bf78e09c 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -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) +} diff --git a/apps/backend/internal/agent/agents/new_acp_agents_test.go b/apps/backend/internal/agent/agents/new_acp_agents_test.go index 0c982e187c..78d89ff8a5 100644 --- a/apps/backend/internal/agent/agents/new_acp_agents_test.go +++ b/apps/backend/internal/agent/agents/new_acp_agents_test.go @@ -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, diff --git a/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go b/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go index 2927719055..83442adb61 100644 --- a/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go +++ b/apps/backend/internal/agent/hostutility/managed_runtime_recovery_test.go @@ -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()) diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index bf23359c08..62d6ab43ce 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -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 } diff --git a/apps/backend/internal/agent/managedruntime/npm_project.go b/apps/backend/internal/agent/managedruntime/npm_project.go new file mode 100644 index 0000000000..03f4bf0f59 --- /dev/null +++ b/apps/backend/internal/agent/managedruntime/npm_project.go @@ -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 +} diff --git a/apps/backend/internal/agent/managedruntime/npm_project_test.go b/apps/backend/internal/agent/managedruntime/npm_project_test.go new file mode 100644 index 0000000000..4c91619f08 --- /dev/null +++ b/apps/backend/internal/agent/managedruntime/npm_project_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agent/registry/provider_test.go b/apps/backend/internal/agent/registry/provider_test.go index f473965867..00d8a8237f 100644 --- a/apps/backend/internal/agent/registry/provider_test.go +++ b/apps/backend/internal/agent/registry/provider_test.go @@ -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 { diff --git a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go index 9070a3d12e..a2b2121adc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go @@ -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) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_recovery.go b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_recovery.go index 156f552285..49abfada49 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_recovery.go +++ b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_recovery.go @@ -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 diff --git a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup.go b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup.go index 6665619a63..2ee5d97052 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup.go @@ -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 diff --git a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup_recovery_test.go b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup_recovery_test.go index be7619535a..f52594cec0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup_recovery_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_startup_recovery_test.go @@ -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, "") { + 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") diff --git a/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go b/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go index 5e2bec7d5c..ad422ccb8a 100644 --- a/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go @@ -30,6 +30,7 @@ func TestClassForCodeCoversProviderCatalogue(t *testing.T) { {CodePermissionDeniedByUser, ClassUnclassified}, {CodeNpxCacheCorrupted, ClassUnclassified}, {CodeManagedRuntimeNpmResolution, ClassUnclassified}, + {CodeManagedRuntimeNpmPolicy, ClassUnclassified}, {CodeResumeCorrupted, ClassUnclassified}, } diff --git a/apps/backend/internal/agent/runtime/routingerr/npm_resolution.go b/apps/backend/internal/agent/runtime/routingerr/npm_resolution.go index 32eb6447cf..2aa7222d08 100644 --- a/apps/backend/internal/agent/runtime/routingerr/npm_resolution.go +++ b/apps/backend/internal/agent/runtime/routingerr/npm_resolution.go @@ -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: ", + } +} diff --git a/apps/backend/internal/agent/runtime/routingerr/npm_resolution_test.go b/apps/backend/internal/agent/runtime/routingerr/npm_resolution_test.go index deda867407..8a93a6753c 100644 --- a/apps/backend/internal/agent/runtime/routingerr/npm_resolution_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/npm_resolution_test.go @@ -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, "") || 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 { diff --git a/apps/backend/internal/agent/runtime/routingerr/routingerr.go b/apps/backend/internal/agent/runtime/routingerr/routingerr.go index c03e87eac1..e464e4cdab 100644 --- a/apps/backend/internal/agent/runtime/routingerr/routingerr.go +++ b/apps/backend/internal/agent/runtime/routingerr/routingerr.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/agent_update.go b/apps/backend/internal/agent/settings/controller/agent_update.go index c175f44d93..460f1c9e42 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update.go +++ b/apps/backend/internal/agent/settings/controller/agent_update.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/agent_update_job.go b/apps/backend/internal/agent/settings/controller/agent_update_job.go index 29621d2b44..27842f60c8 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_job.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_job.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go b/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go index 17940608ac..5b7caf8b55 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go @@ -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{ diff --git a/apps/backend/internal/agent/settings/controller/agent_update_test.go b/apps/backend/internal/agent/settings/controller/agent_update_test.go index f4ce69d9ee..c8d5310a22 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_test.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 3b4fabff84..6177dc508b 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -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) } diff --git a/apps/backend/internal/agentctl/server/process/managed_npm_stderr.go b/apps/backend/internal/agentctl/server/process/managed_npm_stderr.go index e3f28b1558..4e5d987093 100644 --- a/apps/backend/internal/agentctl/server/process/managed_npm_stderr.go +++ b/apps/backend/internal/agentctl/server/process/managed_npm_stderr.go @@ -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) { diff --git a/apps/backend/internal/agentctl/server/process/managed_npm_stderr_test.go b/apps/backend/internal/agentctl/server/process/managed_npm_stderr_test.go new file mode 100644 index 0000000000..b37da186d0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/managed_npm_stderr_test.go @@ -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 ", + }, + { + 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 ", + }, + } + 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) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/managed_runtime.go b/apps/backend/internal/agentctl/server/process/managed_runtime.go index 70a03c82cd..2c90720942 100644 --- a/apps/backend/internal/agentctl/server/process/managed_runtime.go +++ b/apps/backend/internal/agentctl/server/process/managed_runtime.go @@ -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, diff --git a/apps/backend/internal/agentctl/server/process/managed_runtime_test.go b/apps/backend/internal/agentctl/server/process/managed_runtime_test.go index 4e01904b3c..181b35e63b 100644 --- a/apps/backend/internal/agentctl/server/process/managed_runtime_test.go +++ b/apps/backend/internal/agentctl/server/process/managed_runtime_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index add9f89b9f..6022012d7d 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -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 } diff --git a/apps/backend/internal/agentctl/server/process/manager_temp_test.go b/apps/backend/internal/agentctl/server/process/manager_temp_test.go index 26300c5c38..a4340d5fca 100644 --- a/apps/backend/internal/agentctl/server/process/manager_temp_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_temp_test.go @@ -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") diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index bfa932da0e..b739fd8212 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -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 . + 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 } diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor_test.go b/apps/backend/internal/agentctl/server/utility/acp_executor_test.go index 9a2263a943..b8a44cdec4 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor_test.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor_test.go @@ -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() diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 3a2c40c775..4075d3f3a4 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -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. diff --git a/apps/backend/internal/common/npmresolution/matcher.go b/apps/backend/internal/common/npmresolution/matcher.go index f578fedb57..5f75a11758 100644 --- a/apps/backend/internal/common/npmresolution/matcher.go +++ b/apps/backend/internal/common/npmresolution/matcher.go @@ -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+\.?\s*$`, +) + +const ReleaseDateMarker = "" // 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 +} diff --git a/apps/backend/internal/common/npmresolution/matcher_test.go b/apps/backend/internal/common/npmresolution/matcher_test.go new file mode 100644 index 0000000000..490b970efb --- /dev/null +++ b/apps/backend/internal/common/npmresolution/matcher_test.go @@ -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") + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 137cc7edc0..6f7f511a87 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -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. diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 109d492e70..7aad89f7fc 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -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 ", + })) + 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) { diff --git a/apps/backend/internal/orchestrator/recovery_actions_test.go b/apps/backend/internal/orchestrator/recovery_actions_test.go index 84aa3b8b31..d9b9b7aafd 100644 --- a/apps/backend/internal/orchestrator/recovery_actions_test.go +++ b/apps/backend/internal/orchestrator/recovery_actions_test.go @@ -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 ", + }, "")) + + 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 " { + 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) diff --git a/apps/web/components/task/chat/messages/action-message.test.tsx b/apps/web/components/task/chat/messages/action-message.test.tsx index bc07cc33bd..c8eb453207 100644 --- a/apps/web/components/task/chat/messages/action-message.test.tsx +++ b/apps/web/components/task/chat/messages/action-message.test.tsx @@ -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: ", + 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), + "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", () => { diff --git a/apps/web/components/task/chat/messages/action-message.tsx b/apps/web/components/task/chat/messages/action-message.tsx index bcb71d453a..826cf9f7d4 100644 --- a/apps/web/components/task/chat/messages/action-message.tsx +++ b/apps/web/components/task/chat/messages/action-message.tsx @@ -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 ( void; }) { const { t } = useTranslation(); + const isPolicyFailure = metadata.failure_kind === "managed_runtime_npm_policy"; const actions = metadata.actions?.slice(0, 1) ?? []; return (

- {t("chat:managedRuntimeNpmTitle")} + {t( + isPolicyFailure ? "chat:managedRuntimeNpmPolicyTitle" : "chat:managedRuntimeNpmTitle", + )}

- {t("chat:managedRuntimeNpmBody")} + {t(isPolicyFailure ? "chat:managedRuntimeNpmPolicyBody" : "chat:managedRuntimeNpmBody")}

{actions.length > 0 && ( diff --git a/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx b/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx index 5ca3eb9476..1be21d57dc 100644 --- a/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx +++ b/apps/web/components/task/simple/components/managed-runtime-npm-run-error.tsx @@ -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({ {agentName} {formatRelativeTime(error.failedAt)}
-

{t("chat:managedRuntimeNpmBody")}

+

+ {t(isPolicyFailure ? "chat:managedRuntimeNpmPolicyBody" : "chat:managedRuntimeNpmBody")} +

{technicalDetails && ( diff --git a/apps/web/components/task/simple/components/run-error-entry.test.tsx b/apps/web/components/task/simple/components/run-error-entry.test.tsx index 2bb375b65e..301a1287b7 100644 --- a/apps/web/components/task/simple/components/run-error-entry.test.tsx +++ b/apps/web/components/task/simple/components/run-error-entry.test.tsx @@ -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( + , + ); + + 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", () => { , ); - 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(); }); }); diff --git a/apps/web/components/task/simple/components/run-error-entry.tsx b/apps/web/components/task/simple/components/run-error-entry.tsx index 98436e534e..6d1ba25636 100644 --- a/apps/web/components/task/simple/components/run-error-entry.tsx +++ b/apps/web/components/task/simple/components/run-error-entry.tsx @@ -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 ( "$target_dir/fresh-marker" printf '%s\n' "$package_spec" >> "$online_invocations" - shift 3 + shift "$shift_count" exec "$mock_agent" "$@" fi diff --git a/apps/web/e2e/tests/office/managed-runtime-npm-recovery.spec.ts b/apps/web/e2e/tests/office/managed-runtime-npm-recovery.spec.ts index a245da65bb..a76bb0de00 100644 --- a/apps/web/e2e/tests/office/managed-runtime-npm-recovery.spec.ts +++ b/apps/web/e2e/tests/office/managed-runtime-npm-recovery.spec.ts @@ -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: ", + }, + }, + }); + + 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"'); +}); diff --git a/apps/web/e2e/tests/session/managed-runtime-npm-recovery.spec.ts b/apps/web/e2e/tests/session/managed-runtime-npm-recovery.spec.ts index 0b9c7884d6..d97fdbda05 100644 --- a/apps/web/e2e/tests/session/managed-runtime-npm-recovery.spec.ts +++ b/apps/web/e2e/tests/session/managed-runtime-npm-recovery.spec.ts @@ -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: " + : "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, + }); +}); diff --git a/apps/web/e2e/tests/session/mobile-managed-runtime-npm-recovery.spec.ts b/apps/web/e2e/tests/session/mobile-managed-runtime-npm-recovery.spec.ts index ad1b18c8b6..31332f875e 100644 --- a/apps/web/e2e/tests/session/mobile-managed-runtime-npm-recovery.spec.ts +++ b/apps/web/e2e/tests/session/mobile-managed-runtime-npm-recovery.spec.ts @@ -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: " + : "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); }); diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index 8c8bd59dad..a8d66e748d 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -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", diff --git a/apps/web/src/locales/ja/chat.json b/apps/web/src/locales/ja/chat.json index d687ae3f8c..b887650726 100644 --- a/apps/web/src/locales/ja/chat.json +++ b/apps/web/src/locales/ja/chat.json @@ -139,6 +139,8 @@ "technicalDetails": "技術的な詳細", "managedRuntimeNpmTitle": "npm がランタイムを準備できませんでした", "managedRuntimeNpmBody": "Kandev はパッケージデータを更新し、同じランタイムをもう一度試しました。", + "managedRuntimeNpmPolicyTitle": "npm がこのランタイムバージョンをブロックしました", + "managedRuntimeNpmPolicyBody": "npm の min-release-age または before 設定を確認してください。このバージョンが利用可能になるまで待つか、古いバージョンを選んで再試行してください。", "managedRuntimeRetry": "ランタイムを再試行", "theAssociatedWorktree": "関連付けられたワークツリー", "theTaskAndItsData": "タスクとそのデータ", diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index 1cf69ba7f9..4e714b29cf 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -139,6 +139,8 @@ "technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś", "managedRuntimeNpmTitle": "ńƥḿ ćōũĺď ńōţ ƥŕēƥàŕē ţĥē ŕũńţĩḿē", "managedRuntimeNpmBody": "Ķàńďēv ŕēƒŕēśĥēď ƥàćķàĝē ďàţà àńď ŕēţŕĩēď ţĥē śàḿē ŕũńţĩḿē ōńćē.", + "managedRuntimeNpmPolicyTitle": "ńƥḿ ƀĺōćķēď ţĥĩś ŕũńţĩḿē vēŕśĩōń", + "managedRuntimeNpmPolicyBody": "Ćĥēćķ ńƥḿ'ś ḿĩń-ŕēĺēàśē-àĝē ōŕ ƀēƒōŕē śēţţĩńĝ. Ŵàĩţ ũńţĩĺ ţĥĩś vēŕśĩōń ĩś ēĺĩĝĩƀĺē ōŕ śēĺēćţ àń ōĺďēŕ vēŕśĩōń, ţĥēń ŕēţŕŷ.", "managedRuntimeRetry": "Ŕēţŕŷ ŕũńţĩḿē", "theAssociatedWorktree": "Ţĥē àśśōćĩàţēď ŵōŕķţŕēē", "theTaskAndItsData": "Ţĥē ţàśķ àńď ĩţś ďàţà", diff --git a/apps/web/src/locales/pt-pt/chat.json b/apps/web/src/locales/pt-pt/chat.json index 82f03ddcba..ffd5bdd12f 100644 --- a/apps/web/src/locales/pt-pt/chat.json +++ b/apps/web/src/locales/pt-pt/chat.json @@ -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", diff --git a/apps/web/src/locales/zh-cn/chat.json b/apps/web/src/locales/zh-cn/chat.json index cdd0479846..ff0401e1c9 100644 --- a/apps/web/src/locales/zh-cn/chat.json +++ b/apps/web/src/locales/zh-cn/chat.json @@ -63,6 +63,8 @@ "technicalDetails": "技术详情", "managedRuntimeNpmTitle": "npm 无法准备运行时", "managedRuntimeNpmBody": "Kandev 已刷新软件包数据,并重试了同一运行时一次。", + "managedRuntimeNpmPolicyTitle": "npm 阻止了此运行时版本", + "managedRuntimeNpmPolicyBody": "请检查 npm 的 min-release-age 或 before 设置。请等待此版本符合条件,或选择较旧版本后重试。", "managedRuntimeRetry": "重试运行时", "providerQuotaTitle": "{{provider}} 使用额度已达上限", "newChatTab": "新建聊天", diff --git a/apps/web/src/locales/zh-hk/chat.json b/apps/web/src/locales/zh-hk/chat.json index 540bf01a9a..0e5d891c95 100644 --- a/apps/web/src/locales/zh-hk/chat.json +++ b/apps/web/src/locales/zh-hk/chat.json @@ -63,6 +63,8 @@ "technicalDetails": "技術詳情", "managedRuntimeNpmTitle": "npm 無法準備執行時", "managedRuntimeNpmBody": "Kandev 已刷新軟件包數據,並重試了同一執行時一次。", + "managedRuntimeNpmPolicyTitle": "npm 阻止了此執行時版本", + "managedRuntimeNpmPolicyBody": "請檢查 npm 的 min-release-age 或 before 設定。請等待此版本符合條件,或選擇較舊版本後重試。", "managedRuntimeRetry": "重試執行時", "providerQuotaTitle": "{{provider}} 使用額度已達上限", "newChatTab": "新建聊天", diff --git a/apps/web/src/locales/zh-tw/chat.json b/apps/web/src/locales/zh-tw/chat.json index bcb2b04b6e..4b070309bb 100644 --- a/apps/web/src/locales/zh-tw/chat.json +++ b/apps/web/src/locales/zh-tw/chat.json @@ -63,6 +63,8 @@ "technicalDetails": "技術詳情", "managedRuntimeNpmTitle": "npm 無法準備執行時", "managedRuntimeNpmBody": "Kandev 已重新整理軟體包資料,並重試了同一執行時一次。", + "managedRuntimeNpmPolicyTitle": "npm 阻止了此執行時版本", + "managedRuntimeNpmPolicyBody": "請檢查 npm 的 min-release-age 或 before 設定。請等待此版本符合條件,或選擇較舊版本後重試。", "managedRuntimeRetry": "重試執行時", "providerQuotaTitle": "{{provider}} 使用額度已達上限", "newChatTab": "新建聊天", diff --git a/docs/decisions/2026-09-24-isolate-managed-npm-project-config.md b/docs/decisions/2026-09-24-isolate-managed-npm-project-config.md new file mode 100644 index 0000000000..3038a23a26 --- /dev/null +++ b/docs/decisions/2026-09-24-isolate-managed-npm-project-config.md @@ -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. diff --git a/docs/plans/managed-npm-project-config-isolation/plan.md b/docs/plans/managed-npm-project-config-isolation/plan.md new file mode 100644 index 0000000000..267807d371 --- /dev/null +++ b/docs/plans/managed-npm-project-config-isolation/plan.md @@ -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. diff --git a/docs/plans/managed-npm-project-config-isolation/task-01-isolate-managed-npm-commands.md b/docs/plans/managed-npm-project-config-isolation/task-01-isolate-managed-npm-commands.md new file mode 100644 index 0000000000..ca56f26128 --- /dev/null +++ b/docs/plans/managed-npm-project-config-isolation/task-01-isolate-managed-npm-commands.md @@ -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) +``` diff --git a/docs/plans/managed-npm-project-config-isolation/task-02-report-release-age-policy-failures.md b/docs/plans/managed-npm-project-config-isolation/task-02-report-release-age-policy-failures.md new file mode 100644 index 0000000000..d73e4ac62d --- /dev/null +++ b/docs/plans/managed-npm-project-config-isolation/task-02-report-release-age-policy-failures.md @@ -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`. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 69b91b733b..512ff6702a 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -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@`, 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@`. +- 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 diff --git a/docs/specs/agents/requirements/managed-npm-runtime-recovery.md b/docs/specs/agents/requirements/managed-npm-runtime-recovery.md index cffaecac63..c6bc90994c 100644 --- a/docs/specs/agents/requirements/managed-npm-runtime-recovery.md +++ b/docs/specs/agents/requirements/managed-npm-runtime-recovery.md @@ -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. diff --git a/docs/specs/agents/system-design/managed-npm-runtime-recovery.md b/docs/specs/agents/system-design/managed-npm-runtime-recovery.md index e309b016f6..986b5de7f9 100644 --- a/docs/specs/agents/system-design/managed-npm-runtime-recovery.md +++ b/docs/specs/agents/system-design/managed-npm-runtime-recovery.md @@ -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)