fix: apply profile auto-approve to agentctl instances (#1358)

This commit is contained in:
Carlos Florêncio
2026-06-13 23:22:54 +01:00
committed by GitHub
parent e983cb1be1
commit c724e38be9
15 changed files with 390 additions and 165 deletions
@@ -39,21 +39,22 @@ type McpServerConfig struct {
// CreateInstanceRequest contains the parameters for creating a new agent instance.
type CreateInstanceRequest struct {
ID string `json:"id,omitempty"`
WorkspacePath string `json:"workspace_path"`
AgentCommand string `json:"agent_command,omitempty"`
Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp, codex)
AgentType string `json:"agent_type,omitempty"` // Agent type ID for debug file naming (e.g., "codex", "auggie")
WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root")
Env map[string]string `json:"env,omitempty"`
AutoStart bool `json:"auto_start,omitempty"`
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls
TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection)
DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents)
AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers
AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers
McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "config", or "office"
ID string `json:"id,omitempty"`
WorkspacePath string `json:"workspace_path"`
AgentCommand string `json:"agent_command,omitempty"`
Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp, codex)
AgentType string `json:"agent_type,omitempty"` // Agent type ID for debug file naming (e.g., "codex", "auggie")
WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root")
Env map[string]string `json:"env,omitempty"`
AutoStart bool `json:"auto_start,omitempty"`
AutoApprovePermissions *bool `json:"auto_approve_permissions,omitempty"`
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls
TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection)
DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents)
AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers
AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers
McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "config", or "office"
// RequiresProcessKill forces agentctl to kill the agent's process group
// (not just close stdin) on shutdown. Required for agents whose runtime
// keeps child processes (e.g. MCP servers) alive when stdin closes —
@@ -27,32 +27,49 @@ const (
// ContainerConfig holds configuration for launching a Docker container
type ContainerConfig struct {
AgentConfig agents.Agent
WorkspacePath string // If empty, workspace is not mounted (will clone inside container)
TaskID string
TaskTitle string
TaskEnvironmentID string
TaskDescription string
Model string
SessionID string
ExecutorProfileID string
Credentials map[string]string
ProfileInfo *AgentProfileInfo
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
McpServers []McpServerConfig
McpMode string
PrepareScript string // Script to run inside container before agent starts (e.g., clone repo)
ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag)
LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path.
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
Metadata map[string]interface{} // Optional metadata (e.g., office runtime dir)
AgentConfig agents.Agent
WorkspacePath string // If empty, workspace is not mounted (will clone inside container)
TaskID string
TaskTitle string
TaskEnvironmentID string
TaskDescription string
Model string
SessionID string
ExecutorProfileID string
Credentials map[string]string
AutoApprovePermissions bool
AutoApprovePermissionsOverride *bool
ProfileInfo *AgentProfileInfo
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
McpServers []McpServerConfig
McpMode string
PrepareScript string // Script to run inside container before agent starts (e.g., clone repo)
ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag)
LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path.
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
Metadata map[string]interface{} // Optional metadata (e.g., office runtime dir)
// BaseBranches maps RepositoryName → base branch ref; forwarded into
// agentctl's CreateInstanceRequest so each WorkspaceTracker resolves
// diff stats against the task-recorded base.
BaseBranches map[string]string
}
func boolPtr(v bool) *bool {
return &v
}
func autoApprovePermissionsOverride(enabled bool, override *bool) *bool {
if override != nil {
return override
}
if enabled {
// Preserve compatibility for callers that still only set the legacy bool.
return boolPtr(true)
}
return nil
}
// ContainerManager handles Docker container lifecycle operations
type ContainerManager struct {
dockerClient *docker.Client
@@ -217,11 +234,15 @@ func (cm *ContainerManager) createInstanceAndClient(
}
createReq := &agentctl.CreateInstanceRequest{
ID: config.InstanceID,
WorkspacePath: "/workspace",
AgentCommand: "",
AgentType: agentType,
Env: config.Credentials,
ID: config.InstanceID,
WorkspacePath: "/workspace",
AgentCommand: "",
AgentType: agentType,
Env: config.Credentials,
AutoApprovePermissions: autoApprovePermissionsOverride(
config.AutoApprovePermissions,
config.AutoApprovePermissionsOverride,
),
AutoStart: false,
McpServers: config.McpServers,
SessionID: config.SessionID,
@@ -250,22 +250,26 @@ type RemoteStatusProvider interface {
// ExecutorCreateRequest contains parameters for creating an agentctl instance.
type ExecutorCreateRequest struct {
InstanceID string
TaskID string
TaskTitle string
SessionID string
TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task)
AgentProfileID string
WorkspacePath string
Protocol string
Env map[string]string
Metadata map[string]interface{}
McpServers []McpServerConfig
AgentConfig agents.Agent // Agent type info needed by runtimes
PreviousExecutionID string // Non-empty when reconnecting to a previous execution
McpMode string // MCP tool mode: "task" (default), "config", or "office"
AuthToken string // Previously handshaken agentctl token for reconnects
BootstrapNonce string // Stored nonce for re-handshake after container restart
InstanceID string
TaskID string
TaskTitle string
SessionID string
TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task)
AgentProfileID string
WorkspacePath string
Protocol string
Env map[string]string
AutoApprovePermissions bool
// AutoApprovePermissionsOverride is set when a resolved profile explicitly
// selected the auto-approve value. Nil preserves agentctl defaults/env fallback.
AutoApprovePermissionsOverride *bool
Metadata map[string]interface{}
McpServers []McpServerConfig
AgentConfig agents.Agent // Agent type info needed by runtimes
PreviousExecutionID string // Non-empty when reconnecting to a previous execution
McpMode string // MCP tool mode: "task" (default), "config", or "office"
AuthToken string // Previously handshaken agentctl token for reconnects
BootstrapNonce string // Stored nonce for re-handshake after container restart
// OnProgress is an optional callback for streaming preparation progress.
// Executors that perform multi-step setup (e.g. Sprites, remote Docker) can
@@ -228,20 +228,22 @@ func (r *DockerExecutor) seedSessionDir(ctx context.Context, req *ExecutorCreate
func (r *DockerExecutor) buildContainerLaunchConfig(req *ExecutorCreateRequest) ContainerConfig {
return ContainerConfig{
AgentConfig: req.AgentConfig,
WorkspacePath: "", // Empty = no workspace mount; we clone inside container.
TaskID: req.TaskID,
TaskTitle: req.TaskTitle,
TaskEnvironmentID: req.TaskEnvironmentID,
SessionID: req.SessionID,
ExecutorProfileID: getMetadataString(req.Metadata, "executor_profile_id"),
InstanceID: req.InstanceID,
Credentials: req.Env,
McpServers: req.McpServers,
PrepareScript: r.resolvePrepareScript(req),
ImageTagOverride: getMetadataString(req.Metadata, MetadataKeyImageTagOverride),
LocalClonePath: localCloneMountPath(req.Metadata),
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
AgentConfig: req.AgentConfig,
WorkspacePath: "", // Empty = no workspace mount; we clone inside container.
TaskID: req.TaskID,
TaskTitle: req.TaskTitle,
TaskEnvironmentID: req.TaskEnvironmentID,
SessionID: req.SessionID,
ExecutorProfileID: getMetadataString(req.Metadata, "executor_profile_id"),
InstanceID: req.InstanceID,
Credentials: req.Env,
AutoApprovePermissions: req.AutoApprovePermissions,
AutoApprovePermissionsOverride: req.AutoApprovePermissionsOverride,
McpServers: req.McpServers,
PrepareScript: r.resolvePrepareScript(req),
ImageTagOverride: getMetadataString(req.Metadata, MetadataKeyImageTagOverride),
LocalClonePath: localCloneMountPath(req.Metadata),
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
}
}
@@ -489,10 +491,14 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID
}
}
return &agentctl.CreateInstanceRequest{
ID: instanceID,
WorkspacePath: dockerWorkspacePath,
AgentType: agentType,
Env: req.Env,
ID: instanceID,
WorkspacePath: dockerWorkspacePath,
AgentType: agentType,
Env: req.Env,
AutoApprovePermissions: autoApprovePermissionsOverride(
req.AutoApprovePermissions,
req.AutoApprovePermissionsOverride,
),
AutoStart: false,
McpServers: req.McpServers,
SessionID: req.SessionID,
@@ -509,8 +509,9 @@ func TestShouldStartExistingDockerContainer(t *testing.T) {
func TestBuildReconnectCreateInstanceRequest(t *testing.T) {
req := &ExecutorCreateRequest{
TaskID: "task-1",
SessionID: "session-1",
TaskID: "task-1",
SessionID: "session-1",
AutoApprovePermissions: true,
AgentConfig: &testAgent{
id: "codex",
enabled: true,
@@ -542,6 +543,9 @@ func TestBuildReconnectCreateInstanceRequest(t *testing.T) {
if got.Env["OPENAI_API_KEY"] != "token" {
t.Fatalf("env not propagated: %v", got.Env)
}
if got.AutoApprovePermissions == nil || !*got.AutoApprovePermissions {
t.Fatalf("AutoApprovePermissions = %v, want true", got.AutoApprovePermissions)
}
if len(got.McpServers) != 1 || got.McpServers[0].Name != "test-mcp" {
t.Fatalf("mcp servers not propagated: %v", got.McpServers)
}
@@ -556,6 +560,15 @@ func TestBuildReconnectCreateInstanceRequest(t *testing.T) {
}
}
func TestBuildReconnectCreateInstanceRequestOmitsAutoApproveOverrideWhenUnset(t *testing.T) {
req := &ExecutorCreateRequest{}
got := buildReconnectCreateInstanceRequest(req, "previous-exec")
if got.AutoApprovePermissions != nil {
t.Fatalf("AutoApprovePermissions = %v, want nil", got.AutoApprovePermissions)
}
}
func TestResolvePrepareScript(t *testing.T) {
log := newTestDockerLogger()
exec := NewDockerExecutor(config.DockerConfig{}, "", log)
@@ -357,12 +357,16 @@ func (r *SpritesExecutor) createAgentInstance(
req *ExecutorCreateRequest,
) (int, error) {
instanceReq := agentctl.CreateInstanceRequest{
ID: req.InstanceID,
WorkspacePath: spritesWorkspacePath,
SessionID: req.SessionID,
TaskID: req.TaskID,
Protocol: req.Protocol,
AgentType: agentTypeFromReq(req),
ID: req.InstanceID,
WorkspacePath: spritesWorkspacePath,
SessionID: req.SessionID,
TaskID: req.TaskID,
Protocol: req.Protocol,
AgentType: agentTypeFromReq(req),
AutoApprovePermissions: autoApprovePermissionsOverride(
req.AutoApprovePermissions,
req.AutoApprovePermissionsOverride,
),
McpServers: req.McpServers,
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKillFromReq(req),
@@ -471,12 +471,16 @@ func createRemoteAgentInstance(
log *logger.Logger,
) (int, error) {
body, err := json.Marshal(agentctl.CreateInstanceRequest{
ID: req.InstanceID,
WorkspacePath: workspacePath,
SessionID: req.SessionID,
TaskID: req.TaskID,
Protocol: req.Protocol,
AgentType: sshAgentTypeFromReq(req),
ID: req.InstanceID,
WorkspacePath: workspacePath,
SessionID: req.SessionID,
TaskID: req.TaskID,
Protocol: req.Protocol,
AgentType: sshAgentTypeFromReq(req),
AutoApprovePermissions: autoApprovePermissionsOverride(
req.AutoApprovePermissions,
req.AutoApprovePermissionsOverride,
),
McpServers: req.McpServers,
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKillFromReq(req),
@@ -107,12 +107,16 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr
}
createReq := &agentctl.CreateInstanceRequest{
ID: req.InstanceID,
WorkspacePath: req.WorkspacePath,
AgentCommand: "", // Agent command set via Configure endpoint
Protocol: req.Protocol,
AgentType: agentType,
Env: env,
ID: req.InstanceID,
WorkspacePath: req.WorkspacePath,
AgentCommand: "", // Agent command set via Configure endpoint
Protocol: req.Protocol,
AgentType: agentType,
Env: env,
AutoApprovePermissions: autoApprovePermissionsOverride(
req.AutoApprovePermissions,
req.AutoApprovePermissionsOverride,
),
AutoStart: false,
McpServers: req.McpServers,
SessionID: req.SessionID,
@@ -401,25 +401,44 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
// session/load then looks under the wrong SDK root → -32002 Resource not
// found.
env := map[string]string{}
m.mergeAgentProfileEnv(ctx, info.AgentProfileID, env)
var profileInfo *AgentProfileInfo
if info.AgentProfileID != "" && m.profileResolver != nil {
resolvedProfile, err := m.profileResolver.ResolveProfile(ctx, info.AgentProfileID)
if err != nil {
m.logger.Warn("failed to resolve profile for workspace execution",
zap.String("agent_profile_id", info.AgentProfileID),
zap.Error(err))
} else {
profileInfo = resolvedProfile
}
}
m.mergeAgentProfileEnvFromInfo(ctx, profileInfo, env)
autoApprove := false
var autoApproveOverride *bool
if profileInfo != nil {
autoApprove = profileInfo.AutoApprove
autoApproveOverride = boolPtr(profileInfo.AutoApprove)
}
if len(env) == 0 {
env = nil
}
req := &ExecutorCreateRequest{
InstanceID: executionID,
TaskID: taskID,
SessionID: info.SessionID,
TaskEnvironmentID: info.TaskEnvironmentID,
AgentProfileID: info.AgentProfileID,
WorkspacePath: info.WorkspacePath,
Protocol: string(agentConfig.Runtime().Protocol),
Env: env,
AgentConfig: agentConfig,
Metadata: info.Metadata,
PreviousExecutionID: info.AgentExecutionID,
AuthToken: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyAuthTokenSecret),
BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret),
InstanceID: executionID,
TaskID: taskID,
SessionID: info.SessionID,
TaskEnvironmentID: info.TaskEnvironmentID,
AgentProfileID: info.AgentProfileID,
WorkspacePath: info.WorkspacePath,
Protocol: string(agentConfig.Runtime().Protocol),
Env: env,
AutoApprovePermissions: autoApprove,
AutoApprovePermissionsOverride: autoApproveOverride,
AgentConfig: agentConfig,
Metadata: info.Metadata,
PreviousExecutionID: info.AgentExecutionID,
AuthToken: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyAuthTokenSecret),
BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret),
}
runtimeInstance, err := rt.CreateInstance(ctx, req)
@@ -17,6 +17,7 @@ import (
"github.com/kandev/kandev/internal/agent/executor"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -590,6 +591,44 @@ func TestEnsureWorkspaceExecutionForSession_ReusesExistingTaskEnvironmentExecuti
}
}
func TestCreateExecutionResolvesProfileOnceForEnvAndAutoApprove(t *testing.T) {
profileResolver := &countingProfileResolver{
info: &AgentProfileInfo{
ProfileID: "profile-1",
AgentID: "auggie",
AutoApprove: true,
EnvVars: []settingsmodels.ProfileEnvVar{{Key: "CLAUDE_CONFIG_DIR", Value: "/tmp/claude"}},
},
}
mgr, backend := newEnvironmentExecutionTestManagerWithProfileResolver(t, &mockWorkspaceInfoProvider{}, profileResolver)
_, err := mgr.createExecution(context.Background(), "task-1", &WorkspaceInfo{
SessionID: "session-1",
AgentID: "auggie",
AgentProfileID: "profile-1",
WorkspacePath: "/workspace/task-1",
})
if err != nil {
t.Fatalf("createExecution returned error: %v", err)
}
if got := profileResolver.calls.Load(); got != 1 {
t.Fatalf("ResolveProfile calls = %d, want 1", got)
}
if backend.lastRequest == nil {
t.Fatal("CreateInstance was not called")
}
if !backend.lastRequest.AutoApprovePermissions {
t.Fatal("AutoApprovePermissions = false, want true")
}
if backend.lastRequest.AutoApprovePermissionsOverride == nil || !*backend.lastRequest.AutoApprovePermissionsOverride {
t.Fatalf("AutoApprovePermissionsOverride = %v, want true", backend.lastRequest.AutoApprovePermissionsOverride)
}
if got := backend.lastRequest.Env["CLAUDE_CONFIG_DIR"]; got != "/tmp/claude" {
t.Fatalf("CLAUDE_CONFIG_DIR = %q, want %q", got, "/tmp/claude")
}
}
// --- test helpers ---
type createInstanceExecutor struct {
@@ -597,6 +636,7 @@ type createInstanceExecutor struct {
client *agentctl.Client
createCount atomic.Int32
stopCount atomic.Int32
lastRequest *ExecutorCreateRequest
authToken string
nonce string
delay time.Duration
@@ -634,6 +674,7 @@ func (e *createInstanceExecutor) CreateInstance(ctx context.Context, req *Execut
case <-time.After(e.delay):
}
}
e.lastRequest = req
e.createCount.Add(1)
if progress != nil {
completeStepSuccess(progress)
@@ -657,6 +698,14 @@ func (e *createInstanceExecutor) StopInstance(ctx context.Context, instance *Exe
}
func newEnvironmentExecutionTestManager(t *testing.T, provider WorkspaceInfoProvider) (*Manager, *createInstanceExecutor) {
return newEnvironmentExecutionTestManagerWithProfileResolver(t, provider, &MockProfileResolver{})
}
func newEnvironmentExecutionTestManagerWithProfileResolver(
t *testing.T,
provider WorkspaceInfoProvider,
profileResolver ProfileResolver,
) (*Manager, *createInstanceExecutor) {
t.Helper()
log := newTestLogger()
execRegistry := NewExecutorRegistry(log)
@@ -667,7 +716,7 @@ func newEnvironmentExecutionTestManager(t *testing.T, provider WorkspaceInfoProv
execRegistry.Register(backend)
mgr := NewManager(
newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil,
newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, profileResolver, nil,
ExecutorFallbackWarn, "", log,
)
mgr.workspaceInfoProvider = provider
@@ -675,6 +724,17 @@ func newEnvironmentExecutionTestManager(t *testing.T, provider WorkspaceInfoProv
return mgr, backend
}
type countingProfileResolver struct {
info *AgentProfileInfo
err error
calls atomic.Int32
}
func (r *countingProfileResolver) ResolveProfile(_ context.Context, _ string) (*AgentProfileInfo, error) {
r.calls.Add(1)
return r.info, r.err
}
func newReadyAgentctlClient(t *testing.T, log *logger.Logger) *agentctl.Client {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -510,24 +510,30 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st
metadata := buildLaunchMetadata(reqWithWorktree, mainRepoGitDir, worktreeID, worktreeBranch)
var autoApproveOverride *bool
if profileInfo != nil {
autoApproveOverride = boolPtr(profileInfo.AutoApprove)
}
execReq := &ExecutorCreateRequest{
InstanceID: executionID,
TaskID: reqWithWorktree.TaskID,
TaskTitle: reqWithWorktree.TaskTitle,
SessionID: reqWithWorktree.SessionID,
TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID,
AgentProfileID: reqWithWorktree.AgentProfileID,
WorkspacePath: reqWithWorktree.WorkspacePath,
Protocol: string(agentConfig.Runtime().Protocol),
Env: env,
Metadata: metadata,
AgentConfig: agentConfig,
McpServers: mcpServers,
PreviousExecutionID: reqWithWorktree.PreviousExecutionID,
McpMode: reqWithWorktree.McpMode,
AuthToken: m.revealRuntimeSecret(ctx, metadata, MetadataKeyAuthTokenSecret),
BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret),
OnProgress: onProgress,
InstanceID: executionID,
TaskID: reqWithWorktree.TaskID,
TaskTitle: reqWithWorktree.TaskTitle,
SessionID: reqWithWorktree.SessionID,
TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID,
AgentProfileID: reqWithWorktree.AgentProfileID,
WorkspacePath: reqWithWorktree.WorkspacePath,
Protocol: string(agentConfig.Runtime().Protocol),
Env: env,
AutoApprovePermissions: profileInfo != nil && profileInfo.AutoApprove,
AutoApprovePermissionsOverride: autoApproveOverride,
Metadata: metadata,
AgentConfig: agentConfig,
McpServers: mcpServers,
PreviousExecutionID: reqWithWorktree.PreviousExecutionID,
McpMode: reqWithWorktree.McpMode,
AuthToken: m.revealRuntimeSecret(ctx, metadata, MetadataKeyAuthTokenSecret),
BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret),
OnProgress: onProgress,
}
if resumer, ok := rt.(RemoteSessionResumer); ok {
@@ -0,0 +1,53 @@
package config
import "testing"
func TestNewInstanceConfigAutoApproveFromEnvOverride(t *testing.T) {
cfg := &Config{
Defaults: InstanceDefaults{
AutoApprovePermissions: false,
},
}
instanceCfg := cfg.NewInstanceConfig(0, &InstanceOverrides{
Env: []string{"AGENTCTL_AUTO_APPROVE_PERMISSIONS=true"},
})
if !instanceCfg.AutoApprovePermissions {
t.Fatal("AutoApprovePermissions = false, want true from per-instance env override")
}
}
func TestNewInstanceConfigAutoApproveFromExplicitOverride(t *testing.T) {
cfg := &Config{
Defaults: InstanceDefaults{
AutoApprovePermissions: false,
},
}
autoApprove := true
instanceCfg := cfg.NewInstanceConfig(0, &InstanceOverrides{
AutoApprovePermissions: &autoApprove,
})
if !instanceCfg.AutoApprovePermissions {
t.Fatal("AutoApprovePermissions = false, want true from explicit per-instance override")
}
}
func TestNewInstanceConfigAutoApproveExplicitOverrideCanDisableDefault(t *testing.T) {
cfg := &Config{
Defaults: InstanceDefaults{
AutoApprovePermissions: true,
},
}
autoApprove := false
instanceCfg := cfg.NewInstanceConfig(0, &InstanceOverrides{
AutoApprovePermissions: &autoApprove,
})
if instanceCfg.AutoApprovePermissions {
t.Fatal("AutoApprovePermissions = true, want false from explicit per-instance override")
}
}
@@ -354,12 +354,7 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) {
if overrides.AutoStart != nil {
cfg.AutoStart = *overrides.AutoStart
}
if overrides.Env != nil {
cfg.AgentEnv = overrides.Env
}
if overrides.ApprovalPolicy != "" {
cfg.ApprovalPolicy = overrides.ApprovalPolicy
}
applyApprovalOverrides(cfg, overrides)
if overrides.AgentType != "" {
cfg.AgentType = overrides.AgentType
}
@@ -392,25 +387,43 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) {
}
}
// applyApprovalOverrides sets approval-related instance overrides. Env is a
// legacy path that can only enable auto-approve; explicit values win.
func applyApprovalOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) {
if overrides.Env != nil {
cfg.AgentEnv = overrides.Env
if envBool(overrides.Env, "AGENTCTL_AUTO_APPROVE_PERMISSIONS") {
cfg.AutoApprovePermissions = true
}
}
if overrides.AutoApprovePermissions != nil {
cfg.AutoApprovePermissions = *overrides.AutoApprovePermissions
}
if overrides.ApprovalPolicy != "" {
cfg.ApprovalPolicy = overrides.ApprovalPolicy
}
}
// InstanceOverrides allows overriding default values when creating an instance
type InstanceOverrides struct {
InstanceID string
Protocol agent.Protocol
AgentCommand string
WorkDir string
AutoStart *bool
Env []string
ApprovalPolicy string
AgentType string
McpServers []McpServerConfig
SessionID string
TaskID string
DisableAskQuestion bool
AssumeMcpSse bool
AssumeMcpHttp bool
McpMode string
RequiresProcessKill bool
BaseBranches map[string]string
InstanceID string
Protocol agent.Protocol
AgentCommand string
WorkDir string
AutoStart *bool
Env []string
AutoApprovePermissions *bool
ApprovalPolicy string
AgentType string
McpServers []McpServerConfig
SessionID string
TaskID string
DisableAskQuestion bool
AssumeMcpSse bool
AssumeMcpHttp bool
McpMode string
RequiresProcessKill bool
BaseBranches map[string]string
}
// ParseCommand splits a command string into arguments
@@ -448,6 +461,18 @@ func CollectAgentEnv(additional map[string]string) []string {
return result
}
func envBool(env []string, key string) bool {
prefix := key + "="
for _, entry := range env {
value, ok := strings.CutPrefix(entry, prefix)
if !ok {
continue
}
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes")
}
return false
}
// isNpmEnvVar returns true if the key is an npm-related environment variable
// that should be filtered out to prevent warnings in npx commands.
func isNpmEnvVar(key string) bool {
@@ -130,6 +130,10 @@ type CreateRequest struct {
// AutoStart indicates whether to start the agent automatically after creation.
AutoStart bool `json:"auto_start,omitempty"`
// AutoApprovePermissions controls agentctl-side permission auto-approval
// for this instance. Nil uses the control server default.
AutoApprovePermissions *bool `json:"auto_approve_permissions,omitempty"`
// McpServers is a list of MCP servers to configure for the agent.
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
@@ -102,22 +102,23 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
zap.String("workspace_path", req.WorkspacePath))
overrides := &config.InstanceOverrides{
InstanceID: id,
Protocol: agent.Protocol(req.Protocol),
AgentCommand: agentCmd,
WorkDir: req.WorkspacePath,
AutoStart: &autoStart,
Env: config.CollectAgentEnv(req.Env),
AgentType: req.AgentType,
McpServers: mcpServers,
SessionID: req.SessionID,
TaskID: req.TaskID,
DisableAskQuestion: req.DisableAskQuestion,
AssumeMcpSse: req.AssumeMcpSse,
AssumeMcpHttp: req.AssumeMcpHttp,
McpMode: req.McpMode,
RequiresProcessKill: req.RequiresProcessKill,
BaseBranches: req.BaseBranches,
InstanceID: id,
Protocol: agent.Protocol(req.Protocol),
AgentCommand: agentCmd,
WorkDir: req.WorkspacePath,
AutoStart: &autoStart,
Env: config.CollectAgentEnv(req.Env),
AutoApprovePermissions: req.AutoApprovePermissions,
AgentType: req.AgentType,
McpServers: mcpServers,
SessionID: req.SessionID,
TaskID: req.TaskID,
DisableAskQuestion: req.DisableAskQuestion,
AssumeMcpSse: req.AssumeMcpSse,
AssumeMcpHttp: req.AssumeMcpHttp,
McpMode: req.McpMode,
RequiresProcessKill: req.RequiresProcessKill,
BaseBranches: req.BaseBranches,
}
m.logger.Info("CreateInstance: applying overrides",