Files
kandev/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go
T
2026-07-28 21:19:27 +01:00

1179 lines
40 KiB
Go

package lifecycle
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/mcpconfig"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
)
// mockPassthroughProfileResolver is a mock for testing passthrough verification
type mockPassthroughProfileResolver struct {
cliPassthrough bool
envVars []settingsmodels.ProfileEnvVar
err error
agentName string
}
func (m *mockPassthroughProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
if m.err != nil {
return nil, m.err
}
return &AgentProfileInfo{
ProfileID: profileID,
AgentName: m.agentName,
CLIPassthrough: m.cliPassthrough,
EnvVars: m.envVars,
}, nil
}
func assertClaudePassthroughMCPConfig(t *testing.T, cmd agents.Command, wantURL string) {
t.Helper()
args := cmd.Args()
for i, arg := range args {
if arg != "--mcp-config" {
continue
}
if i+1 >= len(args) {
t.Fatalf("--mcp-config missing path in command %v", args)
}
if i != len(args)-2 {
t.Fatalf("--mcp-config must be final flag pair for claude variadic parsing: %v", args)
}
var payload struct {
MCPServers map[string]struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"mcpServers"`
}
data, err := os.ReadFile(args[i+1])
if err != nil {
t.Fatalf("read generated MCP config: %v", err)
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("generated MCP config is not JSON: %v\n%s", err, data)
}
kandev := payload.MCPServers["kandev"]
if kandev.Type != "http" {
t.Fatalf("kandev MCP type = %q, want http", kandev.Type)
}
if kandev.URL != wantURL {
t.Fatalf("kandev MCP url = %q, want %q", kandev.URL, wantURL)
}
return
}
t.Fatalf("passthrough command missing --mcp-config: %v", args)
}
func newPassthroughMCPTestManager(t *testing.T, agentName string) (*Manager, *AgentExecution, *AgentProfileInfo) {
t.Helper()
mgr := newTestManager(t)
mgr.dataDir = t.TempDir()
mgr.profileResolver = &mockPassthroughProfileResolver{
agentName: agentName,
cliPassthrough: true,
}
execution := &AgentExecution{
ID: "exec-1",
TaskID: "task-1",
SessionID: "session-1",
AgentProfileID: "profile-1",
WorkspacePath: t.TempDir(),
Metadata: map[string]interface{}{
"standalone_port": 45678,
},
standalonePort: 45678,
}
profile := &AgentProfileInfo{
ProfileID: "profile-1",
AgentName: agentName,
Model: "default",
CLIPassthrough: true,
}
return mgr, execution, profile
}
func newClaudePassthroughMCPTestManager(t *testing.T) (*Manager, *AgentExecution, *AgentProfileInfo) {
t.Helper()
return newPassthroughMCPTestManager(t, "claude-acp")
}
func TestBuildPassthroughCommand(t *testing.T) {
tests := []struct {
name string
agent agents.PassthroughAgent
opts agents.PassthroughOptions
wantCmd []string
}{
{
name: "basic command without options",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli", "--verbose")},
},
},
opts: agents.PassthroughOptions{Resume: true},
wantCmd: []string{"test-cli", "--verbose"},
},
{
name: "command with model",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ModelFlag: agents.NewParam("--model", "{model}")},
},
},
opts: agents.PassthroughOptions{Model: "gpt-4"},
wantCmd: []string{"test-cli", "--model", "gpt-4"},
},
{
name: "resume with single-word flag",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ResumeFlag: agents.NewParam("-c")},
},
},
opts: agents.PassthroughOptions{Resume: true},
wantCmd: []string{"test-cli", "-c"},
},
{
name: "resume with multi-word flag",
agent: &testAgent{
id: "gemini-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("gemini"), ResumeFlag: agents.NewParam("--resume", "latest")},
},
},
opts: agents.PassthroughOptions{Resume: true},
wantCmd: []string{"gemini", "--resume", "latest"},
},
{
name: "permission settings as CLI flags",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")},
PermSettings: map[string]agents.PermissionSetting{
"auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes"},
},
},
},
opts: agents.PassthroughOptions{
PermissionValues: map[string]bool{"auto_approve": true},
},
wantCmd: []string{"test-cli", "--yes"},
},
{
name: "full resume with model + settings + resume flag",
agent: &testAgent{
id: "claude-code",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"),
ModelFlag: agents.NewParam("--model", "{model}"),
ResumeFlag: agents.NewParam("-c"),
},
PermSettings: map[string]agents.PermissionSetting{
"dangerously_skip_permissions": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"},
},
},
},
opts: agents.PassthroughOptions{
Model: "claude-sonnet-4",
Resume: true,
PermissionValues: map[string]bool{"dangerously_skip_permissions": true},
},
wantCmd: []string{"npx", "-y", "@anthropic-ai/claude-code", "--model", "claude-sonnet-4", "--dangerously-skip-permissions", "-c"},
},
{
name: "permission setting with cli_flag_value",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")},
PermSettings: map[string]agents.PermissionSetting{
"auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--approve-level", CLIFlagValue: "all"},
},
},
},
opts: agents.PassthroughOptions{
PermissionValues: map[string]bool{"auto_approve": true},
},
wantCmd: []string{"test-cli", "--approve-level", "all"},
},
{
name: "new session with prompt (positional)",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")},
},
},
opts: agents.PassthroughOptions{Prompt: "fix the bug"},
wantCmd: []string{"test-cli", "fix the bug"},
},
{
name: "new session with prompt flag",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), PromptFlag: agents.NewParam("--prompt", "{prompt}")},
},
},
opts: agents.PassthroughOptions{Prompt: "fix the bug"},
wantCmd: []string{"test-cli", "--prompt", "fix the bug"},
},
{
name: "resume with session ID",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("test-cli"),
SessionResumeFlag: agents.NewParam("--resume"),
},
},
},
opts: agents.PassthroughOptions{SessionID: "sess-123"},
wantCmd: []string{"test-cli", "--resume", "sess-123"},
},
{
name: "mock agent resume with -c flag",
agent: &testAgent{
id: "mock-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("mock-agent", "--tui"),
ModelFlag: agents.NewParam("--model", "{model}"),
ResumeFlag: agents.NewParam("-c"),
},
},
},
opts: agents.PassthroughOptions{Model: "mock-fast", Resume: true},
wantCmd: []string{"mock-agent", "--tui", "--model", "mock-fast", "-c"},
},
{
name: "mock agent session resume with --resume flag",
agent: &testAgent{
id: "mock-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("mock-agent", "--tui"),
ModelFlag: agents.NewParam("--model", "{model}"),
SessionResumeFlag: agents.NewParam("--resume"),
},
},
},
opts: agents.PassthroughOptions{Model: "mock-fast", SessionID: "sess-123"},
wantCmd: []string{"mock-agent", "--tui", "--model", "mock-fast", "--resume", "sess-123"},
},
{
name: "user cli flag tokens appended after model + settings",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("test-cli"),
ModelFlag: agents.NewParam("--model", "{model}"),
ResumeFlag: agents.NewParam("-c"),
},
PermSettings: map[string]agents.PermissionSetting{
"auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes"},
},
},
},
opts: agents.PassthroughOptions{
Model: "gpt-4",
Resume: true,
PermissionValues: map[string]bool{"auto_approve": true},
CLIFlagTokens: []string{"--debug", "--log-level", "trace"},
},
wantCmd: []string{"test-cli", "--model", "gpt-4", "--yes", "--debug", "--log-level", "trace", "-c"},
},
{
name: "mcp args go after positional prompt because claude treats --mcp-config as variadic",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("test-cli"),
},
},
},
opts: agents.PassthroughOptions{
Prompt: "fix the bug",
MCPArgs: []string{"--mcp-config", "/tmp/kandev-mcp.json"},
},
wantCmd: []string{"test-cli", "fix the bug", "--mcp-config", "/tmp/kandev-mcp.json"},
},
{
name: "mcp args are appended after the resume flag",
agent: &testAgent{
id: "test-agent",
StandardPassthrough: agents.StandardPassthrough{
Cfg: agents.PassthroughConfig{
Supported: true,
PassthroughCmd: agents.NewCommand("test-cli"),
ResumeFlag: agents.NewParam("--resume"),
},
},
},
opts: agents.PassthroughOptions{
Resume: true,
MCPArgs: []string{"-c", `mcp_servers.kandev.url="http://x/mcp"`},
},
wantCmd: []string{"test-cli", "--resume", "-c", `mcp_servers.kandev.url="http://x/mcp"`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.agent.BuildPassthroughCommand(tt.opts).Args()
if len(got) != len(tt.wantCmd) {
t.Errorf("BuildPassthroughCommand() = %v, want %v", got, tt.wantCmd)
return
}
for i, arg := range got {
if arg != tt.wantCmd[i] {
t.Errorf("BuildPassthroughCommand()[%d] = %q, want %q", i, arg, tt.wantCmd[i])
}
}
})
}
}
func TestPassthroughAgentCommandInjectsKandevMCPConfig(t *testing.T) {
mgr, execution, profile := newClaudePassthroughMCPTestManager(t)
_, _, _, cmd, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
assertClaudePassthroughMCPConfig(t, cmd, "http://localhost:45678/mcp")
}
func TestFreshPassthroughCommandInjectsKandevMCPConfig(t *testing.T) {
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
_, _, cmd, err := mgr.freshPassthroughCommand(context.Background(), execution)
if err != nil {
t.Fatalf("freshPassthroughCommand returned error: %v", err)
}
assertClaudePassthroughMCPConfig(t, cmd, "http://localhost:45678/mcp")
}
func TestResumePassthroughCommandInjectsKandevMCPConfig(t *testing.T) {
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
resolved, err := mgr.resolvePassthroughAgent(context.Background(), execution)
if err != nil {
t.Fatalf("resolvePassthroughAgent returned error: %v", err)
}
cmd, err := mgr.resumePassthroughCommand(context.Background(), execution, resolved, true)
if err != nil {
t.Fatalf("resumePassthroughCommand returned error: %v", err)
}
assertClaudePassthroughMCPConfig(t, cmd, "http://localhost:45678/mcp")
}
func TestPassthroughCodexInjectsKandevMCPArgs(t *testing.T) {
mgr, execution, profile := newPassthroughMCPTestManager(t, "codex-acp")
_, _, _, cmd, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
joined := strings.Join(cmd.Args(), " ")
if want := `-c mcp_servers.kandev.url="http://localhost:45678/mcp"`; !strings.Contains(joined, want) {
t.Fatalf("codex command missing kandev override %q in: %v", want, cmd.Args())
}
// Codex injects via CLI flags only — it must not write any config file.
if files := getPassthroughMCPFiles(execution); len(files) != 0 {
t.Fatalf("codex must not write MCP config files, got %v", files)
}
}
func TestPassthroughOpenCodeInjectsConfigEnv(t *testing.T) {
mgr, execution, profile := newPassthroughMCPTestManager(t, "opencode-acp")
_, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
files := getPassthroughMCPFiles(execution)
if len(files) != 1 {
t.Fatalf("expected one opencode config file, got %v", files)
}
if _, err := os.Stat(files[0]); err != nil {
t.Fatalf("opencode config not written: %v", err)
}
// OPENCODE_CONFIG must be merged into the passthrough environment.
env := mgr.buildPassthroughEnv(context.Background(), execution, nil)
if env["OPENCODE_CONFIG"] != files[0] {
t.Fatalf("OPENCODE_CONFIG = %q, want %q", env["OPENCODE_CONFIG"], files[0])
}
}
func TestPassthroughCursorWritesProjectFile(t *testing.T) {
mgr, execution, profile := newPassthroughMCPTestManager(t, "cursor-acp")
cursorPath := filepath.Join(execution.WorkspacePath, ".cursor", "mcp.json")
_, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
data, err := os.ReadFile(cursorPath)
if err != nil {
t.Fatalf("cursor mcp.json not written: %v", err)
}
if !strings.Contains(string(data), kandevMCPServerName) {
t.Fatalf("cursor mcp.json missing kandev server: %s", data)
}
}
func TestPassthroughCursorMergesIntoExistingProjectFile(t *testing.T) {
mgr, execution, profile := newPassthroughMCPTestManager(t, "cursor-acp")
cursorPath := filepath.Join(execution.WorkspacePath, ".cursor", "mcp.json")
if err := os.MkdirAll(filepath.Dir(cursorPath), 0o755); err != nil {
t.Fatal(err)
}
// A user file with their own top-level key AND an existing MCP server.
userContent := `{"user":"config","mcpServers":{"user-srv":{"url":"https://user"}}}`
if err := os.WriteFile(cursorPath, []byte(userContent), 0o644); err != nil {
t.Fatal(err)
}
if _, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile); err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
data, err := os.ReadFile(cursorPath)
if err != nil {
t.Fatalf("read cursor mcp.json: %v", err)
}
var merged struct {
User string `json:"user"`
MCPServers map[string]struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"mcpServers"`
}
if err := json.Unmarshal(data, &merged); err != nil {
t.Fatalf("merged file not JSON: %v\n%s", err, data)
}
if merged.User != "config" {
t.Errorf("user top-level key lost: %s", data)
}
if merged.MCPServers["user-srv"].URL != "https://user" {
t.Errorf("user's existing server dropped: %s", data)
}
// Cursor emits remote servers as {url, headers} with no type field.
if merged.MCPServers[kandevMCPServerName].URL != "http://localhost:45678/mcp" {
t.Errorf("kandev server not merged in: %s", data)
}
// A file merged into the user's must NOT be tracked for cleanup (it's theirs).
if files := getPassthroughMCPFiles(execution); len(files) != 0 {
t.Fatalf("merged user file must not be tracked for cleanup, got %v", files)
}
}
func TestPassthroughPiWritesProjectFile(t *testing.T) {
mgr, execution, profile := newPassthroughMCPTestManager(t, "pi-acp")
piPath := filepath.Join(execution.WorkspacePath, ".pi", "mcp.json")
if _, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile); err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
data, err := os.ReadFile(piPath)
if err != nil {
t.Fatalf("pi mcp.json not written: %v", err)
}
var payload struct {
MCPServers map[string]struct {
Transport string `json:"transport"`
URL string `json:"url"`
} `json:"mcpServers"`
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("pi mcp.json not valid JSON: %v\n%s", err, data)
}
kandev := payload.MCPServers[kandevMCPServerName]
if kandev.Transport != "streamable-http" {
t.Fatalf("kandev transport = %q, want streamable-http", kandev.Transport)
}
if kandev.URL != "http://localhost:45678/mcp" {
t.Fatalf("kandev URL = %q", kandev.URL)
}
}
func TestPassthroughPiMergesIntoExistingProjectFile(t *testing.T) {
mgr, execution, profile := newPassthroughMCPTestManager(t, "pi-acp")
piPath := filepath.Join(execution.WorkspacePath, ".pi", "mcp.json")
if err := os.MkdirAll(filepath.Dir(piPath), 0o755); err != nil {
t.Fatal(err)
}
userContent := `{"settings":{"toolPrefix":"mcp"},"mcpServers":{"user-srv":{"command":"user-tool"}}}`
if err := os.WriteFile(piPath, []byte(userContent), 0o644); err != nil {
t.Fatal(err)
}
if _, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile); err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
data, err := os.ReadFile(piPath)
if err != nil {
t.Fatalf("read pi mcp.json: %v", err)
}
var merged struct {
Settings map[string]string `json:"settings"`
MCPServers map[string]struct {
Command string `json:"command"`
Transport string `json:"transport"`
URL string `json:"url"`
} `json:"mcpServers"`
}
if err := json.Unmarshal(data, &merged); err != nil {
t.Fatalf("merged file not JSON: %v\n%s", err, data)
}
if merged.Settings["toolPrefix"] != "mcp" {
t.Fatalf("existing settings not preserved: %+v", merged.Settings)
}
if merged.MCPServers["user-srv"].Command != "user-tool" {
t.Fatalf("existing server not preserved: %+v", merged.MCPServers)
}
if merged.MCPServers[kandevMCPServerName].URL != "http://localhost:45678/mcp" {
t.Fatalf("kandev server not merged: %+v", merged.MCPServers)
}
}
func TestGetPassthroughMCPFilesDecodesRestartShapes(t *testing.T) {
// After a backend restart, Metadata is rehydrated from JSON, so a []string
// becomes []interface{} of strings. The reader must tolerate both shapes.
t.Run("in-memory []string", func(t *testing.T) {
exec := &AgentExecution{Metadata: map[string]interface{}{
metadataKeyPassthroughMCPFiles: []string{"/a.json", "/b.json"},
}}
got := getPassthroughMCPFiles(exec)
if len(got) != 2 || got[0] != "/a.json" || got[1] != "/b.json" {
t.Fatalf("got %v, want [/a.json /b.json]", got)
}
})
t.Run("JSON-decoded []interface{}", func(t *testing.T) {
exec := &AgentExecution{Metadata: map[string]interface{}{
metadataKeyPassthroughMCPFiles: []interface{}{"/a.json", 42, "/b.json"},
}}
got := getPassthroughMCPFiles(exec)
// Non-string entries are dropped, not panicked on.
if len(got) != 2 || got[0] != "/a.json" || got[1] != "/b.json" {
t.Fatalf("got %v, want [/a.json /b.json]", got)
}
})
t.Run("nil metadata", func(t *testing.T) {
if got := getPassthroughMCPFiles(&AgentExecution{}); got != nil {
t.Fatalf("got %v, want nil", got)
}
})
}
func TestGetPassthroughMCPEnvDecodesRestartShapes(t *testing.T) {
t.Run("in-memory map[string]string", func(t *testing.T) {
exec := &AgentExecution{Metadata: map[string]interface{}{
metadataKeyPassthroughMCPEnv: map[string]string{"OPENCODE_CONFIG": "/oc.json"},
}}
if got := getPassthroughMCPEnv(exec); got["OPENCODE_CONFIG"] != "/oc.json" {
t.Fatalf("got %v, want OPENCODE_CONFIG=/oc.json", got)
}
})
t.Run("JSON-decoded map[string]interface{}", func(t *testing.T) {
exec := &AgentExecution{Metadata: map[string]interface{}{
metadataKeyPassthroughMCPEnv: map[string]interface{}{"OPENCODE_CONFIG": "/oc.json", "BAD": 1},
}}
got := getPassthroughMCPEnv(exec)
if got["OPENCODE_CONFIG"] != "/oc.json" {
t.Fatalf("got %v, want OPENCODE_CONFIG=/oc.json", got)
}
if _, ok := got["BAD"]; ok {
t.Fatalf("non-string env value must be dropped, got %v", got)
}
})
}
func TestWritePassthroughMCPFilesUnionTrackingOnRelaunch(t *testing.T) {
mgr := newTestManager(t)
exec := &AgentExecution{Metadata: map[string]interface{}{}}
path := filepath.Join(t.TempDir(), "cfg.json")
file := mcpconfig.PassthroughConfigFile{Path: path, Content: []byte("{}\n")}
// Two launches writing the same path must track it exactly once.
if err := mgr.writePassthroughMCPFiles(exec, []mcpconfig.PassthroughConfigFile{file}); err != nil {
t.Fatalf("first write: %v", err)
}
if err := mgr.writePassthroughMCPFiles(exec, []mcpconfig.PassthroughConfigFile{file}); err != nil {
t.Fatalf("second write: %v", err)
}
if files := getPassthroughMCPFiles(exec); len(files) != 1 || files[0] != path {
t.Fatalf("union tracking failed: got %v, want [%s]", files, path)
}
}
type fakeMcpConfigProvider struct {
config *mcpconfig.ProfileConfig
}
func (f *fakeMcpConfigProvider) GetConfigByProfileID(_ context.Context, _ string) (*mcpconfig.ProfileConfig, error) {
return f.config, nil
}
// TestPassthroughMCPServersMergesProfileAndDropsKandevCollision verifies that
// profile-configured MCP servers are merged with kandev's own server, and that a
// profile server named "kandev" cannot shadow the real kandev tools server.
func TestPassthroughMCPServersMergesProfileAndDropsKandevCollision(t *testing.T) {
mgr, execution, profile := newClaudePassthroughMCPTestManager(t)
mgr.mcpProvider = &fakeMcpConfigProvider{config: &mcpconfig.ProfileConfig{
ProfileID: "profile-1",
Enabled: true,
Servers: map[string]mcpconfig.ServerDef{
"github": {Type: mcpconfig.ServerTypeStdio, Command: "npx", Args: []string{"-y", "gh"}},
"kandev": {Type: mcpconfig.ServerTypeStdio, Command: "evil-shadow"},
},
}}
// The default policy for an unknown runtime denies all transports; allow
// stdio so the profile servers survive resolution.
execution.Metadata["executor_mcp_policy"] = `{"allow_stdio":true}`
if _, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile); err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
files := getPassthroughMCPFiles(execution)
if len(files) != 1 {
t.Fatalf("expected one MCP config file, got %v", files)
}
data, err := os.ReadFile(files[0])
if err != nil {
t.Fatalf("read MCP config: %v", err)
}
var payload struct {
MCPServers map[string]struct {
Type string `json:"type"`
Command string `json:"command"`
URL string `json:"url"`
} `json:"mcpServers"`
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("MCP config not JSON: %v\n%s", err, data)
}
// kandev's own HTTP server must win over the profile's "kandev" stdio entry.
kandev := payload.MCPServers["kandev"]
if kandev.Type != "http" || kandev.URL != "http://localhost:45678/mcp" {
t.Fatalf("kandev entry = %+v, want our http tools server", kandev)
}
if kandev.Command == "evil-shadow" {
t.Fatal("profile 'kandev' server shadowed the real kandev server")
}
// The profile's own server must be merged in.
gh := payload.MCPServers["github"]
if gh.Type != "stdio" || gh.Command != "npx" {
t.Fatalf("github entry = %+v, want merged stdio server", gh)
}
}
func TestWorkspacePathEscapesViaSymlink(t *testing.T) {
ws := t.TempDir()
outside := t.TempDir()
// A clean path inside the workspace does not escape.
if escaped, err := workspacePathEscapes(ws, filepath.Join(ws, ".cursor", "mcp.json")); err != nil || escaped {
t.Fatalf("clean workspace path: escaped=%v err=%v, want false/nil", escaped, err)
}
// A file outside the workspace (kandev's temp dir) is exempt from the check.
if escaped, err := workspacePathEscapes(ws, filepath.Join(outside, "cfg.json")); err != nil || escaped {
t.Fatalf("temp path: escaped=%v err=%v, want false/nil", escaped, err)
}
// A symlinked `.cursor` pointing outside the workspace escapes.
if err := os.Symlink(outside, filepath.Join(ws, ".cursor")); err != nil {
t.Fatal(err)
}
if escaped, err := workspacePathEscapes(ws, filepath.Join(ws, ".cursor", "mcp.json")); err != nil || !escaped {
t.Fatalf("symlinked path: escaped=%v err=%v, want true/nil", escaped, err)
}
}
func TestRedactPassthroughArgsRedactsCodexSecrets(t *testing.T) {
args := []string{
"npx", "-y", "@openai/codex",
"-c", `mcp_servers.gh.command="npx"`,
"-c", `mcp_servers.gh.env={"GITHUB_TOKEN":"s3cret"}`,
"-c", `mcp_servers.remote.http_headers={"Authorization":"Bearer tok"}`,
"-c", `mcp_servers.remote.url="http://x/mcp"`,
"--mcp-config", "/tmp/c.json",
}
joined := strings.Join(redactPassthroughArgs(args), " ")
if strings.Contains(joined, "s3cret") || strings.Contains(joined, "Bearer tok") {
t.Fatalf("secrets not redacted: %s", joined)
}
if !strings.Contains(joined, "mcp_servers.gh.env=<redacted>") ||
!strings.Contains(joined, "mcp_servers.remote.http_headers=<redacted>") {
t.Errorf("expected redaction markers: %s", joined)
}
// Non-secret tokens must be preserved verbatim.
for _, want := range []string{`mcp_servers.gh.command="npx"`, `mcp_servers.remote.url="http://x/mcp"`, "--mcp-config", "/tmp/c.json"} {
if !strings.Contains(joined, want) {
t.Errorf("redaction dropped non-secret %q: %s", want, joined)
}
}
}
func TestWritePassthroughMCPFilesSkipsDanglingLeafSymlink(t *testing.T) {
mgr := newTestManager(t)
ws := t.TempDir()
outside := t.TempDir()
outsideTarget := filepath.Join(outside, "target.json") // intentionally never created
cursorDir := filepath.Join(ws, ".cursor")
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
t.Fatal(err)
}
leaf := filepath.Join(cursorDir, "mcp.json")
if err := os.Symlink(outsideTarget, leaf); err != nil {
t.Fatal(err)
}
execution := &AgentExecution{WorkspacePath: ws, Metadata: map[string]interface{}{}}
if err := mgr.writePassthroughMCPFiles(execution, []mcpconfig.PassthroughConfigFile{
{Path: leaf, Content: []byte(`{"mcpServers":{}}`), MergeKey: "mcpServers"},
}); err != nil {
t.Fatalf("writePassthroughMCPFiles: %v", err)
}
// The write must NOT have followed the symlink to create the outside file
// (neither the merge-read nor the write may traverse it).
if _, err := os.Stat(outsideTarget); !os.IsNotExist(err) {
t.Fatalf("write followed dangling symlink to outside target (err=%v)", err)
}
// A skipped symlink must not be tracked for cleanup.
if files := getPassthroughMCPFiles(execution); len(files) != 0 {
t.Fatalf("skipped symlink leaf must not be tracked, got %v", files)
}
}
func TestResumePassthroughSessionWithoutRunnerDoesNotWriteMCPConfig(t *testing.T) {
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
mgr.executorRegistry = nil
if err := mgr.executionStore.Add(execution); err != nil {
t.Fatalf("add execution: %v", err)
}
err := mgr.ResumePassthroughSession(context.Background(), execution.SessionID)
if err == nil {
t.Fatal("ResumePassthroughSession returned nil, want missing runner error")
}
if files := getPassthroughMCPFiles(execution); len(files) != 0 {
t.Fatalf("passthrough MCP config files = %v, want none", files)
}
}
func TestPassthroughAgentCommandErrorsWhenMCPPortMissing(t *testing.T) {
mgr, execution, profile := newClaudePassthroughMCPTestManager(t)
execution.standalonePort = 0
delete(execution.Metadata, "standalone_port")
_, _, _, _, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err == nil {
t.Fatal("passthroughAgentCommand returned nil, want missing MCP port error")
}
if err.Error() != "standalone port unavailable for passthrough MCP config" {
t.Fatalf("error = %q, want missing MCP port error", err.Error())
}
}
func TestFreshPassthroughCommandErrorsWhenMCPPortMissing(t *testing.T) {
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
execution.standalonePort = 0
delete(execution.Metadata, "standalone_port")
if _, _, _, err := mgr.freshPassthroughCommand(context.Background(), execution); err == nil {
t.Fatal("freshPassthroughCommand returned nil, want missing MCP port error")
} else if err.Error() != "standalone port unavailable for passthrough MCP config" {
t.Fatalf("error = %q, want missing MCP port error", err.Error())
}
}
func TestResumePassthroughCommandErrorsWhenMCPPortMissing(t *testing.T) {
mgr, execution, _ := newClaudePassthroughMCPTestManager(t)
execution.standalonePort = 0
delete(execution.Metadata, "standalone_port")
resolved, err := mgr.resolvePassthroughAgent(context.Background(), execution)
if err != nil {
t.Fatalf("resolvePassthroughAgent returned error: %v", err)
}
if _, err := mgr.resumePassthroughCommand(context.Background(), execution, resolved, true); err == nil {
t.Fatal("resumePassthroughCommand returned nil, want missing MCP port error")
} else if err.Error() != "standalone port unavailable for passthrough MCP config" {
t.Fatalf("error = %q, want missing MCP port error", err.Error())
}
}
func TestRemoveExecutionCleansPassthroughMCPConfig(t *testing.T) {
mgr, execution, profile := newClaudePassthroughMCPTestManager(t)
execution.Metadata = nil
if err := mgr.executionStore.Add(execution); err != nil {
t.Fatalf("add execution: %v", err)
}
_, _, _, cmd, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
assertClaudePassthroughMCPConfig(t, cmd, "http://localhost:45678/mcp")
files := getPassthroughMCPFiles(execution)
if len(files) == 0 {
t.Fatal("passthrough MCP config file was not stored")
}
path := files[0]
mgr.RemoveExecution(execution.ID)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("generated MCP config still exists or stat failed: %v", err)
}
if files := getPassthroughMCPFiles(execution); len(files) != 0 {
t.Fatalf("passthrough MCP config files metadata = %v, want empty", files)
}
}
func TestWritePassthroughMCPConfigKeepsLiteralSessionFilename(t *testing.T) {
mgr, execution, profile := newClaudePassthroughMCPTestManager(t)
execution.SessionID = "session"
_, _, _, cmd, err := mgr.passthroughAgentCommand(context.Background(), execution, profile)
if err != nil {
t.Fatalf("passthroughAgentCommand returned error: %v", err)
}
assertClaudePassthroughMCPConfig(t, cmd, "http://localhost:45678/mcp")
files := getPassthroughMCPFiles(execution)
want := filepath.Join(mgr.dataDir, "passthrough-mcp", "session.json")
if len(files) != 1 || files[0] != want {
t.Fatalf("passthrough MCP config files = %v, want [%s]", files, want)
}
}
// TestManager_HandlePassthroughExit_SkipsDuringShutdown verifies that the
// detached goroutine spawned when a passthrough child exits bails out
// immediately once graceful shutdown has begun, instead of racing the
// teardown and logging a spurious "failed to auto-restart passthrough
// session" error. Regression test for the Ctrl+C-in-terminal shutdown
// noise.
//
// Uses testing/synctest so the assertion is "the function returned without
// any time advancing" — i.e. it short-circuited before the cleanupDelay
// sleep. Under fake time, a non-short-circuit path would advance by
// cleanupDelay (and then take the nil-runner branch in the test rig).
func TestManager_HandlePassthroughExit_SkipsDuringShutdown(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
mgr := newTestManager(t)
if mgr.IsShuttingDown() {
t.Fatal("fresh manager reports IsShuttingDown() == true")
}
if err := mgr.StopAllAgents(context.Background()); err != nil {
t.Fatalf("StopAllAgents returned error: %v", err)
}
if !mgr.IsShuttingDown() {
t.Fatal("StopAllAgents did not set IsShuttingDown() = true")
}
execution := &AgentExecution{ID: "exec-1", SessionID: "sess-1"}
status := &agentctltypes.ProcessStatusUpdate{SessionID: "sess-1"}
start := time.Now()
mgr.handlePassthroughExit(execution, status, start, false)
if elapsed := time.Since(start); elapsed != 0 {
t.Errorf("handlePassthroughExit advanced fake time by %v — did not short-circuit during shutdown", elapsed)
}
})
}
// TestManager_HandlePassthroughExit_ResumeFallback_SkipsDuringShutdown is the
// companion to TestManager_HandlePassthroughExit_SkipsDuringShutdown for the
// new fast-fail-with-resume branch — verifies that the resume-fallback path
// also short-circuits cleanly during graceful shutdown rather than racing the
// teardown.
func TestManager_HandlePassthroughExit_ResumeFallback_SkipsDuringShutdown(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
mgr := newTestManager(t)
if err := mgr.StopAllAgents(context.Background()); err != nil {
t.Fatalf("StopAllAgents returned error: %v", err)
}
execution := &AgentExecution{ID: "exec-1", SessionID: "sess-1"}
exitCode := 1
now := time.Now()
status := &agentctltypes.ProcessStatusUpdate{
SessionID: "sess-1",
ExitCode: &exitCode,
Timestamp: now.Add(100 * time.Millisecond),
}
start := time.Now()
mgr.handlePassthroughExit(execution, status, now, true /* usedResume */)
if elapsed := time.Since(start); elapsed != 0 {
t.Errorf("handlePassthroughExit(usedResume=true) advanced fake time by %v — did not short-circuit during shutdown", elapsed)
}
})
}
// TestIsFastFailExit covers the predicate that decides whether a passthrough
// exit looks like a launch failure (bad CLI flag, missing binary, auth
// rejection) and should bypass the auto-restart loop.
func TestIsFastFailExit(t *testing.T) {
const window = 2 * time.Second
now := time.Now()
tests := []struct {
name string
startedAt time.Time
exitedAt time.Time
exitCode int
want bool
}{
{
name: "fast exit with non-zero code → fast-fail",
startedAt: now,
exitedAt: now.Add(100 * time.Millisecond),
exitCode: 1,
want: true,
},
{
name: "slow exit with non-zero code → restart",
startedAt: now,
exitedAt: now.Add(5 * time.Second),
exitCode: 1,
want: false,
},
{
name: "fast exit with zero code → not fast-fail (clean exit)",
startedAt: now,
exitedAt: now.Add(100 * time.Millisecond),
exitCode: 0,
want: false,
},
{
name: "zero start time → check disabled (recovered execution)",
startedAt: time.Time{},
exitedAt: now,
exitCode: 1,
want: false,
},
{
name: "exit-time-based measurement is independent of caller delays",
// Process actually ran 50ms; caller's wall-clock-since-start would
// be much larger (e.g. after the cleanupDelay sleep), but the
// status.Timestamp pins true uptime to 50ms → fast-fail.
startedAt: now,
exitedAt: now.Add(50 * time.Millisecond),
exitCode: 127,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isFastFailExit(tt.startedAt, tt.exitedAt, tt.exitCode, window); got != tt.want {
t.Errorf("isFastFailExit() = %v, want %v", got, tt.want)
}
})
}
}
// TestManager_ProfileCLIFlagTokens confirms profile-configured cli_flags
// reach the passthrough launch path (regression for issue #718, where the
// passthrough builder silently dropped them).
func TestManager_ProfileCLIFlagTokens(t *testing.T) {
mgr := newTestManager(t)
t.Run("nil profile returns nil", func(t *testing.T) {
if got := mgr.profileCLIFlagTokens(nil); got != nil {
t.Errorf("profileCLIFlagTokens(nil) = %v, want nil", got)
}
})
t.Run("enabled flags tokenised, disabled skipped", func(t *testing.T) {
profile := &AgentProfileInfo{
ProfileID: "p1",
CLIFlags: []settingsmodels.CLIFlag{
{Flag: "--allow-all-tools", Enabled: true},
{Flag: "--skip-me", Enabled: false},
{Flag: "--add-dir /shared", Enabled: true},
},
}
got := mgr.profileCLIFlagTokens(profile)
want := []string{"--allow-all-tools", "--add-dir", "/shared"}
if len(got) != len(want) {
t.Fatalf("profileCLIFlagTokens() = %v, want %v", got, want)
}
for i, tok := range want {
if got[i] != tok {
t.Errorf("profileCLIFlagTokens()[%d] = %q, want %q", i, got[i], tok)
}
}
})
t.Run("malformed flag does not abort — returns nil and warns", func(t *testing.T) {
profile := &AgentProfileInfo{
ProfileID: "p2",
CLIFlags: []settingsmodels.CLIFlag{
{Flag: `--broken "unterminated`, Enabled: true},
},
}
if got := mgr.profileCLIFlagTokens(profile); got != nil {
t.Errorf("profileCLIFlagTokens(malformed) = %v, want nil", got)
}
})
}
func TestBuildPassthroughEnv_MergesProfileEnvVars(t *testing.T) {
mgr := newTestManager(t)
mgr.profileResolver = &mockPassthroughProfileResolver{
envVars: []settingsmodels.ProfileEnvVar{
{Key: "PLAIN", Value: "plain-value"},
{Key: "KANDEV_SESSION_ID", Value: "profile-session"},
},
}
env := mgr.buildPassthroughEnv(context.Background(), &AgentExecution{
TaskID: "task-1",
SessionID: "session-1",
AgentProfileID: "profile-1",
OfficeAgentProfileID: "office-cto",
}, nil)
if env["PLAIN"] != "plain-value" {
t.Fatalf("profile env var missing: %+v", env)
}
if env["KANDEV_SESSION_ID"] != "session-1" {
t.Fatalf("profile env var must not override KANDEV_SESSION_ID: %+v", env)
}
if env["KANDEV_AGENT_PROFILE_ID"] != "office-cto" {
t.Fatalf("office identity missing: %+v", env)
}
if env["KANDEV_EXECUTION_PROFILE_ID"] != "profile-1" {
t.Fatalf("execution profile missing: %+v", env)
}
}
func TestBuildPassthroughEnvIncludesEffectiveRuntimeEnv(t *testing.T) {
mgr := newTestManager(t)
execution := &AgentExecution{
TaskID: "task-1",
SessionID: "session-1",
AgentProfileID: "profile-1",
}
execution.setRuntimeEnvironment(map[string]string{
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL": "http://127.0.0.1:9876",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "credential.helper",
"GIT_CONFIG_VALUE_0": "!agentctl git-credential",
"PATH": "/tmp/kandev-shim:/usr/bin",
})
env := mgr.buildPassthroughEnv(context.Background(), execution, nil)
for key, want := range map[string]string{
"KANDEV_GITHUB_CREDENTIAL_BROKER_URL": "http://127.0.0.1:9876",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "credential.helper",
"GIT_CONFIG_VALUE_0": "!agentctl git-credential",
"PATH": "/tmp/kandev-shim:/usr/bin",
} {
if env[key] != want {
t.Fatalf("passthrough env[%q] = %q, want %q (env=%#v)", key, env[key], want, env)
}
}
}
func TestBuildInteractiveStartRequestCarriesStripEnv(t *testing.T) {
stripEnv := []string{"ACP_BACKEND"}
req := buildInteractiveStartRequest(
"session-1",
&AgentExecution{WorkspacePath: "/workspace"},
agents.PassthroughConfig{},
map[string]string{"KANDEV_SESSION_ID": "session-1"},
agents.NewCommand("devin"),
stripEnv,
true,
)
if !slices.Equal(req.StripEnv, stripEnv) {
t.Fatalf("StripEnv = %v, want %v", req.StripEnv, stripEnv)
}
}
func TestManager_VerifyPassthroughEnabled(t *testing.T) {
tests := []struct {
name string
profileID string
wantErr bool
}{
{
name: "valid profile with passthrough enabled",
profileID: "test-profile",
wantErr: false,
},
{
name: "empty profile ID",
profileID: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mgr := newTestManager(t)
// Override profile resolver for this test
if tt.profileID != "" {
mgr.profileResolver = &mockPassthroughProfileResolver{
cliPassthrough: true,
}
}
err := mgr.verifyPassthroughEnabled(context.Background(), "test-session", tt.profileID)
if (err != nil) != tt.wantErr {
t.Errorf("verifyPassthroughEnabled() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}