feat: add Devin ACP agent integration (#1497)

This commit is contained in:
jijiamoer
2026-07-03 04:57:25 +08:00
committed by GitHub
parent 8e9a41efab
commit 5c0d4f5e9b
37 changed files with 826 additions and 12 deletions
+3 -2
View File
@@ -29,7 +29,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
## Features
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae, Oh My Pi
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae, Oh My Pi
- **Parallel task execution** – start and manage multiple tasks from different sources simultaneously, boosting productivity with AI agents
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
@@ -78,6 +78,7 @@ Connect Kandev to the tools your team already uses — pull issues into the kanb
| **Auggie** | `npx -y @augmentcode/auggie --acp` |
| **OpenCode** | `opencode acp` |
| **Cursor** | `cursor-agent acp` *(requires Cursor Pro)* |
| **Devin** | `devin acp` *(install Devin CLI from Devin Desktop or standalone installer)* |
| **Qwen** | `npx -y @qwen-code/qwen-code --acp` |
| **Factory Droid** | `npx -y droid exec --output-format acp` |
| **iFlow (beta)** | `npx -y @iflow-ai/iflow-cli --experimental-acp` |
@@ -248,7 +249,7 @@ There are a few similar tools in this space, and new ones appearing everyday. He
- **Server-first architecture** - The core app runs as a server you can access from any device, including your phone.
- **Remote runtimes** - Run agents on remote servers via SSH, Docker hosts, and cloud environments, not just your local machine.
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, and Oh My Pi side by side. Not locked to one vendor.
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, and Oh My Pi side by side. Not locked to one vendor.
- **CLI passthrough and chat** - Interact with agents through structured chat messages or drop into raw CLI mode for full agent TUI capabilities.
- **Open source and self-hostable** - No vendor lock-in, no telemetry, runs on your infrastructure.
@@ -214,6 +214,13 @@ type RuntimeConfig struct {
// alive). Agentctl still reaps the process group for every agent after
// graceful shutdown or timeout; this flag only makes that reap immediate.
RequiresProcessKill bool
// StripEnv lists environment variables to remove from the agent's child
// process environment entirely (not just set to empty). Some programs
// distinguish unset from empty string and change behavior based on
// presence rather than value. The process manager strips these from the
// final child env after adapter merge; the inference executor strips
// them from the one-shot probe/inference subprocess env.
StripEnv []string
}
// MountTemplate defines a mount with template variables.
@@ -0,0 +1,185 @@
//nolint:dupl,goconst // Native-binary ACP agents (Cursor, Kimi, Kiro, Qoder, Trae, Omp, Devin) follow the same minimal scaffold; shared literals (CLI Passthrough, etc.) live in every peer file by convention.
package agents
import (
"context"
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
//go:embed logos/devin_acp_light.svg
var devinACPLogoLight []byte
//go:embed logos/devin_acp_dark.svg
var devinACPLogoDark []byte
const devinACPBin = "devin"
const (
devinCredentialsDir = ".local/share/devin"
devinCredentialsRelPath = devinCredentialsDir + "/credentials.toml"
devinDefaultAPIServer = "https://server.self-serve.windsurf.com"
)
var (
_ Agent = (*DevinACP)(nil)
_ PassthroughAgent = (*DevinACP)(nil)
_ InferenceAgent = (*DevinACP)(nil)
)
// DevinACP implements Agent for Cognition's Devin CLI using ACP.
// The CLI binary (devin) is installed via the Devin Desktop app or
// standalone installer. It speaks ACP natively via the `devin acp` subcommand.
//
// Credential handling: `devin acp` checks the ACP_BACKEND environment variable.
// When set (e.g. by Windsurf Next), it requires the ACP host to call
// `authenticate` and refuses local credentials. When unset, it falls back to
// reading ~/.local/share/devin/credentials.toml directly. The process manager
// strips ACP_BACKEND from the child environment so Devin uses the fall-back
// path — no protocol-level authenticate needed.
type DevinACP struct {
StandardPassthrough
}
func NewDevinACP() *DevinACP {
return &DevinACP{
StandardPassthrough: StandardPassthrough{
PermSettings: emptyPermSettings,
Cfg: PassthroughConfig{
Supported: true,
Label: "CLI Passthrough",
Description: "Show terminal directly instead of chat interface",
PassthroughCmd: NewCommand(devinACPBin),
IdleTimeout: 3 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
},
}
}
func (a *DevinACP) ID() string { return "devin-acp" }
func (a *DevinACP) Name() string { return "Devin ACP Agent" }
func (a *DevinACP) DisplayName() string { return "Devin" }
func (a *DevinACP) Description() string {
return "Cognition Devin coding agent using the ACP protocol via `devin acp`."
}
func (a *DevinACP) Enabled() bool { return true }
func (a *DevinACP) DisplayOrder() int { return 19 }
func (a *DevinACP) Logo(v LogoVariant) []byte {
if v == LogoDark {
return devinACPLogoDark
}
return devinACPLogoLight
}
func (a *DevinACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, WithCommand(devinACPBin))
if err != nil {
return result, err
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
}
return result, nil
}
func (a *DevinACP) BuildCommand(opts CommandOptions) Command {
return Cmd(devinACPBin, "acp").Build()
}
func (a *DevinACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd(devinACPBin, "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
// Devin advertises mcpCapabilities {http: false, sse: false} but
// actually supports streamable HTTP MCP (used by Windsurf Next).
// Without these overrides, filterMcpServersByCapabilities drops the
// Kandev MCP server (exposed via HTTP /mcp and SSE /sse), and the
// agent loses access to task tools (get_task_plan_kandev, etc.).
AssumeMcpSse: true,
AssumeMcpHttp: true,
// See DevinACP doc comment for the ACP_BACKEND rationale.
StripEnv: []string{"ACP_BACKEND"},
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.local/share/devin",
SessionDirTarget: "/root/.local/share/devin",
},
}
}
func (a *DevinACP) RemoteAuth() *RemoteAuth {
return &RemoteAuth{
Methods: []RemoteAuthMethod{
{
Type: "files",
Label: "Copy Devin CLI credentials",
SourceFiles: map[string][]string{
"darwin": {devinCredentialsRelPath},
"linux": {devinCredentialsRelPath},
},
TargetRelDir: devinCredentialsDir,
},
{
Type: "env",
EnvVar: "WINDSURF_API_KEY",
SetupHint: "Set WINDSURF_API_KEY to authenticate Devin CLI in remote or headless environments.",
SetupScript: `mkdir -p "${HOME}/.local/share/devin"
escape_toml() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
api_key="$(escape_toml "${WINDSURF_API_KEY}")"
api_server="$(escape_toml "${WINDSURF_API_SERVER_URL:-` + devinDefaultAPIServer + `}")"
umask 077
cat > "${HOME}/.local/share/devin/credentials.toml" <<CREDS
windsurf_api_key = "${api_key}"
api_server_url = "${api_server}"
CREDS
chmod 600 "${HOME}/.local/share/devin/credentials.toml"`,
},
},
}
}
func (a *DevinACP) InstallScript() string {
return `set -e
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL https://cli.devin.ai/install.sh -o "$tmp"
if ! bash "$tmp" && [ ! -x "$HOME/.local/bin/devin" ]; then
exit 1
fi
export PATH="$HOME/.local/bin:$PATH"
persist_devin_path() {
grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$1" 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$1"
}
persist_devin_path "$HOME/.profile"
persist_devin_path "$HOME/.bash_profile"
persist_devin_path "$HOME/.bashrc"
persist_devin_path "$HOME/.zprofile"
persist_devin_path "$HOME/.zshrc"
devin --version >/dev/null`
}
func (a *DevinACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
func (a *DevinACP) InferenceConfig() *InferenceConfig {
return &InferenceConfig{
Supported: true,
Command: NewCommand(devinACPBin, "acp"),
}
}
func (a *DevinACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -0,0 +1,157 @@
package agents
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func TestDevinACPRemoteAuth(t *testing.T) {
auth := NewDevinACP().RemoteAuth()
if auth == nil {
t.Fatal("RemoteAuth() returned nil; expected files and env auth methods")
}
if len(auth.Methods) != 2 {
t.Fatalf("Methods len = %d, want 2", len(auth.Methods))
}
files := auth.Methods[0]
if files.Type != "files" {
t.Errorf("first Type = %q, want files", files.Type)
}
if files.Label == "" {
t.Error("files auth method should have a UI label")
}
if files.TargetRelDir != devinCredentialsDir {
t.Errorf("TargetRelDir = %q, want %q", files.TargetRelDir, devinCredentialsDir)
}
for _, goos := range []string{"darwin", "linux"} {
got := files.SourceFiles[goos]
want := []string{devinCredentialsRelPath}
if !reflect.DeepEqual(got, want) {
t.Errorf("SourceFiles[%q] = %v, want %v", goos, got, want)
}
}
env := auth.Methods[1]
if env.Type != "env" {
t.Errorf("second Type = %q, want env", env.Type)
}
if env.EnvVar != "WINDSURF_API_KEY" {
t.Errorf("EnvVar = %q, want WINDSURF_API_KEY", env.EnvVar)
}
for _, needle := range []string{
"credentials.toml",
"windsurf_api_key",
"api_server_url",
devinDefaultAPIServer,
"umask 077",
"chmod 600",
} {
if !strings.Contains(env.SetupScript, needle) {
t.Errorf("SetupScript missing %q: %q", needle, env.SetupScript)
}
}
}
func TestDevinACPSessionDirTargetMountsCredentialsInDocker(t *testing.T) {
cfg := NewDevinACP().Runtime().SessionConfig
if cfg.SessionDirTemplate != "{home}/.local/share/devin" {
t.Fatalf("SessionDirTemplate = %q, want {home}/.local/share/devin", cfg.SessionDirTemplate)
}
if cfg.SessionDirTarget != "/root/.local/share/devin" {
t.Fatalf("SessionDirTarget = %q, want /root/.local/share/devin", cfg.SessionDirTarget)
}
}
func TestDevinACPInstallScriptUsesOfficialInstaller(t *testing.T) {
script := NewDevinACP().InstallScript()
for _, needle := range []string{
"curl -fsSL https://cli.devin.ai/install.sh",
`trap 'rm -f "$tmp"' EXIT`,
"$HOME/.local/bin/devin",
`export PATH="$HOME/.local/bin:$PATH"`,
"persist_devin_path",
"$HOME/.profile",
"$HOME/.zprofile",
"devin --version",
} {
if !strings.Contains(script, needle) {
t.Errorf("InstallScript missing %q: %q", needle, script)
}
}
if strings.Contains(script, "Install Devin CLI from") {
t.Errorf("InstallScript must be executable shell, got prose: %q", script)
}
if strings.HasPrefix(script, "npm install -g ") {
t.Errorf("InstallScript should use the native Devin installer, got npm script: %q", script)
}
}
func TestDevinACPInstallScriptToleratesPostInstallSetupFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("install script is POSIX shell")
}
home := t.TempDir()
binDir := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir fake bin: %v", err)
}
fakeCurl := `#!/bin/sh
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-o" ]; then
shift
out="$1"
fi
shift
done
cat > "$out" <<'INSTALL'
#!/bin/sh
mkdir -p "$HOME/.local/bin"
cat > "$HOME/.local/bin/devin" <<'DEVIN'
#!/bin/sh
if [ "$1" = "--version" ]; then
echo "devin test"
exit 0
fi
exit 0
DEVIN
chmod +x "$HOME/.local/bin/devin"
exit 1
INSTALL
`
fakeCurlPath := filepath.Join(binDir, "curl")
if err := os.WriteFile(fakeCurlPath, []byte(fakeCurl), 0o755); err != nil {
t.Fatalf("write fake curl: %v", err)
}
cmd := exec.Command("sh", "-c", NewDevinACP().InstallScript())
cmd.Env = []string{
"HOME=" + home,
"PATH=" + binDir + ":/usr/bin:/bin",
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("InstallScript failed: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(home, ".local/bin/devin")); err != nil {
t.Fatalf("expected devin binary to be installed: %v", err)
}
for _, rel := range []string{".profile", ".bash_profile", ".bashrc", ".zprofile", ".zshrc"} {
data, err := os.ReadFile(filepath.Join(home, rel))
if err != nil {
t.Fatalf("read %s: %v", rel, err)
}
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
t.Fatalf("%s missing PATH export: %q", rel, string(data))
}
}
}
@@ -25,6 +25,18 @@ func CatalogPermissionSettings(ag Agent) map[string]PermissionSetting {
return mergeAgentctlAutoApprove(ag.PermissionSettings())
}
// StripEnvFor returns the runtime-declared environment keys that should be
// removed before spawning an agent subprocess. Inference-only paths derive
// this from RuntimeConfig instead of carrying a second declaration.
func StripEnvFor(ia InferenceAgent) []string {
if a, ok := ia.(Agent); ok {
if rt := a.Runtime(); rt != nil {
return rt.StripEnv
}
}
return nil
}
func mergeAgentctlAutoApprove(settings map[string]PermissionSetting) map[string]PermissionSetting {
merged := make(map[string]PermissionSetting, len(settings)+1)
for k, v := range settings {
@@ -1,6 +1,7 @@
package agents
import (
"slices"
"testing"
)
@@ -43,3 +44,15 @@ func TestResumeAt_EmptyFlag(t *testing.T) {
t.Errorf("ResumeAt with empty flag should not modify args, got %v", args)
}
}
func TestStripEnvFor(t *testing.T) {
got := StripEnvFor(NewDevinACP())
if want := []string{"ACP_BACKEND"}; !slices.Equal(got, want) {
t.Fatalf("StripEnvFor(NewDevinACP()) = %v, want %v", got, want)
}
var ia InferenceAgent
if got := StripEnvFor(ia); got != nil {
t.Fatalf("StripEnvFor(nil) = %v, want nil", got)
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 425 425" fill="none"><path d="M70 159.333V91.3471C70 88.3592 71.594 85.5983 74.1816 84.1044L133.043 50.1205C135.631 48.6265 138.819 48.6265 141.407 50.1205L200.269 84.1044C202.856 85.5983 204.45 88.3592 204.45 91.3471V126.068C204.708 137.606 210.806 148.734 221.531 154.926C232.256 161.117 244.942 160.834 255.063 155.289L285.132 137.929C287.719 136.435 290.907 136.435 293.495 137.929L352.357 171.913C354.944 173.406 356.538 176.167 356.538 179.155V247.123C356.538 250.111 354.944 252.872 352.357 254.366L293.495 288.35C290.907 289.844 287.719 289.844 285.132 288.35L255.306 271.13C245.146 265.456 232.344 265.117 221.534 271.358C210.809 277.55 204.711 288.678 204.453 300.215V334.926C204.453 337.914 202.859 340.675 200.271 342.169L141.41 376.153C138.822 377.647 135.634 377.647 133.046 376.153L74.1845 342.169C71.5969 340.675 70.0028 337.914 70.0028 334.926V266.959C70.0029 263.971 71.5969 261.21 74.1845 259.716L133.046 225.732C135.634 224.238 138.822 224.238 141.41 225.732L171.547 243.132C181.656 248.638 194.306 248.906 205.005 242.729C215.815 236.488 221.922 225.231 222.088 213.595C221.83 202.057 215.732 189.737 205.008 183.545C194.283 177.353 181.597 177.636 171.476 183.181L141.269 200.72C138.67 202.229 135.461 202.228 132.864 200.716L74.1576 166.562C71.5835 165.065 70 162.311 70 159.333Z" fill="#FFFFFF"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 425 425" fill="none"><path d="M70 159.333V91.3471C70 88.3592 71.594 85.5983 74.1816 84.1044L133.043 50.1205C135.631 48.6265 138.819 48.6265 141.407 50.1205L200.269 84.1044C202.856 85.5983 204.45 88.3592 204.45 91.3471V126.068C204.708 137.606 210.806 148.734 221.531 154.926C232.256 161.117 244.942 160.834 255.063 155.289L285.132 137.929C287.719 136.435 290.907 136.435 293.495 137.929L352.357 171.913C354.944 173.406 356.538 176.167 356.538 179.155V247.123C356.538 250.111 354.944 252.872 352.357 254.366L293.495 288.35C290.907 289.844 287.719 289.844 285.132 288.35L255.306 271.13C245.146 265.456 232.344 265.117 221.534 271.358C210.809 277.55 204.711 288.678 204.453 300.215V334.926C204.453 337.914 202.859 340.675 200.271 342.169L141.41 376.153C138.822 377.647 135.634 377.647 133.046 376.153L74.1845 342.169C71.5969 340.675 70.0028 337.914 70.0028 334.926V266.959C70.0029 263.971 71.5969 261.21 74.1845 259.716L133.046 225.732C135.634 224.238 138.822 224.238 141.41 225.732L171.547 243.132C181.656 248.638 194.306 248.906 205.005 242.729C215.815 236.488 221.922 225.231 222.088 213.595C221.83 202.057 215.732 189.737 205.008 183.545C194.283 177.353 181.597 177.636 171.476 183.181L141.269 200.72C138.67 202.229 135.461 202.228 132.864 200.716L74.1576 166.562C71.5835 165.065 70 162.311 70 159.333Z" fill="#000000"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -29,6 +29,7 @@ type acpAgentSpec struct {
passthroughArgv []string // PassthroughCmd (zero-args allowed)
installViaNpm bool // InstallScript starts with "npm install -g"
installScript string // expected InstallScript() value (empty = unchecked)
stripEnv []string // expected Runtime().StripEnv (nil = unchecked)
}
var newACPAgentSpecs = []struct {
@@ -122,6 +123,14 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
passthroughArgv: []string{"omp"},
installViaNpm: false,
}},
{func() Agent { return NewDevinACP() }, acpAgentSpec{
id: "devin-acp", displayName: "Devin", detectBinaries: []string{"devin"},
expectedArgv: []string{"devin", "acp"},
inferenceArgv: []string{"devin", "acp"},
passthroughArgv: []string{"devin"},
installViaNpm: false,
stripEnv: []string{"ACP_BACKEND"},
}},
}
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
@@ -160,6 +169,14 @@ func TestNewACPAgents_AllCommandSurfaces(t *testing.T) {
}
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), tc.spec.expectedArgv)
// RuntimeConfig.StripEnv is the single source of truth for the
// persistent session path; inference derives from it separately.
if tc.spec.stripEnv != nil {
if !slices.Equal(rt.StripEnv, tc.spec.stripEnv) {
t.Errorf("Runtime.StripEnv = %v, want %v", rt.StripEnv, tc.spec.stripEnv)
}
}
ia, ok := ag.(InferenceAgent)
if !ok {
t.Fatalf("%s does not implement InferenceAgent", tc.spec.id)
@@ -533,6 +533,7 @@ func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.Inference
Command: cfg.Command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: inst.workDir,
StripEnv: agents.StripEnvFor(ia),
},
}
resp, err := inst.client.Probe(probeCtx, req)
@@ -95,6 +95,7 @@ func (m *Manager) ExecutePromptWithMCP(
Command: cfg.Command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: inst.workDir,
StripEnv: agents.StripEnvFor(ia),
},
MCPServers: mcpServers,
}
@@ -62,6 +62,7 @@ func (r *Registry) LoadDefaults() {
agents.NewQoderACP(),
agents.NewTraeACP(),
agents.NewOmpACP(),
agents.NewDevinACP(),
agents.NewMockAgent(),
}
@@ -61,6 +61,11 @@ type CreateInstanceRequest struct {
// — notably opencode acp.
RequiresProcessKill bool `json:"requires_process_kill,omitempty"`
// StripEnv lists environment variables to strip from the agent's child
// process environment entirely (not just set to empty). Propagated from
// RuntimeConfig.StripEnv by the lifecycle executors.
StripEnv []string `json:"strip_env,omitempty"`
// BaseBranches maps RepositoryName → base branch ref for the task's
// per-repo diff stats. The empty key "" applies to the root /
// single-repo tracker. Empty map disables the override and falls back
@@ -225,11 +225,13 @@ func (cm *ContainerManager) createInstanceAndClient(
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
var stripEnv []string
if config.AgentConfig != nil {
if rt := config.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
requiresProcessKill = rt.RequiresProcessKill
stripEnv = rt.StripEnv
}
}
@@ -251,6 +253,7 @@ func (cm *ContainerManager) createInstanceAndClient(
AssumeMcpHttp: assumeMcpHttp,
McpMode: config.McpMode,
RequiresProcessKill: requiresProcessKill,
StripEnv: stripEnv,
BaseBranches: config.BaseBranches,
}
@@ -230,6 +230,7 @@ func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing.
{"codex-acp", agents.NewCodexACP()},
{"claude-acp", agents.NewClaudeACP()},
{"opencode-acp", agents.NewOpenCodeACP()},
{"devin-acp", agents.NewDevinACP()},
{"copilot-acp", agents.NewCopilotACP()},
{"amp-acp", agents.NewAmpACP()},
{"gemini", agents.NewGemini()},
@@ -289,6 +290,30 @@ func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing.
}
}
func TestBuildContainerConfig_MountsDevinCredentialSessionDir(t *testing.T) {
cm := newCMTest(t)
cm.kandevHomeDir = "/tmp/kandev-test-home"
instanceID := "devin-instance"
got, err := cm.buildContainerConfig(ContainerConfig{
AgentConfig: agents.NewDevinACP(),
InstanceID: instanceID,
TaskID: "task-1",
})
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
wantSource := filepath.Join(cm.kandevHomeDir, "agent-sessions", instanceID, ".local/share/devin")
wantTarget := "/root/.local/share/devin"
for _, mount := range got.Mounts {
if mount.Source == wantSource && mount.Target == wantTarget {
return
}
}
t.Fatalf("expected Devin credential mount %s -> %s, got %+v", wantSource, wantTarget, got.Mounts)
}
func TestBuildContainerConfig_MountsLocalClonePath(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
@@ -23,6 +23,8 @@ type FileUploader interface {
WriteFile(ctx context.Context, path string, data []byte, mode os.FileMode) error
}
const credentialFileMode os.FileMode = 0o600
// UploadCredentialFiles reads local credential files and uploads them to the remote environment.
func UploadCredentialFiles(
ctx context.Context,
@@ -56,7 +58,7 @@ func UploadCredentialFiles(
}
targetPath := filepath.Join(targetHomeDir, method.TargetRelDir, filepath.Base(relPath))
if err := uploader.WriteFile(ctx, targetPath, data, 0o644); err != nil {
if err := uploader.WriteFile(ctx, targetPath, data, credentialFileMode); err != nil {
return fmt.Errorf("failed to upload %s: %w", targetPath, err)
}
log.Debug("uploaded credential file",
@@ -0,0 +1,52 @@
package lifecycle
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/agent/remoteauth"
)
type recordingCredentialUploader struct {
path string
data []byte
mode os.FileMode
}
func (u *recordingCredentialUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error {
u.path = path
u.data = append([]byte(nil), data...)
u.mode = mode
return nil
}
func TestUploadCredentialFilesWritesSecretFilesPrivate(t *testing.T) {
hostHome := seedTestHostHome(t)
writeFile(t, hostHome, ".local/share/devin/credentials.toml", []byte("windsurf_api_key = \"secret\"\n"))
uploader := &recordingCredentialUploader{}
methods := []remoteauth.Method{{
MethodID: "agent:devin-acp:files:0",
Type: "files",
SourceFiles: []string{".local/share/devin/credentials.toml"},
TargetRelDir: ".local/share/devin",
}}
targetHome := filepath.Join(t.TempDir(), "remote-home")
if err := UploadCredentialFiles(context.Background(), uploader, methods, targetHome, newSeederTestLogger(t)); err != nil {
t.Fatalf("UploadCredentialFiles: %v", err)
}
wantPath := filepath.Join(targetHome, ".local/share/devin", "credentials.toml")
if uploader.path != wantPath {
t.Fatalf("uploaded path = %q, want %q", uploader.path, wantPath)
}
if string(uploader.data) != "windsurf_api_key = \"secret\"\n" {
t.Fatalf("uploaded data = %q", string(uploader.data))
}
if uploader.mode != credentialFileMode {
t.Fatalf("uploaded mode = %o, want %o", uploader.mode, credentialFileMode)
}
}
@@ -483,6 +483,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
var stripEnv []string
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
disableAskQuestion = agents.IsPassthroughOnly(req.AgentConfig)
@@ -490,6 +491,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
requiresProcessKill = rt.RequiresProcessKill
stripEnv = rt.StripEnv
}
}
return &agentctl.CreateInstanceRequest{
@@ -510,6 +512,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID
AssumeMcpHttp: assumeMcpHttp,
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKill,
StripEnv: stripEnv,
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
}
}
@@ -370,6 +370,7 @@ func (r *SpritesExecutor) createAgentInstance(
McpServers: req.McpServers,
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKillFromReq(req),
StripEnv: stripEnvFromReq(req),
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
}
reqJSON, err := json.Marshal(instanceReq)
@@ -488,6 +489,20 @@ func requiresProcessKillFromReq(req *ExecutorCreateRequest) bool {
return rt.RequiresProcessKill
}
// stripEnvFromReq returns the agent's StripEnv list from its RuntimeConfig
// (nil when unset). Used by remote executors (SSH, Sprites) that serialize
// the CreateInstanceRequest over the wire.
func stripEnvFromReq(req *ExecutorCreateRequest) []string {
if req == nil || req.AgentConfig == nil {
return nil
}
rt := req.AgentConfig.Runtime()
if rt == nil {
return nil
}
return rt.StripEnv
}
func (r *SpritesExecutor) waitForHealth(ctx context.Context, sprite *sprites.Sprite) error {
deadline := time.Now().Add(spriteHealthTimeout)
healthURL := fmt.Sprintf("http://localhost:%d/health", r.agentctlPort)
@@ -559,6 +559,7 @@ func createRemoteAgentInstance(
McpServers: req.McpServers,
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKillFromReq(req),
StripEnv: stripEnvFromReq(req),
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
})
if err != nil {
@@ -98,11 +98,13 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
var stripEnv []string
if req.AgentConfig != nil {
if rt := req.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
requiresProcessKill = rt.RequiresProcessKill
stripEnv = rt.StripEnv
}
}
@@ -126,6 +128,7 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr
AssumeMcpHttp: assumeMcpHttp,
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKill,
StripEnv: stripEnv,
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
}
@@ -676,7 +676,7 @@ func (m *Manager) profileCLIFlagTokens(p *AgentProfileInfo) []string {
// buildInteractiveStartRequest builds the InteractiveStartRequest for a passthrough session.
// immediateStart overrides pt.WaitForTerminal when true (used for restart/resume where the
// terminal WebSocket is already connected).
func buildInteractiveStartRequest(sessionID string, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command, immediateStart bool) process.InteractiveStartRequest {
func buildInteractiveStartRequest(sessionID string, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command, stripEnv []string, immediateStart bool) process.InteractiveStartRequest {
return process.InteractiveStartRequest{
SessionID: sessionID,
Command: cmd.Args(),
@@ -685,6 +685,7 @@ func buildInteractiveStartRequest(sessionID string, execution *AgentExecution, p
LogCommand: redactPassthroughArgs(cmd.Args()),
WorkingDir: execution.WorkspacePath,
Env: env,
StripEnv: stripEnv,
PromptPattern: pt.PromptPattern,
IdleTimeout: pt.IdleTimeout,
BufferMaxBytes: pt.BufferMaxBytes,
@@ -699,7 +700,7 @@ func buildInteractiveStartRequest(sessionID string, execution *AgentExecution, p
// startInteractiveProcess launches the interactive PTY process for a passthrough session.
// Returns the process info on success.
func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command) (*process.InteractiveProcessInfo, error) {
func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command, stripEnv []string) (*process.InteractiveProcessInfo, error) {
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return nil, fmt.Errorf("interactive runner not available for passthrough mode")
@@ -710,7 +711,7 @@ func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentE
// WaitForTerminal agents deadlock: the frontend won't connect the terminal
// until the session leaves STARTING, but the process never starts without a resize.
// This matches ResumePassthroughSession and restartPassthroughProcess.
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, true)
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, stripEnv, true)
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
@@ -734,7 +735,7 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
processInfo, err := m.startInteractiveProcess(ctx, execution, pt, env, cmd)
processInfo, err := m.startInteractiveProcess(ctx, execution, pt, env, cmd, rt.StripEnv)
if err != nil {
return err
}
@@ -882,7 +883,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
// 3. Start new PTY process with ImmediateStart (terminal is already connected)
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, true)
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, rt.StripEnv, true)
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
@@ -957,7 +958,7 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string
// from the terminal will correct the dimensions. Without this, TUI apps that use
// WaitForTerminal would never start because the frontend may not send resizes
// to a process it doesn't know about yet.
startReq := buildInteractiveStartRequest(sessionID, execution, resolved.pt, env, cmd, true)
startReq := buildInteractiveStartRequest(sessionID, execution, resolved.pt, env, cmd, resolved.rt.StripEnv, true)
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
@@ -1228,7 +1229,7 @@ func (m *Manager) attemptResumeFallback(execution *AgentExecution, runner *proce
}
env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
startReq := buildInteractiveStartRequest(sessionID, execution, pt, env, cmd, true)
startReq := buildInteractiveStartRequest(sessionID, execution, pt, env, cmd, rt.StripEnv, true)
processInfo, err := runner.Start(ctx, startReq)
if err != nil {
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"testing/synctest"
@@ -1086,6 +1087,23 @@ func TestBuildPassthroughEnv_MergesProfileEnvVars(t *testing.T) {
}
}
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
@@ -46,7 +46,8 @@ func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID
return nil, fmt.Errorf("agentctl client not available for session %s", sessionID)
}
// Build request with inference config
// Build request with inference config. StripEnv is derived from
// Runtime().StripEnv via agents.StripEnvFor, not declared separately.
req := &utility.PromptRequest{
Prompt: prompt,
AgentID: agentID,
@@ -55,6 +56,7 @@ func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID
Command: cfg.Command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: execution.WorkspacePath,
StripEnv: agents.StripEnvFor(ia),
},
}
+15
View File
@@ -99,6 +99,21 @@ times out, it also re-runs the pgid SIGKILL fallback.
To add another agent that needs immediate kill instead of graceful stdin close:
set `RequiresProcessKill: true` in its `Runtime()` config.
## Env stripping for credential-mode agents (`StripEnv`)
Some agents check environment variables to decide their credential mode. For example, `devin acp` checks `ACP_BACKEND`: when set (any value, including empty), it requires protocol-level `authenticate` and refuses local credentials; when unset, it falls back to reading `~/.local/share/devin/credentials.toml` directly. Kandev (which may inherit `ACP_BACKEND` from Windsurf Next) needs the fall-back path, so the variable must be **absent** from the child environment — not just empty.
The strip list flows agent → instance config → process manager via a single slice, mirroring `RequiresProcessKill`:
1. `agents.RuntimeConfig.StripEnv` (set `[]string{"ACP_BACKEND"}` on `DevinACP`).
2. The lifecycle executors copy it into `agentctl.CreateInstanceRequest.StripEnv`.
3. `instance.Manager` stores it on `config.InstanceConfig.StripEnv`.
4. `process.Manager.buildAdapterConfig` iterates the list and calls `utility.RemoveEnvEntry` on `m.cfg.AgentEnv` before that env is used to spawn child processes.
For the one-shot probe/inference path, the strip list is derived from `Runtime().StripEnv` via the shared `agents.StripEnvFor` helper — it is not an independent field on `InferenceConfig`. The derived value is propagated through `InferenceConfigDTO.StripEnv` and applied by `utility.sanitizeEnvForAgent` before spawning the ephemeral subprocess.
To add another agent that needs env vars stripped: set `StripEnv: []string{"VAR_NAME"}` in its `Runtime()` — that's all.
## Idle-instance reaper (`KANDEV_ACP_IDLE_TIMEOUT`)
`instance.Manager` runs a background goroutine that reaps instances whose owning kandev backend appears to be gone. An instance is "idle" when it has zero in-flight HTTP requests *and* its most recent observed activity is older than the configured timeout. Activity is tracked by a middleware on the per-instance handler that bumps a counter on every request entry/exit, so a long-lived `/agent/stream` WebSocket keeps the instance "active" for as long as the stream is open.
@@ -221,6 +221,10 @@ type InstanceConfig struct {
// keeps child processes alive when stdin closes (opencode acp).
RequiresProcessKill bool
// StripEnv lists environment variables to strip from the agent's child
// process environment entirely (not just set to empty).
StripEnv []string
// BaseBranches maps RepositoryName → base branch ref for per-repo diff
// stats. The empty key "" applies to the root / single-repo tracker.
// process.Manager reads this at construction and stamps each
@@ -382,6 +386,9 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) {
if overrides.RequiresProcessKill {
cfg.RequiresProcessKill = true
}
if len(overrides.StripEnv) > 0 {
cfg.StripEnv = overrides.StripEnv
}
if len(overrides.BaseBranches) > 0 {
cfg.BaseBranches = overrides.BaseBranches
}
@@ -423,6 +430,7 @@ type InstanceOverrides struct {
AssumeMcpHttp bool
McpMode string
RequiresProcessKill bool
StripEnv []string
BaseBranches map[string]string
}
@@ -160,6 +160,10 @@ type CreateRequest struct {
// opencode acp, which spawns MCP server children that leak otherwise).
RequiresProcessKill bool `json:"requires_process_kill,omitempty"`
// StripEnv lists environment variables to strip from the agent's child
// process environment entirely (not just set to empty).
StripEnv []string `json:"strip_env,omitempty"`
// BaseBranches maps RepositoryName → base branch ref for per-repo diff
// stats. The empty key "" applies to the root / single-repo tracker.
// Each WorkspaceTracker reads its entry at startup and uses it as the
@@ -120,6 +120,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
AssumeMcpHttp: req.AssumeMcpHttp,
McpMode: req.McpMode,
RequiresProcessKill: req.RequiresProcessKill,
StripEnv: req.StripEnv,
BaseBranches: req.BaseBranches,
}
@@ -225,7 +225,7 @@ func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows in
if proc.startDir != "" {
cmd.Dir = proc.startDir
}
cmd.Env = mergeEnv(proc.startEnv)
cmd.Env = mergeEnvWithStrip(proc.startEnv, req.StripEnv)
// Note: Do NOT set Setpgid when using PTY - it conflicts with terminal control
// The PTY session handles process group management
@@ -28,6 +28,7 @@ type InteractiveStartRequest struct {
TerminalID string `json:"terminal_id,omitempty"` // User-shell terminal ID when applicable
Label string `json:"label,omitempty"` // User-facing terminal label when applicable
Env map[string]string `json:"env,omitempty"` // Additional environment variables
StripEnv []string `json:"strip_env,omitempty"` // Environment variable keys to remove from the inherited environment
PromptPattern string `json:"prompt_pattern,omitempty"` // Regex pattern to detect agent prompt for turn completion
IdleTimeout time.Duration `json:"idle_timeout,omitempty"` // Idle timeout for turn detection
BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` // Max output buffer size
@@ -21,6 +21,7 @@ import (
"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/agentctl/server/utility"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
@@ -794,6 +795,15 @@ func (m *Manager) Start(ctx context.Context) error {
// startOneShot initialises a one-shot adapter without spawning a long-lived subprocess.
// The adapter manages its own per-prompt subprocess lifecycle internally.
func (m *Manager) startOneShot() error {
// One-shot adapters bypass buildFinalCommand, so restore temp env vars
// after StripEnv before their per-prompt subprocesses inherit Env.
if err := m.ensureAgentTempEnv(); err != nil {
return err
}
if m.adapterCfg != nil && m.adapterCfg.OneShotConfig != nil {
m.adapterCfg.OneShotConfig.Env = m.cfg.AgentEnv
}
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
@@ -874,6 +884,17 @@ func (m *Manager) buildAdapterConfig() error {
for k, v := range adapterEnv {
m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v))
}
// Strip agent-declared environment variables from the final child process
// environment entirely (not just set to empty). Applied after adapter env
// merge so that adapter-injected values are also stripped. Some programs
// distinguish unset from empty string — see RuntimeConfig.StripEnv docs.
for _, key := range m.cfg.StripEnv {
m.cfg.AgentEnv = utility.RemoveEnvEntry(m.cfg.AgentEnv, key)
}
if m.adapterCfg.OneShotConfig != nil {
m.adapterCfg.OneShotConfig.Env = m.cfg.AgentEnv
}
return nil
}
@@ -6,6 +6,8 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
@@ -13,6 +15,7 @@ import (
"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/pkg/agent"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -29,6 +32,16 @@ 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 {
@@ -182,3 +195,75 @@ func TestFormatAgentStartError_E2BIGIncludesEnvDiagnostics(t *testing.T) {
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 TestStartOneShotRestoresTempEnvAfterStrip(t *testing.T) {
log := newTestLogger(t)
workDir := t.TempDir()
m := &Manager{
cfg: &config.InstanceConfig{
SessionID: "session-1",
WorkDir: workDir,
AgentEnv: []string{"PATH=/usr/bin"},
},
logger: log,
adapter: newOneShotStubAdapter(),
adapterCfg: &adapter.Config{
OneShotConfig: &adapter.OneShotConfig{
Env: []string{"PATH=/usr/bin"},
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()
})
wantDir := filepath.Join(os.TempDir(), agentTempDirRoot, agentTempDirName("session-1", 0))
for _, key := range []string{"TMPDIR", "TMP", "TEMP"} {
if got := lookupEnvValue(m.adapterCfg.OneShotConfig.Env, key); got != wantDir {
t.Fatalf("OneShotConfig.Env %s = %q, want %q", key, got, wantDir)
}
}
}
@@ -652,6 +652,10 @@ func (p *commandProcess) snapshot(includeOutput bool) ProcessInfo {
// This allows processes to inherit the agentctl server's environment (including PATH,
// HOME, etc.) while also supporting custom variables per process.
func mergeEnv(env map[string]string) []string {
return mergeEnvWithStrip(env, nil)
}
func mergeEnvWithStrip(env map[string]string, stripEnv []string) []string {
base := make(map[string]string, len(os.Environ())+len(env))
// Parse parent environment into map, filtering out npm config variables
@@ -678,6 +682,10 @@ func mergeEnv(env map[string]string) []string {
base[k] = v
}
for _, key := range stripEnv {
delete(base, key)
}
// Convert back to []string format
merged := make([]string, 0, len(base))
for k, v := range base {
@@ -299,3 +299,34 @@ func TestMergeEnv_FiltersNpmVars(t *testing.T) {
t.Error("CUSTOM_VAR should have been added")
}
}
func TestMergeEnvWithStrip_RemovesDeclaredParentAndCustomVars(t *testing.T) {
t.Setenv("ACP_BACKEND", "windsurf")
t.Setenv("TEST_KEEP_ME", "yes")
result := mergeEnvWithStrip(map[string]string{
"ACP_BACKEND": "custom",
"CUSTOM_VAR": "custom_value",
}, []string{"ACP_BACKEND"})
resultMap := envSliceToMap(result)
if _, exists := resultMap["ACP_BACKEND"]; exists {
t.Fatalf("ACP_BACKEND should have been stripped, got %q", resultMap["ACP_BACKEND"])
}
if resultMap["TEST_KEEP_ME"] != "yes" {
t.Fatalf("TEST_KEEP_ME should have been kept, got %q", resultMap["TEST_KEEP_ME"])
}
if resultMap["CUSTOM_VAR"] != "custom_value" {
t.Fatalf("CUSTOM_VAR should have been added, got %q", resultMap["CUSTOM_VAR"])
}
}
func envSliceToMap(env []string) map[string]string {
out := make(map[string]string)
for _, entry := range env {
if eq := strings.IndexByte(entry, '='); eq >= 0 {
out[entry[:eq]] = entry[eq+1:]
}
}
return out
}
@@ -76,6 +76,7 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...)
cmd.Dir = workDir
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
configureACPCommand(cmd, e.logger)
stdin, err := cmd.StdinPipe()
@@ -322,6 +323,7 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...)
cmd.Dir = workDir
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
configureACPCommand(cmd, e.logger)
stdin, err := cmd.StdinPipe()
@@ -714,6 +716,7 @@ func derefString(p *string) string {
var allowedProbeCommands = map[string]string{
"auggie": "auggie",
"cursor-agent": "cursor-agent",
"devin": "devin",
"kimi": "kimi",
"kiro-cli-chat": "kiro-cli-chat",
"mock-agent": "mock-agent",
@@ -730,6 +733,34 @@ func resolveProbeCommand(name string) string {
return allowedProbeCommands[filepath.Base(name)]
}
// sanitizeEnvForAgent returns a child-process environment with agent-declared
// variables (InferenceConfigDTO.StripEnv) removed. Applied to one-shot
// probe/inference subprocesses; the persistent session path strips in
// process.Manager.buildAdapterConfig instead.
func sanitizeEnvForAgent(cfg *InferenceConfigDTO) []string {
env := os.Environ()
if cfg != nil {
for _, key := range cfg.StripEnv {
env = RemoveEnvEntry(env, key)
}
}
return env
}
// RemoveEnvEntry removes all entries for the given key from the env slice.
// Used to ensure a variable is truly absent (not just empty) in the child
// process environment — some programs distinguish unset from empty string.
func RemoveEnvEntry(env []string, key string) []string {
prefix := key + "="
next := make([]string, 0, len(env))
for _, e := range env {
if !strings.HasPrefix(e, prefix) {
next = append(next, e)
}
}
return next
}
// buildACPCommand builds the command arguments for ACP inference. The model
// parameter is a no-op for ACP-first agents (they have no ModelFlag); model
// selection is applied through the ACP session after session/new instead.
@@ -4,6 +4,7 @@ import (
"maps"
"path/filepath"
"slices"
"strings"
"testing"
acp "github.com/coder/acp-go-sdk"
@@ -359,3 +360,81 @@ func TestApplySessionProbeFields_FlattensGroupedConfigOptions(t *testing.T) {
t.Fatalf("Modes = %+v, want [default bypass]", out.Modes)
}
}
func TestRemoveEnvEntry(t *testing.T) {
t.Parallel()
tests := []struct {
name string
env []string
key string
want []string
}{
{
name: "removes single match",
env: []string{"ACP_BACKEND=windsurf", "PATH=/usr/bin"},
key: "ACP_BACKEND",
want: []string{"PATH=/usr/bin"},
},
{
name: "removes all matches",
env: []string{"ACP_BACKEND=a", "PATH=/usr/bin", "ACP_BACKEND=b"},
key: "ACP_BACKEND",
want: []string{"PATH=/usr/bin"},
},
{
name: "no match returns unchanged",
env: []string{"PATH=/usr/bin", "HOME=/root"},
key: "ACP_BACKEND",
want: []string{"PATH=/usr/bin", "HOME=/root"},
},
{
name: "prefix-only key not removed",
env: []string{"ACP_BACKEND_URL=http://x", "ACP_BACKEND=windsurf"},
key: "ACP_BACKEND",
want: []string{"ACP_BACKEND_URL=http://x"},
},
{
name: "empty env",
env: []string{},
key: "ACP_BACKEND",
want: []string{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := RemoveEnvEntry(tc.env, tc.key)
if !slices.Equal(got, tc.want) {
t.Errorf("RemoveEnvEntry(%v, %q) = %v, want %v", tc.env, tc.key, got, tc.want)
}
})
}
}
func TestSanitizeEnvForAgent(t *testing.T) {
// Core case: strip listed vars while keeping the rest. This covers the
// main RemoveEnvEntry + sanitizeEnvForAgent path.
t.Setenv("ACP_BACKEND", "windsurf")
t.Setenv("TEST_KEEP_ME", "yes")
env := sanitizeEnvForAgent(&InferenceConfigDTO{
StripEnv: []string{"ACP_BACKEND"},
})
for _, e := range env {
if strings.HasPrefix(e, "ACP_BACKEND=") {
t.Errorf("ACP_BACKEND not stripped: %q", e)
}
}
found := false
for _, e := range env {
if e == "TEST_KEEP_ME=yes" {
found = true
break
}
}
if !found {
t.Errorf("TEST_KEEP_ME was stripped but should have been kept")
}
}
@@ -174,6 +174,9 @@ type InferenceConfigDTO struct {
ModelFlag []string `json:"model_flag,omitempty"`
// WorkDir is the working directory for the agent process.
WorkDir string `json:"work_dir"`
// StripEnv lists environment variables to strip from the inference
// subprocess environment entirely (not just set to empty).
StripEnv []string `json:"strip_env,omitempty"`
}
// PromptResponse is the response from executing a utility prompt.