fix: discover locally installed agents across launch modes (#3696)

This commit is contained in:
José Almeida
2026-09-15 18:04:02 +02:00
committed by GitHub
parent d986a14326
commit 21afb5ff16
11 changed files with 826 additions and 1 deletions
+1 -1
View File
@@ -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)
+154
View File
@@ -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.
+10
View File
@@ -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.