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