706 lines
24 KiB
Go
706 lines
24 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/managedruntime"
|
|
"github.com/kandev/kandev/internal/agentctl/server/adapter"
|
|
"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_BuildFinalCommandPreparesManagedNpmPrefixOutsideAgentHome(t *testing.T) {
|
|
home := t.TempDir()
|
|
workDir := t.TempDir()
|
|
manager := NewManager(&config.InstanceConfig{
|
|
WorkDir: workDir,
|
|
AgentArgs: []string{
|
|
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
|
|
"@scope/managed-acp@1.2.3",
|
|
},
|
|
AgentEnv: []string{"HOME=" + home},
|
|
}, newTestLogger(t))
|
|
manager.adapter = newStubAdapter()
|
|
|
|
if err := manager.buildFinalCommand(); err != nil {
|
|
t.Fatalf("buildFinalCommand() error = %v", err)
|
|
}
|
|
prefix := manager.cmd.Args[4]
|
|
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
|
|
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
|
|
}
|
|
info, err := os.Stat(prefix)
|
|
if err != nil || !info.IsDir() {
|
|
t.Fatalf("managed npm prefix stat = (%v, %v), want an existing directory", info, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
|
|
t.Fatalf("managed npm prefix was created under agent home, stat error = %v", err)
|
|
}
|
|
if manager.cmd.Dir != workDir {
|
|
t.Fatalf("managed runtime working directory = %q, want workspace %q", manager.cmd.Dir, workDir)
|
|
}
|
|
}
|
|
|
|
func TestManager_BuildFinalCommandPreparesOneShotNpmPrefixesWithoutChangingArgs(t *testing.T) {
|
|
initialArgs := []string{"npx", "--prefix", managedruntime.NPMProjectPrefix, "pkg@1.2.3", "--initial-only"}
|
|
continueArgs := []string{"npx", "--prefix", managedruntime.NPMProjectPrefix, "pkg@1.2.3", "--continue-only"}
|
|
manager := NewManager(&config.InstanceConfig{
|
|
WorkDir: t.TempDir(),
|
|
AgentArgs: []string{"persistent-agent", "--persistent-only"},
|
|
}, newTestLogger(t))
|
|
manager.adapter = newStubAdapter()
|
|
manager.adapterCfg = &adapter.Config{OneShotConfig: &adapter.OneShotConfig{
|
|
InitialArgs: initialArgs,
|
|
ContinueArgs: continueArgs,
|
|
}}
|
|
|
|
if err := manager.buildFinalCommand(); err != nil {
|
|
t.Fatalf("buildFinalCommand() error = %v", err)
|
|
}
|
|
oneShot := manager.adapterCfg.OneShotConfig
|
|
for name, args := range map[string][]string{
|
|
"initial": oneShot.InitialArgs,
|
|
"continue": oneShot.ContinueArgs,
|
|
} {
|
|
if len(args) != 5 || args[4] != "--"+name+"-only" {
|
|
t.Errorf("one-shot %s args = %#v, want its original command arguments", name, args)
|
|
continue
|
|
}
|
|
prefix := args[2]
|
|
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
|
|
t.Errorf("one-shot %s prefix = %q, want an absolute path under %q", name, prefix, os.TempDir())
|
|
}
|
|
}
|
|
if initialArgs[2] != managedruntime.NPMProjectPrefix || continueArgs[2] != managedruntime.NPMProjectPrefix {
|
|
t.Fatalf("preparing one-shot args mutated source command slices: initial=%#v continue=%#v", initialArgs, continueArgs)
|
|
}
|
|
}
|
|
|
|
func TestManager_BuildPipedProcessRequestPreparesManagedNpmPrefixOutsideAgentHome(t *testing.T) {
|
|
home := t.TempDir()
|
|
manager := NewManager(&config.InstanceConfig{
|
|
WorkDir: t.TempDir(),
|
|
AgentEnv: []string{"HOME=" + home},
|
|
}, newTestLogger(t))
|
|
args := append(managedruntime.NPMProjectPrefixArgs(), "config", "get", "cache")
|
|
|
|
req, err := manager.buildPipedProcessRequest(PipedStartRequest{Command: "npm", Args: args})
|
|
if err != nil {
|
|
t.Fatalf("buildPipedProcessRequest() error = %v", err)
|
|
}
|
|
prefix := req.Args[1]
|
|
if !filepath.IsAbs(prefix) || !strings.HasPrefix(filepath.Clean(prefix), filepath.Clean(os.TempDir())+string(filepath.Separator)) {
|
|
t.Fatalf("managed npm prefix = %q, want an absolute path under %q", prefix, os.TempDir())
|
|
}
|
|
if _, err := os.Stat(prefix); err != nil {
|
|
t.Fatalf("managed npm prefix was not provisioned: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(home, ".kandev", "managed-npm-runtime")); !os.IsNotExist(err) {
|
|
t.Fatalf("managed npm prefix was created under agent home, stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestManager_BuildFinalCommandFailsSafelyWhenManagedNpmPrefixIsUnavailable(t *testing.T) {
|
|
tempFile := filepath.Join(t.TempDir(), "not-a-temp-directory")
|
|
if err := os.WriteFile(tempFile, []byte("temp"), 0o600); err != nil {
|
|
t.Fatalf("write temp file: %v", err)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
t.Setenv("TEMP", tempFile)
|
|
t.Setenv("TMP", tempFile)
|
|
} else {
|
|
t.Setenv("TMPDIR", tempFile)
|
|
}
|
|
workDir := t.TempDir()
|
|
agentEnv := []string{"HOME=" + t.TempDir(), "KEEP_THIS=unchanged"}
|
|
manager := NewManager(&config.InstanceConfig{
|
|
WorkDir: workDir,
|
|
AgentArgs: []string{
|
|
"npx", "--yes", "--prefer-offline", "--prefix", "~/.kandev/managed-npm-runtime",
|
|
"@scope/managed-acp@1.2.3",
|
|
},
|
|
AgentEnv: agentEnv,
|
|
}, newTestLogger(t))
|
|
manager.adapter = newStubAdapter()
|
|
|
|
err := manager.buildFinalCommand()
|
|
if err == nil {
|
|
t.Fatal("buildFinalCommand() succeeded without an available npm prefix")
|
|
}
|
|
if strings.Contains(err.Error(), tempFile) {
|
|
t.Fatalf("prefix preparation error exposed temp path: %q", err)
|
|
}
|
|
if manager.cmd != nil {
|
|
t.Fatal("agent command was constructed despite unavailable npm prefix")
|
|
}
|
|
if got := envValue(manager.cfg.AgentEnv, "KEEP_THIS"); got != "unchanged" {
|
|
t.Fatalf("agent environment changed: KEEP_THIS=%q", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workDir, ".kandev")); !os.IsNotExist(err) {
|
|
t.Fatalf("workspace received npm state, stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|