diff --git a/apps/backend/internal/launcher/service.go b/apps/backend/internal/launcher/service.go index 3d913df0fe..353cedcd8e 100644 --- a/apps/backend/internal/launcher/service.go +++ b/apps/backend/internal/launcher/service.go @@ -480,7 +480,7 @@ type nativeServiceUnitInput struct { } const ( - systemdServicePath = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin" + systemdServicePath = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:%h/.npm-global/bin" launchdServicePath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" ) diff --git a/apps/backend/internal/launcher/service_npm_path_test.go b/apps/backend/internal/launcher/service_npm_path_test.go new file mode 100644 index 0000000000..6ce2f0bba3 --- /dev/null +++ b/apps/backend/internal/launcher/service_npm_path_test.go @@ -0,0 +1,67 @@ +package launcher + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestSystemdServicePathExecutesNPMGlobalCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("systemd service PATH is POSIX-only") + } + home := t.TempDir() + binDir := filepath.Join(home, ".npm-global", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + const cli = "kandev-test-npm-global-agent" + wantPath := filepath.Join(binDir, cli) + if err := os.WriteFile(wantPath, []byte("#!/bin/sh\nprintf 'npm-global-agent-ok'\n"), 0o755); err != nil { + t.Fatal(err) + } + unit := renderSystemdUnit(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: filepath.Join(home, ".kandev"), + }) + var servicePath string + for _, line := range strings.Split(unit, "\n") { + value, ok := strings.CutPrefix(line, "Environment=") + if !ok { + continue + } + value = strings.Trim(value, `"`) + value, ok = strings.CutPrefix(value, "PATH=") + if !ok { + continue + } + // %h is the systemd home-dir specifier; it differs from input.HomeDir (kandev data dir). + servicePath = strings.ReplaceAll(value, "%h", home) + break + } + if servicePath == "" { + t.Fatal("generated unit has no PATH environment entry") + } + t.Setenv("PATH", servicePath) + resolved, err := exec.LookPath(cli) + if err != nil { + t.Fatalf("global npm CLI is not discoverable with generated service PATH: %v", err) + } + if resolved != wantPath { + t.Fatalf("resolved CLI = %q, want %q", resolved, wantPath) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, cli).CombinedOutput() + if err != nil { + t.Fatalf("execute global npm CLI: %v: %s", err, output) + } + if string(output) != "npm-global-agent-ok" { + t.Fatalf("CLI output = %q", output) + } +} diff --git a/docs/public/run-as-a-service.md b/docs/public/run-as-a-service.md index 328ba5364c..f831500368 100644 --- a/docs/public/run-as-a-service.md +++ b/docs/public/run-as-a-service.md @@ -340,3 +340,16 @@ Reinstall using the upgraded `kandev` binary, preserve the original `--system` a ### Service starts but agents fail Read service logs first. A service has a smaller `PATH` and no interactive shell environment, so tools or credentials visible in a terminal may be absent. Configure executor credentials through Kandev's profile/settings paths, use stable executable paths, and verify Docker/SSH/Sprites connectivity as described in [Executors](executors.md#troubleshooting). + +Linux user services include `~/.npm-global/bin` in their generated `PATH` for +agent CLIs installed with that npm prefix. After upgrading an existing user +service, regenerate its unit and restart it to apply the updated `PATH`: + +```bash +# Repeat your original install-time flags when used. +kandev service install +kandev service restart +``` + +Restarting alone does not update an older unit's `PATH`. Other custom npm +prefixes are not automatically added to the service environment.