From c724e38be9cea5458496161be15577855faf65fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 13 Jun 2026 23:22:54 +0100 Subject: [PATCH] fix: apply profile auto-approve to agentctl instances (#1358) --- .../agent/runtime/agentctl/control.go | 31 ++++---- .../agent/runtime/lifecycle/container.go | 71 ++++++++++++------- .../runtime/lifecycle/executor_backend.go | 36 +++++----- .../runtime/lifecycle/executor_docker.go | 42 ++++++----- .../runtime/lifecycle/executor_docker_test.go | 17 ++++- .../lifecycle/executor_sprites_operations.go | 16 +++-- .../lifecycle/executor_ssh_operations.go | 16 +++-- .../runtime/lifecycle/executor_standalone.go | 16 +++-- .../runtime/lifecycle/manager_execution.go | 47 ++++++++---- .../lifecycle/manager_execution_test.go | 62 +++++++++++++++- .../agent/runtime/lifecycle/manager_launch.go | 40 ++++++----- .../server/config/auto_approve_test.go | 53 ++++++++++++++ .../internal/agentctl/server/config/config.go | 71 +++++++++++++------ .../agentctl/server/instance/instance.go | 4 ++ .../agentctl/server/instance/manager.go | 33 ++++----- 15 files changed, 390 insertions(+), 165 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/config/auto_approve_test.go diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index 13cdfae772..0c02f4e8f0 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -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 — diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index 64071269e7..1ec4e7b816 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -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, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index ec165100ab..62e8c5310f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -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 diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index ce23a527f1..d0e3e625f6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -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, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go index ba0a3835c5..9b36e27cf5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go @@ -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) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index d069b8baf5..38005c69b9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -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), diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index ecc0bb89bc..7aad607c01 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -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), diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index 0e8db94d2d..dd44d26c81 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -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, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 9cab76a362..c0356259f8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -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) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go index 9f60c83bbd..9235379079 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go @@ -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) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index f2c591ed14..e5da0c17b4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -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 { diff --git a/apps/backend/internal/agentctl/server/config/auto_approve_test.go b/apps/backend/internal/agentctl/server/config/auto_approve_test.go new file mode 100644 index 0000000000..41a0bff850 --- /dev/null +++ b/apps/backend/internal/agentctl/server/config/auto_approve_test.go @@ -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") + } +} diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 5b34745e3f..3f15ece89b 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -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 { diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index 44467b30f5..5b18ea9ed3 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -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"` diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index b211cca2c7..154a7be7ae 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -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",