package process import ( "context" "errors" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "testing" "time" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/shell" "github.com/kandev/kandev/internal/githubauth" "github.com/kandev/kandev/pkg/agent" ) func envValue(env []string, key string) string { prefix := key + "=" for _, item := range env { if strings.HasPrefix(item, prefix) { return strings.TrimPrefix(item, prefix) } } return "" } func hasEnvValue(env []string, key string) bool { prefix := key + "=" for _, item := range env { if strings.HasPrefix(item, prefix) { return true } } return false } func TestManager_BuildFinalCommandPreservesConfiguredTempEnvironment(t *testing.T) { serviceTemp := setServiceTempTestEnv(t) mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), AgentArgs: []string{"echo"}, AgentEnv: []string{ "PATH=/usr/bin", "TMPDIR=/configured/tmpdir", "TMP=/configured/tmp", "TEMP=/configured/temp", }, }, newTestLogger(t)) mgr.adapter = newStubAdapter() if err := mgr.buildFinalCommand(); err != nil { t.Fatalf("buildFinalCommand() error = %v", err) } want := map[string]string{ "TMPDIR": "/configured/tmpdir", "TMP": "/configured/tmp", "TEMP": "/configured/temp", } for key, value := range want { if got := envValue(mgr.cmd.Env, key); got != value { t.Fatalf("%s = %q, want configured service value %q", key, got, value) } } assertNoAgentTempRoot(t, serviceTemp) } func TestManager_BuildFinalCommandLeavesUnsetTempEnvironmentUnset(t *testing.T) { serviceTemp := setServiceTempTestEnv(t) mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), AgentArgs: []string{"echo"}, AgentEnv: []string{"PATH=/usr/bin"}, }, newTestLogger(t)) mgr.adapter = newStubAdapter() if err := mgr.buildFinalCommand(); err != nil { t.Fatalf("buildFinalCommand() error = %v", err) } for _, key := range []string{"TMPDIR", "TMP", "TEMP"} { if hasEnvValue(mgr.cmd.Env, key) { t.Fatalf("%s unexpectedly added to child environment: %q", key, mgr.cmd.Env) } } assertNoAgentTempRoot(t, serviceTemp) } func TestManager_StartShellInheritsAgentEnvironment(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("PTY-backed shell sessions are unsupported on Windows") } mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), ShellEnabled: true, AgentEnv: []string{ "KANDEV_GITHUB_CREDENTIAL_BROKER_URL=http://127.0.0.1:9876", "PATH=/tmp/kandev-shim:/usr/bin", }, }, newTestLogger(t)) if err := mgr.StartShell(); err != nil { t.Fatalf("StartShell() error = %v", err) } t.Cleanup(func() { _ = mgr.shell.Stop() }) cfg := mgr.shell.Config() if got := cfg.Env["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"]; got != "http://127.0.0.1:9876" { t.Fatalf("shell broker env = %q, want managed broker URL", got) } if got := cfg.Env["PATH"]; got != "/tmp/kandev-shim:/usr/bin" { t.Fatalf("shell PATH = %q, want shim-first PATH", got) } } func TestManager_StartProcessInheritsAgentEnvironment(t *testing.T) { mgr := NewManager(&config.InstanceConfig{ AgentEnv: []string{ "KANDEV_GITHUB_CREDENTIAL_BROKER_URL=http://127.0.0.1:9876", "PATH=/tmp/kandev-shim:/usr/bin", }, }, newTestLogger(t)) req, err := mgr.buildProcessRequest(StartProcessRequest{ SessionID: "session-1", Command: "echo ok", Env: map[string]string{ "COMMAND_ONLY": "yes", "PATH": "/request/path", }, }) if err != nil { t.Fatalf("buildProcessRequest() error = %v", err) } if got := req.Env["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"]; got != "http://127.0.0.1:9876" { t.Fatalf("process broker env = %q, want managed broker URL", got) } if got := req.Env["PATH"]; got != "/request/path" { t.Fatalf("process PATH = %q, want explicit request value", got) } if got := req.Env["COMMAND_ONLY"]; got != "yes" { t.Fatalf("process explicit env = %q, want yes", got) } } func TestManager_ProcessEnvironmentMergesIndexedGitConfig(t *testing.T) { mgr := NewManager(&config.InstanceConfig{ AgentEnv: []string{ "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=!agentctl git-credential", }, }, newTestLogger(t)) req, err := mgr.buildProcessRequest(StartProcessRequest{ SessionID: "session-1", Command: "echo ok", Env: map[string]string{ "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": "/tmp/hooks", }, }) if err != nil { t.Fatalf("buildProcessRequest() error = %v", err) } if got := req.Env["GIT_CONFIG_COUNT"]; got != "2" { t.Fatalf("GIT_CONFIG_COUNT = %q, want merged count 2", got) } if got := req.Env["GIT_CONFIG_KEY_1"]; got != "core.hooksPath" { t.Fatalf("GIT_CONFIG_KEY_1 = %q, want request entry appended", got) } } func TestManagerConfigureReplacesOwnedHostHelperAndPreservesIndexedEnvironment(t *testing.T) { oldHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/old/gh' auth git-credential \"$@\"; }; f" newHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/new/gh' auth git-credential \"$@\"; }; f" mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), AgentEnv: []string{ "GIT_CONFIG_COUNT=3", "GIT_CONFIG_KEY_0=notes.augment.mergeStrategy", "GIT_CONFIG_VALUE_0=union", "GIT_CONFIG_KEY_1=core.hooksPath", "GIT_CONFIG_VALUE_1=/user/hooks", "GIT_CONFIG_KEY_2=credential.https://github.com.helper", "GIT_CONFIG_VALUE_2=" + oldHelper, }, }, newTestLogger(t)) t.Cleanup(mgr.stopWorkspaceTrackers) configure := func(env map[string]string) { t.Helper() if err := mgr.Configure("echo", nil, false, env, "", "", nil, false); err != nil { t.Fatalf("Configure() error = %v", err) } } configure(map[string]string{ "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "credential.https://github.com.helper", "GIT_CONFIG_VALUE_0": newHelper, }) env := environmentMap(mgr.cfg.AgentEnv) if env["GIT_CONFIG_COUNT"] != "3" || env["GIT_CONFIG_KEY_0"] != "notes.augment.mergeStrategy" || env["GIT_CONFIG_KEY_1"] != "core.hooksPath" || env["GIT_CONFIG_VALUE_1"] != "/user/hooks" || env["GIT_CONFIG_KEY_2"] != "credential.https://github.com.helper" || env["GIT_CONFIG_VALUE_2"] != newHelper { t.Fatalf("configured environment = %#v, want inherited entries plus replacement helper", env) } if trackerEnv := environmentMap(mgr.GetWorkspaceTracker().gitCommand(context.Background(), false, "status").Env); trackerEnv["GIT_CONFIG_VALUE_2"] != newHelper { t.Fatalf("tracker helper = %q, want replacement helper", trackerEnv["GIT_CONFIG_VALUE_2"]) } configure(nil) env = environmentMap(mgr.cfg.AgentEnv) if env["GIT_CONFIG_COUNT"] != "2" || env["GIT_CONFIG_KEY_0"] != "notes.augment.mergeStrategy" || env["GIT_CONFIG_KEY_1"] != "core.hooksPath" || env["GIT_CONFIG_VALUE_1"] != "/user/hooks" { t.Fatalf("reconfigured environment = %#v, want inherited entries without generated helper", env) } if _, present := env["GIT_CONFIG_VALUE_2"]; present { t.Fatalf("stale generated helper remained after reconfiguration: %#v", env) } } func TestManagerConfigureWithEnvironmentReplacesCompleteIndexedBlock(t *testing.T) { oldHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/old/gh' auth git-credential \"$@\"; }; f" newHelper := "!f() { : " + githubauth.HostGitHubCredentialHelperMarker + "; '/new/gh' auth git-credential \"$@\"; }; f" mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), AgentEnv: []string{ "GIT_CONFIG_COUNT=3", "GIT_CONFIG_KEY_0=notes.augment.mergeStrategy", "GIT_CONFIG_VALUE_0=union", "GIT_CONFIG_KEY_1=core.hooksPath", "GIT_CONFIG_VALUE_1=/user/hooks", "GIT_CONFIG_KEY_2=credential.https://github.com.helper", "GIT_CONFIG_VALUE_2=" + oldHelper, }, }, newTestLogger(t)) t.Cleanup(mgr.stopWorkspaceTrackers) complete := map[string]string{ "GIT_CONFIG_COUNT": "3", "GIT_CONFIG_KEY_0": "notes.augment.mergeStrategy", "GIT_CONFIG_VALUE_0": "union", "GIT_CONFIG_KEY_1": "core.hooksPath", "GIT_CONFIG_VALUE_1": "/user/hooks", "GIT_CONFIG_KEY_2": "credential.https://github.com.helper", "GIT_CONFIG_VALUE_2": newHelper, } if err := mgr.ConfigureWithEnvironment("echo", nil, false, complete, "", "", nil, false); err != nil { t.Fatalf("ConfigureWithEnvironment() error = %v", err) } env := environmentMap(mgr.cfg.AgentEnv) if env["GIT_CONFIG_COUNT"] != "3" || env["GIT_CONFIG_KEY_0"] != "notes.augment.mergeStrategy" || env["GIT_CONFIG_KEY_1"] != "core.hooksPath" || env["GIT_CONFIG_VALUE_1"] != "/user/hooks" || env["GIT_CONFIG_KEY_2"] != "credential.https://github.com.helper" || env["GIT_CONFIG_VALUE_2"] != newHelper { t.Fatalf("complete configured environment = %#v, want one complete replacement block", env) } if err := mgr.ConfigureWithEnvironment("echo", nil, false, nil, "", "", nil, false); err != nil { t.Fatalf("ConfigureWithEnvironment() removal error = %v", err) } env = environmentMap(mgr.cfg.AgentEnv) if len(env["GIT_CONFIG_COUNT"]) != 0 { t.Fatalf("removed configured environment = %#v, want complete indexed block removed", env) } for key := range env { if strings.HasPrefix(key, "GIT_CONFIG_KEY_") || strings.HasPrefix(key, "GIT_CONFIG_VALUE_") { t.Fatalf("indexed entry remained after complete removal: %#v", env) } } } func TestManagerConfigureLeavesConfigurationUnchangedWhenEnvironmentIsInvalid(t *testing.T) { mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), AgentCommand: "old-command", AgentArgs: []string{"old-command", "--old"}, ApprovalPolicy: "old-policy", AgentEnv: []string{ "KEEP_ME=yes", "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=core.hooksPath", "GIT_CONFIG_VALUE_0=/user/hooks", }, }, newTestLogger(t)) t.Cleanup(mgr.stopWorkspaceTrackers) err := mgr.ConfigureWithEnvironment( "new-command", []string{"new-command"}, true, map[string]string{"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "missing-value"}, "new-policy", "", nil, false, ) if err == nil { t.Fatal("ConfigureWithEnvironment() succeeded with malformed indexed Git block") } if mgr.cfg.AgentCommand != "old-command" || strings.Join(mgr.cfg.AgentArgs, " ") != "old-command --old" || mgr.cfg.ApprovalPolicy != "old-policy" { t.Fatalf("configuration mutated after failed composition: command=%q args=%#v policy=%q", mgr.cfg.AgentCommand, mgr.cfg.AgentArgs, mgr.cfg.ApprovalPolicy) } env := environmentMap(mgr.cfg.AgentEnv) if env["KEEP_ME"] != "yes" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_VALUE_0"] != "/user/hooks" { t.Fatalf("environment mutated after failed composition: %#v", env) } } func TestManagerConfigureRemovesObsoleteManagedCredentialEnvironment(t *testing.T) { mgr := NewManager(&config.InstanceConfig{ WorkDir: t.TempDir(), AgentEnv: []string{ "KEEP_ME=yes", "KANDEV_GITHUB_CREDENTIAL_BROKER_URL=https://broker.example/resolve", "KANDEV_GITHUB_CREDENTIAL_LEASE=stale-lease", "KANDEV_GITHUB_CLI_SHIM_DIR=/stale/shim", }, }, newTestLogger(t)) t.Cleanup(mgr.stopWorkspaceTrackers) if err := mgr.Configure("echo", nil, false, nil, "", "", nil, false); err != nil { t.Fatalf("Configure() error = %v", err) } env := environmentMap(mgr.cfg.AgentEnv) if env["KEEP_ME"] != "yes" { t.Fatalf("ordinary environment was removed: %#v", env) } for _, key := range []string{ "KANDEV_GITHUB_CREDENTIAL_BROKER_URL", "KANDEV_GITHUB_CREDENTIAL_LEASE", "KANDEV_GITHUB_CLI_SHIM_DIR", } { if _, ok := env[key]; ok { t.Fatalf("obsolete managed credential %s remained: %#v", key, env) } } } func TestManager_PipedProcessInheritsAgentEnvironment(t *testing.T) { mgr := NewManager(&config.InstanceConfig{ AgentEnv: []string{ "KANDEV_GITHUB_CREDENTIAL_BROKER_URL=http://127.0.0.1:9876", "PATH=/tmp/kandev-shim:/usr/bin", }, }, newTestLogger(t)) req, err := mgr.buildPipedProcessRequest(PipedStartRequest{ SessionID: "session-1", Command: "kotlin-language-server", Env: map[string]string{ "COMMAND_ONLY": "yes", "PATH": "/request/path", }, }) if err != nil { t.Fatalf("buildPipedProcessRequest() error = %v", err) } if got := req.Env["KANDEV_GITHUB_CREDENTIAL_BROKER_URL"]; got != "http://127.0.0.1:9876" { t.Fatalf("piped process broker env = %q, want managed broker URL", got) } if got := req.Env["PATH"]; got != "/request/path" { t.Fatalf("piped process PATH = %q, want explicit request value", got) } if got := req.Env["COMMAND_ONLY"]; got != "yes" { t.Fatalf("piped process explicit env = %q, want yes", got) } } func TestManager_StartAutoShellDoesNotDeadlockOnEnvironmentSnapshot(t *testing.T) { mgr := NewManager(&config.InstanceConfig{ AgentArgs: []string{"echo"}, ShellEnabled: true, Protocol: agent.ProtocolACP, }, newTestLogger(t)) mgr.adapter = newOneShotStubAdapter() t.Cleanup(func() { _ = mgr.Stop(context.Background()) }) done := make(chan error, 1) go func() { done <- mgr.Start(context.Background()) }() select { case err := <-done: if err != nil { t.Fatalf("Start() error = %v", err) } case <-time.After(2 * time.Second): t.Fatal("Start() deadlocked while creating the auto shell") } } func TestManager_BeginStopWaitsForInFlightAdmission(t *testing.T) { mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) release, err := mgr.admitStart() if err != nil { t.Fatalf("admitStart() error = %v", err) } stopAdmissionDone := make(chan struct{}) go func() { mgr.BeginStop() close(stopAdmissionDone) }() select { case <-stopAdmissionDone: t.Fatal("BeginStop() returned before in-flight admission completed") default: } release() <-stopAdmissionDone if err := mgr.Start(context.Background()); !errors.Is(err, ErrManagerStopping) { t.Fatalf("Start() error = %v, want manager-stopping error", err) } if _, err := mgr.StartProcess(context.Background(), StartProcessRequest{}); !errors.Is(err, ErrManagerStopping) { t.Fatalf("StartProcess() error = %v, want manager-stopping error", err) } if _, err := mgr.StartPipedProcess(PipedStartRequest{}); !errors.Is(err, ErrManagerStopping) { t.Fatalf("StartPipedProcess() error = %v, want manager-stopping error", err) } if err := mgr.StartShell(); !errors.Is(err, ErrManagerStopping) { t.Fatalf("StartShell() error = %v, want manager-stopping error", err) } if err := mgr.StartVscode(context.Background(), "dark"); !errors.Is(err, ErrManagerStopping) { t.Fatalf("StartVscode() error = %v, want manager-stopping error", err) } if _, err := mgr.ShellManager().Start("terminal", shell.DefaultConfig(t.TempDir())); err == nil { t.Fatal("terminal shell Start() succeeded after BeginStop()") } } func TestManager_StopForTeardownCancelsAndDrainsOwnedOperation(t *testing.T) { mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) allowRelease := make(chan struct{}) var allowReleaseOnce sync.Once signalRelease := func() { allowReleaseOnce.Do(func() { close(allowRelease) }) } operationCtx, release, err := mgr.BeginOwnedOperation(context.Background()) if err != nil { t.Fatalf("BeginOwnedOperation() error = %v", err) } t.Cleanup(func() { signalRelease() release() }) canceled := make(chan struct{}) go func() { <-operationCtx.Done() close(canceled) <-allowRelease release() }() stopDone := make(chan error, 1) go func() { stopDone <- mgr.StopForTeardown(context.Background()) }() <-canceled select { case err := <-stopDone: t.Fatalf("StopForTeardown() returned before the owned operation released: %v", err) default: } signalRelease() if err := <-stopDone; err != nil { t.Fatalf("StopForTeardown() error = %v", err) } if _, _, err := mgr.BeginOwnedOperation(context.Background()); !errors.Is(err, ErrManagerStopping) { t.Fatalf("BeginOwnedOperation() after teardown error = %v, want %v", err, ErrManagerStopping) } } func TestManager_TeardownAdmissionDrainHonorsContextAndRetries(t *testing.T) { mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) release, err := mgr.admitStart() if err != nil { t.Fatalf("admitStart() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) cancel() err = mgr.StopForTeardown(ctx) if !errors.Is(err, context.Canceled) { t.Fatalf("StopForTeardown() error = %v, want %v", err, context.Canceled) } release() if err := mgr.StopForTeardown(context.Background()); err != nil { t.Fatalf("StopForTeardown() retry error = %v", err) } } func TestManager_TeardownWaitsForWorkspaceProcessReap(t *testing.T) { mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) proc := &commandProcess{ info: ProcessInfo{ID: "workspace-blocked-reap"}, stopSignal: make(chan struct{}), done: make(chan struct{}), } mgr.processRunner.processes[proc.info.ID] = proc stopDone := make(chan error, 1) go func() { stopDone <- mgr.StopForTeardown(context.Background()) }() select { case <-proc.stopSignal: case err := <-stopDone: t.Fatalf("StopForTeardown() returned before workspace process stop/reap: %v", err) } select { case err := <-stopDone: t.Fatalf("StopForTeardown() returned before workspace process reap: %v", err) default: } close(proc.done) if err := <-stopDone; err != nil { t.Fatalf("StopForTeardown() error = %v", err) } } func TestManager_TeardownReportsMainProcessGroupReapFailure(t *testing.T) { mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) mgr.cmd = &exec.Cmd{Process: &os.Process{Pid: 424242}} mgr.status.Store(StatusRunning) mgr.groupAliveFn = func(int) bool { return true } mgr.terminateGroupFn = func(int) error { return nil } mgr.killGroupFn = func(int) error { return nil } mgr.waitGroupExitFn = func(context.Context, int) bool { return false } t.Cleanup(func() { mgr.groupAliveFn = func(int) bool { return false } mgr.waitGroupExitFn = nil _ = mgr.StopForTeardown(context.Background()) }) err := mgr.StopForTeardown(context.Background()) if err == nil || !strings.Contains(err.Error(), "remains alive") { t.Fatalf("StopForTeardown() error = %v, want process-group reap failure", err) } } func TestManager_TeardownReportsMainGoroutineReapFailure(t *testing.T) { mgr := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t)) mgr.cmd = &exec.Cmd{Process: &os.Process{Pid: 424243}} mgr.status.Store(StatusRunning) mgr.wg.Add(1) t.Cleanup(func() { mgr.wg.Done() mgr.managerWaitFn = nil _ = mgr.StopForTeardown(context.Background()) }) mgr.groupAliveFn = func(int) bool { return false } mgr.terminateGroupFn = func(int) error { return nil } mgr.killGroupFn = func(int) error { return nil } mgr.managerWaitFn = func(context.Context, <-chan struct{}, time.Duration) bool { return false } err := mgr.StopForTeardown(context.Background()) if err == nil || !strings.Contains(err.Error(), "goroutines were not reaped") { t.Fatalf("StopForTeardown() error = %v, want manager reap failure", err) } } func setServiceTempTestEnv(t *testing.T) string { t.Helper() root := t.TempDir() for _, key := range []string{"TMPDIR", "TMP", "TEMP"} { t.Setenv(key, root) } return root } func assertNoAgentTempRoot(t *testing.T, serviceTemp string) { t.Helper() if _, err := os.Stat(filepath.Join(serviceTemp, "kandev-agent")); !os.IsNotExist(err) { t.Fatalf("unexpected kandev-agent root, err = %v", err) } }