Files
kandev/apps/backend/internal/agentctl/server/process/manager_test.go
T
2026-07-30 20:49:07 +01:00

335 lines
9.7 KiB
Go

package process
import (
"context"
"errors"
"io"
"os"
"os/exec"
"slices"
"strings"
"syscall"
"testing"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/pkg/agent"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublishMCPAttachmentPublishesWithoutBlocking(t *testing.T) {
m := &Manager{
updatesCh: make(chan adapter.AgentEvent, 1),
logger: newTestLogger(t),
}
evidence := streams.MCPAttachmentEvidence{
AttemptID: "attempt-1",
ServerName: "kandev",
Kind: streams.MCPAttachmentEvidenceDelivered,
}
m.PublishMCPAttachment(evidence)
select {
case event := <-m.updatesCh:
if event.Type != streams.EventTypeMCPAttachment {
t.Fatalf("event type = %q, want MCP attachment", event.Type)
}
if event.MCPAttachment == nil || event.MCPAttachment.AttemptID != "attempt-1" {
t.Fatalf("attachment event = %+v, want attempt evidence", event.MCPAttachment)
}
default:
t.Fatal("expected MCP attachment update")
}
// A saturated diagnostics channel must not stall agent lifecycle work.
m.updatesCh <- adapter.AgentEvent{Type: streams.EventTypeMessageChunk}
m.PublishMCPAttachment(evidence)
if got := len(m.updatesCh); got != 1 {
t.Fatalf("queued events = %d, want 1", got)
}
}
func TestPublishMCPAttachmentAttemptCarriesBackendOwnedIdentity(t *testing.T) {
m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1), logger: newTestLogger(t)}
m.PublishMCPAttachmentAttempt(streams.MCPAttachmentAttempt{
AttemptID: "attempt-1",
TaskID: "task-1",
SessionID: "session-1",
ExecutionID: "execution-1",
})
event := <-m.updatesCh
if event.MCPAttachmentAttempt == nil {
t.Fatal("expected MCP attachment attempt")
}
if got := event.MCPAttachmentAttempt; got.TaskID != "task-1" || got.ExecutionID != "execution-1" {
t.Fatalf("attempt identity = %+v", got)
}
}
// stubAdapter is a minimal AgentAdapter for testing the startup sequence.
type stubAdapter struct {
connectCalled bool
requiresProcessKill bool
updatesCh chan adapter.AgentEvent
}
func newStubAdapter() *stubAdapter {
return &stubAdapter{updatesCh: make(chan adapter.AgentEvent, 10)}
}
type oneShotStubAdapter struct {
*stubAdapter
}
func newOneShotStubAdapter() *oneShotStubAdapter {
return &oneShotStubAdapter{stubAdapter: newStubAdapter()}
}
func (s *oneShotStubAdapter) IsOneShot() bool { return true }
func (s *stubAdapter) PrepareEnvironment() (map[string]string, error) { return nil, nil }
func (s *stubAdapter) PrepareCommandArgs() []string { return nil }
func (s *stubAdapter) Connect(stdin io.Writer, stdout io.Reader) error {
s.connectCalled = true
return nil
}
func (s *stubAdapter) Initialize(context.Context) error { return nil }
func (s *stubAdapter) GetAgentInfo() *adapter.AgentInfo { return nil }
func (s *stubAdapter) NewSession(_ context.Context, _ []types.McpServer) (string, error) {
return "", nil
}
func (s *stubAdapter) LoadSession(context.Context, string, []types.McpServer) error { return nil }
func (s *stubAdapter) Prompt(context.Context, string, []v1.MessageAttachment, uint64) error {
return nil
}
func (s *stubAdapter) Cancel(context.Context) error { return nil }
func (s *stubAdapter) Updates() <-chan adapter.AgentEvent { return s.updatesCh }
func (s *stubAdapter) GetSessionID() string { return "" }
func (s *stubAdapter) GetOperationID() string { return "" }
func (s *stubAdapter) SetPermissionHandler(adapter.PermissionHandler) {}
func (s *stubAdapter) Close() error {
close(s.updatesCh)
return nil
}
func (s *stubAdapter) RequiresProcessKill() bool { return s.requiresProcessKill }
func TestStartProcessPipes_CreatesAllPipes(t *testing.T) {
m := &Manager{
cmd: exec.Command("cat"),
}
err := m.startProcessPipes()
require.NoError(t, err)
assert.NotNil(t, m.stdin, "stdin pipe should be created")
assert.NotNil(t, m.stdout, "stdout pipe should be created")
assert.NotNil(t, m.stderr, "stderr pipe should be created")
// Clean up
_ = m.stdin.Close()
}
func TestStartProcessPipes_FailsAfterProcessStarted(t *testing.T) {
// Regression test: documents the bug where cmd.Start() was called inside
// buildFinalCommand() before pipes were created.
// exec.Cmd pipes cannot be created after Start() is called.
cmd := fixtureCmd("sleep 10")
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = cmd.Process.Kill() })
_, err := cmd.StdinPipe()
assert.Error(t, err, "pipes cannot be created after process starts")
}
func TestBuildFinalCommand_DoesNotStartProcess(t *testing.T) {
log := newTestLogger(t)
stub := newStubAdapter()
m := &Manager{
cfg: &config.InstanceConfig{
AgentArgs: []string{"cat"},
WorkDir: t.TempDir(),
},
logger: log,
adapter: stub,
}
err := m.buildFinalCommand()
require.NoError(t, err)
assert.NotNil(t, m.cmd, "cmd should be created")
assert.Nil(t, m.cmd.Process, "process should not be started yet")
}
func TestManager_Start_PipesCreatedBeforeProcessStart(t *testing.T) {
log := newTestLogger(t)
stub := newStubAdapter()
workDir := t.TempDir()
m := &Manager{
cfg: &config.InstanceConfig{
AgentArgs: fixtureArgs(),
WorkDir: workDir,
AgentEnv: fixtureEnvSlice("cat"),
},
logger: log,
adapter: stub,
adapterCfg: &adapter.Config{WorkDir: workDir},
updatesCh: make(chan adapter.AgentEvent, 100),
pendingPermissions: make(map[string]*PendingPermission),
workspaceTracker: NewWorkspaceTracker(workDir, log),
}
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
// Skip buildAdapterConfig since we already have a stub adapter.
// Call the sub-steps in the same order as Start() to verify correctness.
err := m.buildFinalCommand()
require.NoError(t, err)
assert.Nil(t, m.cmd.Process, "process must not be started after buildFinalCommand")
err = m.startProcessPipes()
require.NoError(t, err)
assert.NotNil(t, m.stdin)
assert.NotNil(t, m.stdout)
assert.NotNil(t, m.stderr)
// Now start the process — this is the fix: Start() happens after pipes.
err = m.cmd.Start()
require.NoError(t, err)
waited := false
t.Cleanup(func() {
if waited || m.cmd == nil || m.cmd.Process == nil {
return
}
_ = killProcessGroup(m.cmd.Process.Pid)
_ = m.cmd.Process.Kill()
_ = m.cmd.Wait()
})
assert.NotNil(t, m.cmd.Process, "process should be running")
processLifecycle, err := installProcessLifecycle(m.cmd)
require.NoError(t, err)
defer releaseProcessLifecycle(processLifecycle)
// Adapter connects after process starts.
err = stub.Connect(m.stdin, m.stdout)
require.NoError(t, err)
assert.True(t, stub.connectCalled)
// Clean up: close stdin so cat exits, then wait.
_ = m.stdin.Close()
_ = m.cmd.Wait()
waited = true
}
func TestFormatAgentStartError_E2BIGIncludesEnvDiagnostics(t *testing.T) {
err := formatAgentStartError(&os.PathError{Op: "fork/exec", Path: "npx", Err: syscall.E2BIG}, []string{
"SMALL=1",
"KANDEV_WAKE_PAYLOAD_JSON=" + strings.Repeat("x", 100),
"PATH=/usr/bin",
})
msg := err.Error()
if !strings.Contains(msg, "environment/arguments too large") {
t.Fatalf("missing actionable E2BIG message: %s", msg)
}
if !strings.Contains(msg, "env_bytes=") {
t.Fatalf("missing env byte diagnostics: %s", msg)
}
if !strings.Contains(msg, "KANDEV_WAKE_PAYLOAD_JSON") {
t.Fatalf("missing largest env key diagnostics: %s", msg)
}
if !errors.Is(err, syscall.E2BIG) {
t.Fatalf("formatted error must preserve E2BIG wrapping: %v", err)
}
}
func TestBuildAdapterConfig_StripEnvRemovesDeclaredVars(t *testing.T) {
log := newTestLogger(t)
m := &Manager{
cfg: &config.InstanceConfig{
AgentArgs: []string{"cat"},
WorkDir: t.TempDir(),
AgentEnv: []string{
"ACP_BACKEND=windsurf",
"PATH=/usr/bin",
"HOME=/root",
},
StripEnv: []string{"ACP_BACKEND"},
Protocol: agent.ProtocolACP,
},
logger: log,
}
require.NoError(t, m.buildAdapterConfig())
t.Cleanup(func() { _ = m.adapter.Close() })
for _, e := range m.cfg.AgentEnv {
if strings.HasPrefix(e, "ACP_BACKEND=") {
t.Errorf("ACP_BACKEND not stripped from AgentEnv: %q", e)
}
}
if !slices.Contains(m.cfg.AgentEnv, "PATH=/usr/bin") {
t.Errorf("PATH was stripped but should have been kept")
}
if !slices.Contains(m.cfg.AgentEnv, "HOME=/root") {
t.Errorf("HOME was stripped but should have been kept")
}
}
func TestStartOneShotPreservesConfiguredTempEnvironment(t *testing.T) {
log := newTestLogger(t)
workDir := t.TempDir()
serviceTemp := setServiceTempTestEnv(t)
agentEnv := []string{
"PATH=/usr/bin",
"TMPDIR=/configured/tmpdir",
"TMP=/configured/tmp",
"TEMP=/configured/temp",
}
m := &Manager{
cfg: &config.InstanceConfig{
SessionID: "session-1",
WorkDir: workDir,
AgentEnv: agentEnv,
},
logger: log,
adapter: newOneShotStubAdapter(),
adapterCfg: &adapter.Config{
OneShotConfig: &adapter.OneShotConfig{
Env: agentEnv,
WorkDir: workDir,
},
},
updatesCh: make(chan adapter.AgentEvent, 100),
pendingPermissions: make(map[string]*PendingPermission),
workspaceTracker: NewWorkspaceTracker(workDir, log),
}
require.NoError(t, m.startOneShot())
t.Cleanup(func() {
close(m.stopCh)
m.wg.Wait()
m.workspaceTracker.Stop()
})
want := map[string]string{
"TMPDIR": "/configured/tmpdir",
"TMP": "/configured/tmp",
"TEMP": "/configured/temp",
}
for key, value := range want {
if got := lookupEnvValue(m.adapterCfg.OneShotConfig.Env, key); got != value {
t.Fatalf("OneShotConfig.Env %s = %q, want configured service value %q", key, got, value)
}
}
assertNoAgentTempRoot(t, serviceTemp)
}