fix: discover locally installed agents across launch modes (#3696)
This commit is contained in:
@@ -71,7 +71,7 @@ func backendEnvForConfig(ports portConfig, logLevel, consoleLogLevel string, deb
|
||||
env = upsertEnv(env, key, value)
|
||||
}
|
||||
}
|
||||
return env
|
||||
return withLocalAgentPath(env)
|
||||
}
|
||||
|
||||
func stripAppliedProfileEnvironment(env []string) []string {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var lookupLocalAgentUser = user.LookupId
|
||||
|
||||
func withLocalAgentPath(env []string) []string {
|
||||
if runtime.GOOS == goosWindows {
|
||||
return env
|
||||
}
|
||||
home := localAgentHome(env)
|
||||
if !filepath.IsAbs(home) || strings.ContainsRune(home, os.PathListSeparator) {
|
||||
return env
|
||||
}
|
||||
localBin := filepath.Join(home, ".local", "bin")
|
||||
path := processEnvValue(env, "PATH")
|
||||
for _, dir := range filepath.SplitList(path) {
|
||||
if filepath.Clean(dir) == localBin {
|
||||
return env
|
||||
}
|
||||
}
|
||||
// Installers may create this directory after the backend and agentctl start.
|
||||
// Append it so explicitly configured executables keep their precedence.
|
||||
if path != "" {
|
||||
path += string(os.PathListSeparator)
|
||||
}
|
||||
return upsertEnv(env, "PATH", path+localBin)
|
||||
}
|
||||
|
||||
func localAgentHome(env []string) string {
|
||||
if processEnvValue(env, "KANDEV_SERVICE_MODE") == nativeServiceModeSystem {
|
||||
// A LaunchDaemon's inherited HOME may still name the installing user.
|
||||
account, err := lookupLocalAgentUser(strconv.Itoa(os.Geteuid()))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return account.HomeDir
|
||||
}
|
||||
home := processEnvValue(env, "HOME")
|
||||
if !filepath.IsAbs(home) {
|
||||
home, _ = os.UserHomeDir()
|
||||
}
|
||||
return home
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
)
|
||||
|
||||
// @covers AC-PLATFORM-LOCAL-AGENT-PATH-001.1 AC-PLATFORM-LOCAL-AGENT-PATH-001.2 AC-PLATFORM-LOCAL-AGENT-PATH-001.3
|
||||
func TestLocalAgentPathPostInstall(t *testing.T) {
|
||||
home, env := localAgentEnvironment(t, "/usr/bin:/bin")
|
||||
installLocalAgentFixture(t, home)
|
||||
runLocalAgentChild(t, env)
|
||||
}
|
||||
|
||||
func TestLocalAgentPathLaunchd(t *testing.T) {
|
||||
plist := renderLaunchdPlist(nativeServiceUnitInput{Executable: "/opt/kandev/bin/kandev", HomeDir: t.TempDir()})
|
||||
path := launchdPlistString(t, plist, "PATH")
|
||||
home, env := localAgentEnvironment(t, path)
|
||||
installLocalAgentFixture(t, home)
|
||||
runLocalAgentChild(t, env)
|
||||
}
|
||||
|
||||
func localAgentEnvironment(t *testing.T, path string) (string, []string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix executable fixtures")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("KANDEV_SERVICE_MODE", "")
|
||||
t.Setenv("PATH", path)
|
||||
t.Setenv("KANDEV_HOME_DIR", t.TempDir())
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", nil)
|
||||
return home, upsertEnv(env, "KANDEV_TEST_LOCAL_AGENT_CHILD", "1")
|
||||
}
|
||||
|
||||
func installLocalAgentFixture(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
bin := filepath.Join(home, ".local", "bin")
|
||||
if err := os.MkdirAll(bin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"agent", "cursor-agent"} {
|
||||
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nprintf cursor-ok\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runLocalAgentChild(t *testing.T, env []string) {
|
||||
t.Helper()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, executable, "-test.run=^TestLocalAgentPathChild$")
|
||||
cmd.Env = env
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("child discovery/execution: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAgentPathChild(t *testing.T) {
|
||||
if os.Getenv("KANDEV_TEST_LOCAL_AGENT_CHILD") != "1" {
|
||||
return
|
||||
}
|
||||
result, err := agents.NewCursorACP().IsInstalled(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Available {
|
||||
t.Fatal("installed Cursor is invisible under launcher-produced PATH")
|
||||
}
|
||||
for _, name := range []string{"agent", "cursor-agent"} {
|
||||
output, err := exec.CommandContext(t.Context(), name).Output()
|
||||
if err != nil || string(output) != "cursor-ok" {
|
||||
t.Fatalf("execute %s: %q, %v", name, output, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-PLATFORM-LOCAL-AGENT-PATH-001.4 AC-PLATFORM-LOCAL-AGENT-PATH-001.5
|
||||
func TestLocalAgentPathEnvironment(t *testing.T) {
|
||||
t.Setenv("KANDEV_SERVICE_MODE", "")
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Setenv("PATH", `C:\tools`)
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", nil)
|
||||
if got := processEnvValue(env, "PATH"); got != `C:\tools` {
|
||||
t.Fatalf("Windows PATH changed: %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
home := t.TempDir()
|
||||
local := filepath.Join(home, ".local", "bin")
|
||||
cases := []struct{ name, home, path, want string }{
|
||||
{"append", home, "/usr/bin:/bin", "/usr/bin:/bin:" + local},
|
||||
{"already present", home, local + ":/bin", local + ":/bin"},
|
||||
{"equivalent directory", home, local + "/:/bin", local + "/:/bin"},
|
||||
{"empty path", home, "", local},
|
||||
{"missing home", "", "/bin", "/bin"},
|
||||
{"relative home", "relative", "/bin", "/bin"},
|
||||
{"separator in home", home + ":other", "/bin", "/bin"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("HOME", tc.home)
|
||||
t.Setenv("PATH", tc.path)
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", nil)
|
||||
if got := processEnvValue(env, "PATH"); got != tc.want {
|
||||
t.Fatalf("PATH = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("final overrides", func(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", []string{"HOME=" + home, "PATH=/override"})
|
||||
want := "/override:" + local
|
||||
if got := processEnvValue(env, "PATH"); got != want {
|
||||
t.Fatalf("PATH = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocalAgentPathUnavailable(t *testing.T) {
|
||||
home, env := localAgentEnvironment(t, t.TempDir())
|
||||
t.Setenv("PATH", processEnvValue(env, "PATH"))
|
||||
assertUnavailable := func() {
|
||||
t.Helper()
|
||||
result, err := agents.NewCursorACP().IsInstalled(t.Context())
|
||||
if err != nil || result.Available {
|
||||
t.Fatalf("missing/non-executable Cursor: %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
assertUnavailable()
|
||||
installLocalAgentFixture(t, home)
|
||||
if err := os.Chmod(filepath.Join(home, ".local", "bin", "cursor-agent"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertUnavailable()
|
||||
}
|
||||
|
||||
func TestLocalAgentPathPreservesExecutablePrecedence(t *testing.T) {
|
||||
preferred := t.TempDir()
|
||||
home, env := localAgentEnvironment(t, preferred)
|
||||
installLocalAgentFixture(t, home)
|
||||
want := filepath.Join(preferred, "cursor-agent")
|
||||
if err := os.WriteFile(want, []byte("#!/bin/sh\nprintf preferred\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", processEnvValue(env, "PATH"))
|
||||
result, err := agents.NewCursorACP().IsInstalled(t.Context())
|
||||
if err != nil || result.MatchedPath != want {
|
||||
t.Fatalf("discovery = %+v, %v; want %s", result, err, want)
|
||||
}
|
||||
output, err := exec.CommandContext(t.Context(), "cursor-agent").Output()
|
||||
if err != nil || string(output) != "preferred" {
|
||||
t.Fatalf("preferred executable: %q, %v", output, err)
|
||||
}
|
||||
again := backendEnv(portConfig{}, "", "", false, "test", nil)
|
||||
if got := processEnvValue(again, "PATH"); got != processEnvValue(env, "PATH") {
|
||||
t.Fatalf("repeated normalization changed PATH: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAgentPathRestart(t *testing.T) {
|
||||
home, env := localAgentEnvironment(t, "/usr/bin:/bin")
|
||||
installLocalAgentFixture(t, home)
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
supervisor := newSupervisor()
|
||||
t.Cleanup(func() { supervisor.shutdown("test cleanup") })
|
||||
backend := &restartableBackend{
|
||||
command: executable,
|
||||
args: []string{"-test.run=^TestLocalAgentPathChild$"},
|
||||
env: env,
|
||||
quiet: true,
|
||||
supervisor: supervisor,
|
||||
exitCh: make(chan int, 1),
|
||||
}
|
||||
if err := backend.start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
select {
|
||||
case code := <-backend.exitCh:
|
||||
if code != 0 {
|
||||
t.Fatalf("backend launch %d failed with exit %d", attempt, code)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("backend subprocess did not finish")
|
||||
}
|
||||
if attempt == 0 {
|
||||
backend.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAgentPathLaunchDaemon(t *testing.T) {
|
||||
if runtime.GOOS == goosWindows {
|
||||
t.Skip("Unix service identity")
|
||||
}
|
||||
account, err := user.LookupId(strconv.Itoa(os.Geteuid()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !filepath.IsAbs(account.HomeDir) {
|
||||
t.Skip("account has no absolute home")
|
||||
}
|
||||
for _, inheritedHome := range []string{"", t.TempDir()} {
|
||||
t.Run(inheritedHome, func(t *testing.T) {
|
||||
t.Setenv("HOME", inheritedHome)
|
||||
t.Setenv("PATH", "/usr/bin:/bin")
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", []string{"KANDEV_SERVICE_MODE=system"})
|
||||
want := "/usr/bin:/bin:" + filepath.Join(account.HomeDir, ".local", "bin")
|
||||
if got := processEnvValue(env, "PATH"); got != want {
|
||||
t.Fatalf("service PATH = %q, want account home %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func launchdPlistString(t *testing.T, plist, wanted string) string {
|
||||
t.Helper()
|
||||
// EnvironmentVariables is a nested dictionary; decode its serialized producer output.
|
||||
decoder := xml.NewDecoder(strings.NewReader(plist))
|
||||
var path string
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "key" {
|
||||
continue
|
||||
}
|
||||
var key string
|
||||
if err := decoder.DecodeElement(&key, &start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key != wanted {
|
||||
continue
|
||||
}
|
||||
if err := decoder.Decode(&path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatalf("launchd plist has no %s", wanted)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalAgentPathSelectedServiceAccount(t *testing.T) {
|
||||
home, _ := localAgentEnvironment(t, "/usr/bin:/bin")
|
||||
plist := renderLaunchdPlist(nativeServiceUnitInput{
|
||||
Executable: "/opt/kandev/bin/kandev", HomeDir: t.TempDir(), System: true, SystemUser: "alice",
|
||||
})
|
||||
if got := launchdPlistString(t, plist, "UserName"); got != "alice" {
|
||||
t.Fatalf("service user = %q", got)
|
||||
}
|
||||
original := lookupLocalAgentUser
|
||||
t.Cleanup(func() { lookupLocalAgentUser = original })
|
||||
lookupLocalAgentUser = func(uid string) (*user.User, error) {
|
||||
if uid != strconv.Itoa(os.Geteuid()) {
|
||||
t.Fatalf("looked up uid %q instead of effective uid", uid)
|
||||
}
|
||||
return &user.User{Uid: "1001", Username: "alice", HomeDir: home}, nil
|
||||
}
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", []string{
|
||||
"PATH=" + launchdPlistString(t, plist, "PATH"),
|
||||
"KANDEV_SERVICE_MODE=" + launchdPlistString(t, plist, "KANDEV_SERVICE_MODE"),
|
||||
"KANDEV_TEST_LOCAL_AGENT_CHILD=1",
|
||||
})
|
||||
installLocalAgentFixture(t, home)
|
||||
runLocalAgentChild(t, env)
|
||||
}
|
||||
|
||||
func TestLocalAgentPathServiceLookupFailure(t *testing.T) {
|
||||
_, _ = localAgentEnvironment(t, "/usr/bin:/bin")
|
||||
original := lookupLocalAgentUser
|
||||
t.Cleanup(func() { lookupLocalAgentUser = original })
|
||||
for _, tc := range []struct {
|
||||
name, home string
|
||||
err error
|
||||
}{
|
||||
{name: "lookup failure", err: errors.New("account not found")},
|
||||
{name: "missing account home"},
|
||||
{name: "relative account home", home: "relative"},
|
||||
{name: "separator in account home", home: filepath.Join(t.TempDir(), "bad:home")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
lookupLocalAgentUser = func(string) (*user.User, error) { return &user.User{HomeDir: tc.home}, tc.err }
|
||||
env := backendEnv(portConfig{}, "", "", false, "test", []string{"KANDEV_SERVICE_MODE=system"})
|
||||
if got := processEnvValue(env, "PATH"); got != "/usr/bin:/bin" {
|
||||
t.Fatalf("failed lookup changed PATH to %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -481,6 +481,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:%h/.npm-global/bin"
|
||||
// launchd has no home-directory specifier; withLocalAgentPath adds the runtime user's bin directory.
|
||||
launchdServicePath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/orchestrator/watcher"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
@@ -29,9 +30,17 @@ func TestCompletedTaskFollowUpAdmissionIsConversationalOnly(t *testing.T) {
|
||||
|
||||
agentMgr := &mockAgentManager{repoForExecutionLookup: repo, isAgentRunning: true}
|
||||
svc := createEngineService(t, repo, steps, agentMgr)
|
||||
onEnterDone := make(chan struct{})
|
||||
svc.onProcessOnEnterComplete = func() { close(onEnterDone) }
|
||||
session, err := repo.GetTaskSession(ctx, "session")
|
||||
require.NoError(t, err)
|
||||
require.True(t, svc.processOnTurnCompleteViaEngine(ctx, "task", session))
|
||||
// Finish terminal-step session preparation before admitting a follow-up.
|
||||
select {
|
||||
case <-onEnterDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for terminal step setup")
|
||||
}
|
||||
|
||||
task, err := repo.GetTask(ctx, "task")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
created: 2026-09-15
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-PLATFORM-LOCAL-AGENT-PATH-001
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/local-agent-path.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation plan: Cursor install discovery
|
||||
|
||||
## Overview
|
||||
|
||||
Repair the shared native launcher's backend PATH so agents installed in the
|
||||
runtime user's `.local/bin` can be discovered and executed. One sequential work
|
||||
order covers the environment producer, its consumers, regression tests, and docs.
|
||||
|
||||
## Evidence and diagnosis
|
||||
|
||||
- User reports macOS with regular `npx` and service launch. Terminal resolves
|
||||
both `agent` and `cursor-agent` under `/Users/ram58/.local/bin`.
|
||||
- `agents/cursor_acp.go` checks only `cursor-agent` through process PATH.
|
||||
Its installer exports `.local/bin` in its child shell and appends to `.bashrc`;
|
||||
neither changes a running parent's environment.
|
||||
- `launcher/service.go` omits `.local/bin` from `launchdServicePath`.
|
||||
`backendEnvForConfig` currently forwards PATH without supplementing it.
|
||||
- Post-install cache invalidation and capability refresh already exist in
|
||||
`controller.SetJobBroadcaster`; rescanning alone cannot repair the environment.
|
||||
- The regular-launch failure is reproducible when its inherited PATH omits the
|
||||
install directory. The user's historical regular-launch process PATH has not
|
||||
been captured; do not claim that specific process environment was verified.
|
||||
- A Linux diagnostic bundle was collected before the macOS clarification and
|
||||
deleted. It is not evidence about the affected Mac.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: shared Unix child environment normalization, actual Cursor discovery
|
||||
and subprocess execution, launchd-input coverage, late installation, precedence,
|
||||
restart propagation, and CLI documentation.
|
||||
|
||||
Out of scope: alias expansion, authentication, installer replacement, remote
|
||||
executor changes, frontend code, service-account changes, and live-instance
|
||||
mutation. Both Cursor binary names already exist in the reported installation.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Follow [the design](../../specs/platform/system-design/local-agent-path.md).
|
||||
Add the effective user's `.local/bin` once to the end of the final child PATH
|
||||
in `backendEnvForConfig`. Apply before subprocess creation and retain the path
|
||||
even when the directory does not yet exist. Keep service definitions compatible;
|
||||
fixing only the generated plist would miss regular and existing service launches.
|
||||
|
||||
## Tests and end-to-end evidence
|
||||
|
||||
Use `internal/launcher/local_agent_path_test.go` for the acceptance matrix.
|
||||
`TestLocalAgentPathPostInstall` must create an isolated home, obtain the real
|
||||
launcher-produced environment before installation, create fake `agent` and
|
||||
`cursor-agent` executables afterward, and drive actual Cursor discovery and
|
||||
subprocess execution under that environment. Expect failure before the patch.
|
||||
|
||||
`TestLocalAgentPathLaunchd` must parse PATH from `renderLaunchdPlist` and pass it
|
||||
through the actual backend environment producer. Include a distinct Kandev data
|
||||
home. Add cases for existing PATH precedence, duplicate normalization, empty
|
||||
PATH, unavailable home, missing/non-executable files, and Windows preservation.
|
||||
Cover backend child restart propagation using the existing supervisor test seam.
|
||||
This is process-level end-to-end evidence; no browser layout changes are planned.
|
||||
|
||||
On an isolated macOS runtime, confirm installation followed by rescan exposes
|
||||
Cursor and a local probe executes for regular and service launches. Record this
|
||||
as pending if no macOS runner is available; Linux fixture execution is not a
|
||||
native macOS smoke test.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Preserve local agent PATH](task-01-local-agent-path.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Temporary `TestReproCursorPostInstallPath` created both executable names under
|
||||
an isolated home after obtaining `backendEnv` with `/usr/bin:/bin` PATH. Actual
|
||||
`CursorACP.IsInstalled` returned unavailable. Command:
|
||||
`GOCACHE=/tmp/kandev-cursor-go-cache go test ./internal/launcher -run '^TestReproCursorPostInstallPath$' -count=1`
|
||||
failed with `installed Cursor is invisible under launcher-produced PATH`, as
|
||||
expected. The temporary test was removed at the design checkpoint. This ran on
|
||||
Linux and verifies the shared Go path; native macOS execution remains pending.
|
||||
|
||||
`python3 scripts/list-docs.py validate` passed (272 decisions, 936 specifications).
|
||||
`python3 scripts/lint-spec-files.py --all` passed.
|
||||
Implemented in `env.go` and `local_agent_path.go`; regression coverage is in
|
||||
`local_agent_path_test.go`. Public CLI documentation updated.
|
||||
|
||||
- `GOCACHE=/tmp/kandev-cursor-go-cache go test ./internal/launcher -run '^TestLocalAgentPath' -count=1`: passed after failing on missing discovery before the patch.
|
||||
- `GOCACHE=/tmp/kandev-cursor-go-cache go test -race ./internal/launcher ./internal/agent/agents ./internal/agent/runtime/agentctl/launcher -count=1`: both agent packages passed; launcher required the isolated rerun below because the sandbox forbids sockets and inherited runtime settings conflict with fixture values.
|
||||
- Launcher rerun outside the socket-restricted sandbox: Python copied `os.environ` excluding keys starting with `KANDEV_`, set `GOCACHE=/tmp/kandev-cursor-go-cache`, and ran `go test -race ./internal/launcher -count=1`. Passed (14.310s).
|
||||
- `python3 scripts/list-docs.py validate`: passed.
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check`: passed.
|
||||
- Final lint correction reused the existing `goosWindows` constant; the focused regression suite was rerun with `-race` afterward.
|
||||
- `pnpm install --frozen-lockfile` from `apps/`: passed for commit-hook dependencies.
|
||||
|
||||
Native macOS regular/service smoke testing was not available on this Linux
|
||||
runner. The generated launchd environment, actual discovery, executable
|
||||
precedence, late installation, absence behavior, and supervised restart are
|
||||
covered by process-level tests. Windows preservation is included as a
|
||||
platform-conditional test but was not executed on Windows here.
|
||||
|
||||
|
||||
## Risks
|
||||
|
||||
The fix must use the runtime account home, preserve PATH precedence, cover late
|
||||
installation, and propagate to subprocesses. An already-running old launcher or
|
||||
agentctl may retain its old environment until the runtime is restarted. If the
|
||||
regular-launch symptom persists with a verified correct PATH, investigate that
|
||||
separately instead of expanding this repair without evidence.
|
||||
|
||||
## PR review remediation
|
||||
|
||||
Codex identified that a system LaunchDaemon can inherit an absent or incorrect
|
||||
HOME after selecting a non-root account. System-service local executable PATH
|
||||
now uses the effective UID's account home and preserves PATH when lookup fails.
|
||||
Added `local_agent_service_path_test.go` with a generated non-root LaunchDaemon
|
||||
fixture, actual discovery/execution, and failed/invalid account lookup coverage.
|
||||
Regular launches retain final child HOME precedence.
|
||||
|
||||
Claude's delayed summary suggestion is addressed with a comment on
|
||||
`launchdServicePath` explaining that runtime normalization supplies the user
|
||||
executable directory because launchd has no home-directory specifier. This
|
||||
clarification changes no executable behavior.
|
||||
|
||||
CodeRabbit's summary suggestion is addressed by documenting the usable absolute
|
||||
home condition in the requirement and public CLI guide.
|
||||
|
||||
Remediation validation:
|
||||
- `GOCACHE=/tmp/kandev-cursor-go-cache go test -race ./internal/launcher -run '^TestLocalAgentPath' -count=1`: passed.
|
||||
- `go test -race ./internal/launcher -count=1` with inherited `KANDEV_*` settings removed, writable GOCACHE, and local socket access: passed (16.928s).
|
||||
- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.py --all`, and `git diff --check`: passed.
|
||||
|
||||
Remote CI/review evidence remains pending for the remediation head.
|
||||
|
||||
|
||||
## CI test remediation
|
||||
|
||||
Backend shard 2 failed in `TestCompletedTaskFollowUpAdmissionIsConversationalOnly`
|
||||
because the follow-up raced unfinished terminal-step session preparation.
|
||||
The test now joins the existing `onProcessOnEnterComplete` signal before
|
||||
admitting the follow-up. Its behavior assertions are unchanged; no production
|
||||
contract or public documentation change is needed.
|
||||
|
||||
With inherited `KANDEV_*` settings removed and writable GOCACHE,
|
||||
`go test -race ./internal/orchestrator -run '^TestCompletedTaskFollowUpAdmissionIsConversationalOnly$' -count=20`
|
||||
reproduced the original assertion failure in root and child cases (189.376s),
|
||||
then passed after synchronization (191.113s). Remote suite validation remains
|
||||
pending for the new test-fix head.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
id: "01-local-agent-path"
|
||||
title: "Preserve local agent PATH"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-LOCAL-AGENT-PATH-001
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-LOCAL-AGENT-PATH-001.1
|
||||
- AC-PLATFORM-LOCAL-AGENT-PATH-001.2
|
||||
- AC-PLATFORM-LOCAL-AGENT-PATH-001.3
|
||||
- AC-PLATFORM-LOCAL-AGENT-PATH-001.4
|
||||
- AC-PLATFORM-LOCAL-AGENT-PATH-001.5
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/local-agent-path.md
|
||||
---
|
||||
|
||||
# Task 01: Preserve local agent PATH
|
||||
|
||||
## Summary
|
||||
|
||||
Make the shared Unix backend child environment include the runtime user's local
|
||||
executable directory. Prove newly installed Cursor is discovered and executable
|
||||
without restarting solely to refresh PATH.
|
||||
|
||||
## Scope and owned files
|
||||
|
||||
- `apps/backend/internal/launcher/env.go`: invoke the normalizer after overrides.
|
||||
- `apps/backend/internal/launcher/local_agent_path.go` and
|
||||
`local_agent_path_test.go`: focused helper and producer/consumer regressions.
|
||||
- Existing launcher supervisor tests if needed to verify restart propagation.
|
||||
- `docs/public/cli.md`: describe supported local agent discovery after the fix.
|
||||
|
||||
Do not change Cursor identity, permissions, shell profiles, UI markup, remote
|
||||
execution, service accounts, or npm shim behavior.
|
||||
|
||||
## Inputs
|
||||
|
||||
Read the linked requirement/design and plan evidence. Use
|
||||
`service_npm_path_test.go` as a subprocess fixture pattern. Trace `start.go`,
|
||||
`dev.go`, `supervisor.go`, and local agentctl environment inheritance before edits.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. The post-install and launchd regression tests fail for missing discovery
|
||||
before the patch, then pass through the real environment producer.
|
||||
2. Preserve ordering, effective home, idempotence, Windows behavior, and absence
|
||||
semantics; cover all criteria in the plan's test matrix.
|
||||
3. Actual child execution and restart propagation pass; record isolated macOS
|
||||
regular/service smoke results or explicitly retain that validation gap.
|
||||
|
||||
## Verification
|
||||
|
||||
From the repository root, run the focused regression first for red/green, then
|
||||
the package checks:
|
||||
|
||||
```sh
|
||||
(cd apps/backend && go test ./internal/launcher -run '^TestLocalAgentPath' -count=1)
|
||||
(cd apps/backend && go test -race ./internal/launcher ./internal/agent/agents ./internal/agent/runtime/agentctl/launcher -count=1)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use a writable GOCACHE override when the default cache is read-only. macOS smoke
|
||||
steps: start an isolated owned regular runtime with `.local/bin` absent from
|
||||
inherited PATH; install Cursor through settings; rescan and verify detection and
|
||||
local probe execution. Repeat with an isolated owned launchd service. Never
|
||||
replace or restart the user's main service for this check. Remove owned fixtures.
|
||||
|
||||
## Risks
|
||||
|
||||
Do not use Kandev data home as user home or normalize before final HOME/PATH
|
||||
overrides. Preserve configured executable precedence and do not rely on the
|
||||
directory existing at startup. Keep auth failure separate from binary discovery.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Results
|
||||
|
||||
Implemented in `env.go` and `local_agent_path.go`; regression coverage is in
|
||||
`local_agent_path_test.go`. Public CLI documentation updated.
|
||||
|
||||
- `GOCACHE=/tmp/kandev-cursor-go-cache go test ./internal/launcher -run '^TestLocalAgentPath' -count=1`: passed after failing on missing discovery before the patch.
|
||||
- `GOCACHE=/tmp/kandev-cursor-go-cache go test -race ./internal/launcher ./internal/agent/agents ./internal/agent/runtime/agentctl/launcher -count=1`: both agent packages passed; launcher required the isolated rerun below because the sandbox forbids sockets and inherited runtime settings conflict with fixture values.
|
||||
- Launcher rerun outside the socket-restricted sandbox: Python copied `os.environ` excluding keys starting with `KANDEV_`, set `GOCACHE=/tmp/kandev-cursor-go-cache`, and ran `go test -race ./internal/launcher -count=1`. Passed (14.310s).
|
||||
- `python3 scripts/list-docs.py validate`: passed.
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check`: passed.
|
||||
- Final lint correction reused the existing `goosWindows` constant; the focused regression suite was rerun with `-race` afterward.
|
||||
- `pnpm install --frozen-lockfile` from `apps/`: passed for commit-hook dependencies.
|
||||
|
||||
Native macOS regular/service smoke testing was not available on this Linux
|
||||
runner. The generated launchd environment, actual discovery, executable
|
||||
precedence, late installation, absence behavior, and supervised restart are
|
||||
covered by process-level tests. Windows preservation is included as a
|
||||
platform-conditional test but was not executed on Windows here.
|
||||
|
||||
|
||||
## PR review remediation
|
||||
|
||||
Codex identified that a system LaunchDaemon can inherit an absent or incorrect
|
||||
HOME after selecting a non-root account. System-service local executable PATH
|
||||
now uses the effective UID's account home and preserves PATH when lookup fails.
|
||||
Added `local_agent_service_path_test.go` with a generated non-root LaunchDaemon
|
||||
fixture, actual discovery/execution, and failed/invalid account lookup coverage.
|
||||
Regular launches retain final child HOME precedence.
|
||||
|
||||
Claude's delayed summary suggestion is addressed with a comment on
|
||||
`launchdServicePath` explaining that runtime normalization supplies the user
|
||||
executable directory because launchd has no home-directory specifier. This
|
||||
clarification changes no executable behavior.
|
||||
|
||||
CodeRabbit's summary suggestion is addressed by documenting the usable absolute
|
||||
home condition in the requirement and public CLI guide.
|
||||
|
||||
Remediation validation:
|
||||
- `GOCACHE=/tmp/kandev-cursor-go-cache go test -race ./internal/launcher -run '^TestLocalAgentPath' -count=1`: passed.
|
||||
- `go test -race ./internal/launcher -count=1` with inherited `KANDEV_*` settings removed, writable GOCACHE, and local socket access: passed (16.928s).
|
||||
- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.py --all`, and `git diff --check`: passed.
|
||||
|
||||
Remote CI/review evidence remains pending for the remediation head.
|
||||
|
||||
|
||||
## CI test remediation
|
||||
|
||||
Backend shard 2 failed in `TestCompletedTaskFollowUpAdmissionIsConversationalOnly`
|
||||
because the follow-up raced unfinished terminal-step session preparation.
|
||||
The test now joins the existing `onProcessOnEnterComplete` signal before
|
||||
admitting the follow-up. Its behavior assertions are unchanged; no production
|
||||
contract or public documentation change is needed.
|
||||
|
||||
With inherited `KANDEV_*` settings removed and writable GOCACHE,
|
||||
`go test -race ./internal/orchestrator -run '^TestCompletedTaskFollowUpAdmissionIsConversationalOnly$' -count=20`
|
||||
reproduced the original assertion failure in root and child cases (189.376s),
|
||||
then passed after synchronization (191.113s). Remote suite validation remains
|
||||
pending for the new test-fix head.
|
||||
@@ -149,6 +149,16 @@ values describe elapsed work and do not estimate completion.
|
||||
|
||||
The launcher remains in the foreground. Press `Ctrl+C` or terminate it to stop the backend and its managed children cleanly. A force-kill can leave worktree processes or containers running; inspect them before deleting data.
|
||||
|
||||
On macOS and Linux, Kandev includes the runtime user's `~/.local/bin` in its
|
||||
backend and local agent process PATH when that home resolves to a usable absolute
|
||||
path. Otherwise, the inherited PATH remains unchanged. System services use the
|
||||
effective service account's home, even if inherited HOME names another user. This applies to regular launches and OS
|
||||
services, including agent CLIs installed after Kandev starts. Existing PATH
|
||||
entries take precedence. After installing an agent such as Cursor, its
|
||||
post-install refresh or a manual rescan can discover it without restarting to
|
||||
reload shell configuration. The runtime user's home is separate from Kandev's
|
||||
configured data directory.
|
||||
|
||||
Use headless mode for SSH sessions, containers, or an external reverse proxy:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
status: active
|
||||
system: platform
|
||||
created: 2026-09-15
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Local agent executable discovery
|
||||
|
||||
## Overview
|
||||
|
||||
Native agent installers place executables in the runtime user's `~/.local/bin`.
|
||||
Kandev's shared startup environment must make those executables available to
|
||||
discovery and local execution, including when installation occurs after startup.
|
||||
Platform owns the shared process environment; agent identity and authentication
|
||||
remain owned by the agent system.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-PLATFORM-LOCAL-AGENT-PATH-001: Local agent executable availability
|
||||
|
||||
**Intent:** An agent installed through Kandev is discoverable without restarting
|
||||
solely to load shell configuration.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLATFORM-LOCAL-AGENT-PATH-001.1:** On macOS and Linux, regular native/npm
|
||||
launches and service launches shall discover executable agent CLIs in the
|
||||
runtime user's `~/.local/bin` even when the launcher's inherited PATH omits it,
|
||||
provided the runtime home resolves to a usable absolute path.
|
||||
- **AC-PLATFORM-LOCAL-AGENT-PATH-001.2:** If that directory or executable is
|
||||
created after startup, the existing post-install refresh or manual rescan
|
||||
shall discover it without a PATH-related restart.
|
||||
- **AC-PLATFORM-LOCAL-AGENT-PATH-001.3:** Local capability probes and newly
|
||||
launched local agent processes shall resolve the installed executable using
|
||||
the same startup environment; discovery alone shall not be the only repaired
|
||||
consumer.
|
||||
- **AC-PLATFORM-LOCAL-AGENT-PATH-001.4:** Existing PATH entries shall retain their
|
||||
order and precedence. Repeated startup normalization shall not duplicate the
|
||||
added directory. The directory shall be derived from the runtime user's home,
|
||||
independently of Kandev's data directory. System services shall use the effective
|
||||
service account's home even when inherited HOME is missing or names another user.
|
||||
- **AC-PLATFORM-LOCAL-AGENT-PATH-001.5:** Missing or non-executable agent files
|
||||
shall remain unavailable. When the runtime home cannot be resolved to a usable
|
||||
absolute path, inherited PATH shall remain unchanged. Windows startup and remote executor environments
|
||||
shall retain their existing behavior.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Cursor authentication, new executable aliases, custom install directories,
|
||||
shell-profile editing, remote installation changes, and UI layout changes.
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Cursor install discovery](../../../plans/cursor-install-discovery/plan.md)
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
status: current
|
||||
system: platform
|
||||
requirements:
|
||||
- REQ-PLATFORM-LOCAL-AGENT-PATH-001
|
||||
---
|
||||
|
||||
# Local agent executable discovery design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
Normalize the backend child environment at the native launcher's shared boundary.
|
||||
This follows the existing ownership in
|
||||
[the native launcher ADR](../../../decisions/2026-08-08-go-launcher-owns-all-launch-modes.md).
|
||||
It does not change agent identity or make discovery search a different filesystem
|
||||
from the process that will execute an agent.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-PLATFORM-LOCAL-AGENT-PATH-001` | Startup environment and consumer flow |
|
||||
|
||||
## Startup environment and consumer flow
|
||||
|
||||
`internal/launcher/env.go:backendEnvForConfig` constructs the child environment
|
||||
used by managed run/start and dev startup. Service definitions also invoke the
|
||||
native launcher. After applying child-specific environment overrides, a small
|
||||
Unix-only helper appends the effective user's absolute `.local/bin` path unless
|
||||
already present. Preserve inherited PATH ordering and do not gate addition on
|
||||
the directory existing: installation may create it after startup.
|
||||
|
||||
For system services (`KANDEV_SERVICE_MODE=system`), look up the effective UID
|
||||
with `os/user.LookupId` and use that account's home. Do not fall back to inherited
|
||||
HOME when account lookup fails: launchd may leave HOME unset or naming root after
|
||||
selecting another account through `UserName`. For regular launches and user
|
||||
services, use the final child HOME when valid; otherwise use `os.UserHomeDir`
|
||||
when it yields an absolute home. Do not substitute `KANDEV_HOME_DIR`, the database directory,
|
||||
the workspace, or a privileged installer's home. Missing/unusable home discovery
|
||||
leaves PATH unchanged. Preserve Windows behavior. Avoid an empty leading PATH
|
||||
component when the original PATH is empty.
|
||||
|
||||
The launched backend performs `agents.CursorACP.IsInstalled` through
|
||||
`WithCommand("cursor-agent")`, which uses `exec.LookPath`. Local agentctl launch
|
||||
inherits the backend environment through `environmentWithOverrides(os.Environ(),
|
||||
...)`; existing per-execution overrides remain authoritative. Verify this
|
||||
producer-to-consumer relationship with a subprocess regression, not only string
|
||||
assertions. Restarted backend children must retain the normalized environment.
|
||||
|
||||
`controller.SetJobBroadcaster` already invalidates discovery and refreshes host
|
||||
capabilities after install success. That flow needs no additional PATH mutation.
|
||||
Keeping `.local/bin` present from startup also covers already-running local
|
||||
utility processes created by the corrected launcher before installation.
|
||||
|
||||
## Service compatibility
|
||||
|
||||
`renderLaunchdPlist` currently supplies system directories and detected Node tool
|
||||
directories. Existing plists still reach the corrected shared launcher boundary,
|
||||
so the repair must work without regenerating a plist. Keep service account
|
||||
selection and Node tool PATH precedence intact. The shell's HOME and the Kandev
|
||||
data directory are different identities.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
An absent executable remains unavailable under existing discovery checks.
|
||||
Authentication and capability-probe failures remain distinct from installation.
|
||||
Deploying the fix requires starting the corrected runtime; an already-running
|
||||
old binary does not gain a new environment automatically. Do not terminate
|
||||
existing task sessions as part of the repair.
|
||||
|
||||
## Persistence, security, and observability
|
||||
|
||||
No schema, settings, API, or event changes. Do not execute shell startup files
|
||||
to recover PATH, prepend user directories ahead of configured tools, or log the
|
||||
full environment. Existing install and capability status events report recovery.
|
||||
Public CLI documentation should explain the supported local executable directory
|
||||
after implementation.
|
||||
Reference in New Issue
Block a user