fix: apply profile auto-approve to agentctl instances (#1358)
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user