From 21afb5ff16ec04492820f3306e189e65eca66a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:04:02 +0200 Subject: [PATCH] fix: discover locally installed agents across launch modes (#3696) --- apps/backend/internal/launcher/env.go | 2 +- .../internal/launcher/local_agent_path.go | 51 ++++ .../launcher/local_agent_path_test.go | 267 ++++++++++++++++++ .../launcher/local_agent_service_path_test.go | 59 ++++ apps/backend/internal/launcher/service.go | 1 + .../completed_task_follow_up_test.go | 9 + docs/plans/cursor-install-discovery/plan.md | 154 ++++++++++ .../task-01-local-agent-path.md | 141 +++++++++ docs/public/cli.md | 10 + .../platform/requirements/local-agent-path.md | 56 ++++ .../system-design/local-agent-path.md | 77 +++++ 11 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 apps/backend/internal/launcher/local_agent_path.go create mode 100644 apps/backend/internal/launcher/local_agent_path_test.go create mode 100644 apps/backend/internal/launcher/local_agent_service_path_test.go create mode 100644 docs/plans/cursor-install-discovery/plan.md create mode 100644 docs/plans/cursor-install-discovery/task-01-local-agent-path.md create mode 100644 docs/specs/platform/requirements/local-agent-path.md create mode 100644 docs/specs/platform/system-design/local-agent-path.md diff --git a/apps/backend/internal/launcher/env.go b/apps/backend/internal/launcher/env.go index ab0b5260be..79d057dd60 100644 --- a/apps/backend/internal/launcher/env.go +++ b/apps/backend/internal/launcher/env.go @@ -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 { diff --git a/apps/backend/internal/launcher/local_agent_path.go b/apps/backend/internal/launcher/local_agent_path.go new file mode 100644 index 0000000000..fac1207016 --- /dev/null +++ b/apps/backend/internal/launcher/local_agent_path.go @@ -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 +} diff --git a/apps/backend/internal/launcher/local_agent_path_test.go b/apps/backend/internal/launcher/local_agent_path_test.go new file mode 100644 index 0000000000..56774a7384 --- /dev/null +++ b/apps/backend/internal/launcher/local_agent_path_test.go @@ -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 +} diff --git a/apps/backend/internal/launcher/local_agent_service_path_test.go b/apps/backend/internal/launcher/local_agent_service_path_test.go new file mode 100644 index 0000000000..4d63faa3e1 --- /dev/null +++ b/apps/backend/internal/launcher/local_agent_service_path_test.go @@ -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) + } + }) + } +} diff --git a/apps/backend/internal/launcher/service.go b/apps/backend/internal/launcher/service.go index 353cedcd8e..2d4a9b74d2 100644 --- a/apps/backend/internal/launcher/service.go +++ b/apps/backend/internal/launcher/service.go @@ -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" ) diff --git a/apps/backend/internal/orchestrator/completed_task_follow_up_test.go b/apps/backend/internal/orchestrator/completed_task_follow_up_test.go index ee6f891180..e7aa511b3d 100644 --- a/apps/backend/internal/orchestrator/completed_task_follow_up_test.go +++ b/apps/backend/internal/orchestrator/completed_task_follow_up_test.go @@ -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) diff --git a/docs/plans/cursor-install-discovery/plan.md b/docs/plans/cursor-install-discovery/plan.md new file mode 100644 index 0000000000..8fce081584 --- /dev/null +++ b/docs/plans/cursor-install-discovery/plan.md @@ -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. diff --git a/docs/plans/cursor-install-discovery/task-01-local-agent-path.md b/docs/plans/cursor-install-discovery/task-01-local-agent-path.md new file mode 100644 index 0000000000..5ca74b9f4c --- /dev/null +++ b/docs/plans/cursor-install-discovery/task-01-local-agent-path.md @@ -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. diff --git a/docs/public/cli.md b/docs/public/cli.md index f78bf365a6..a91a669565 100644 --- a/docs/public/cli.md +++ b/docs/public/cli.md @@ -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 diff --git a/docs/specs/platform/requirements/local-agent-path.md b/docs/specs/platform/requirements/local-agent-path.md new file mode 100644 index 0000000000..e38182cff9 --- /dev/null +++ b/docs/specs/platform/requirements/local-agent-path.md @@ -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) diff --git a/docs/specs/platform/system-design/local-agent-path.md b/docs/specs/platform/system-design/local-agent-path.md new file mode 100644 index 0000000000..27ee0e79d0 --- /dev/null +++ b/docs/specs/platform/system-design/local-agent-path.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.