diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index b6719aef25..1db3c0106e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -412,13 +412,13 @@ func (r *prepareProgressRecorder) recordStep(step PrepareStep, index int) { // launchBuildExecutorRequest resolves MCP servers, builds the ExecutorCreateRequest, // and creates the runtime instance. -func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, mainRepoGitDir, worktreeID, worktreeBranch string, onProgress PrepareProgressCallback) (*ExecutorCreateRequest, *ExecutorInstance, ExecutorBackend, error) { +func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, profileInfo *AgentProfileInfo, mainRepoGitDir, worktreeID, worktreeBranch string, onProgress PrepareProgressCallback) (*ExecutorCreateRequest, *ExecutorInstance, ExecutorBackend, error) { rt, err := m.getExecutorBackend(reqWithWorktree.ExecutorType) if err != nil { return nil, nil, nil, fmt.Errorf("no runtime configured: %w", err) } - env := m.buildEnvForExecution(executionID, reqWithWorktree, agentConfig) + env := m.buildEnvForExecution(ctx, executionID, reqWithWorktree, agentConfig, profileInfo) acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig) if err != nil { @@ -808,7 +808,7 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen if req.ACPSessionID == "" { runtimeProgress = progressRecorder.Callback(progressRecorder.Len()) } - execReq, execInstance, rt, err := m.launchBuildExecutorRequest(ctx, executionID, &reqWithWorktree, agentConfig, mainRepoGitDir, worktreeID, worktreeBranch, runtimeProgress) + execReq, execInstance, rt, err := m.launchBuildExecutorRequest(ctx, executionID, &reqWithWorktree, agentConfig, profileInfo, mainRepoGitDir, worktreeID, worktreeBranch, runtimeProgress) if err != nil { m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, false, err) return nil, err @@ -833,6 +833,9 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen // Build the in-memory AgentExecution from the runtime instance. Extracted // to keep launchInternal under the cyclomatic-complexity budget. execution := m.buildExecutionFromInstance(req, execReq, execInstance, rt, profileInfo, agentConfig, prepResult) + if profileInfo != nil && len(profileInfo.EnvVars) > 0 { + m.cacheResolvedProfileEnv(execution, m.resolveAgentProfileEnvVars(ctx, profileInfo.EnvVars)) + } // Track + persist + publish. Returns the rollback error if Add lost a race. if err := m.registerAndPublishExecution(ctx, execution, rt, execInstance, req.SessionID); err != nil { @@ -1053,6 +1056,7 @@ func (m *Manager) configureAndStartAgent(ctx context.Context, execution *AgentEx if taskDescription != "" { env["TASK_DESCRIPTION"] = taskDescription } + m.mergeAgentProfileEnvForExecution(ctx, execution, env) if err := execution.agentctl.ConfigureAgent(ctx, execution.AgentCommand, env, approvalPolicy, execution.ContinueCommand); err != nil { return "", fmt.Errorf("failed to configure agent: %w", err) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index a01b4cd078..304085e205 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -14,6 +14,7 @@ import ( 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" + "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" ) @@ -132,6 +133,64 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) { }) } +func TestBuildEnvForExecution_ResolvesSecretBackedProfileEnv(t *testing.T) { + store := newInMemorySecretStore() + if err := store.Create(context.Background(), &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: "sec-1", Name: "token"}, + Value: "revealed", + }); err != nil { + t.Fatalf("seed secret: %v", err) + } + + mgr := newTestManager() + mgr.secretStore = store + profileInfo := &AgentProfileInfo{ + EnvVars: []settingsmodels.ProfileEnvVar{{Key: "FROM_SECRET", SecretID: "sec-1"}}, + } + + env := mgr.buildEnvForExecution( + context.Background(), + "exec-1", + &LaunchRequest{AgentProfileID: "profile-1"}, + nil, + profileInfo, + ) + if env["FROM_SECRET"] != "revealed" { + t.Fatalf("FROM_SECRET: got %q want revealed", env["FROM_SECRET"]) + } +} + +func TestSetExecutionEnv_DoesNotSnapshotProfileEnvVars(t *testing.T) { + mgr := newTestManager() + mgr.profileResolver = &mockPassthroughProfileResolver{ + envVars: []settingsmodels.ProfileEnvVar{{Key: "PROFILE_ONLY", Value: "new-value"}}, + } + execution := &AgentExecution{ + ID: "exec-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + Metadata: map[string]interface{}{}, + } + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("seed execution: %v", err) + } + + if err := mgr.SetExecutionEnv(context.Background(), execution.ID, map[string]string{"EXECUTOR_ONLY": "executor"}); err != nil { + t.Fatalf("SetExecutionEnv: %v", err) + } + + runtimeEnv, ok := execution.Metadata["runtime_env"].(map[string]string) + if !ok { + t.Fatalf("runtime_env missing or wrong type: %#v", execution.Metadata["runtime_env"]) + } + if runtimeEnv["EXECUTOR_ONLY"] != "executor" { + t.Fatalf("executor env missing: %+v", runtimeEnv) + } + if _, exists := runtimeEnv["PROFILE_ONLY"]; exists { + t.Fatalf("profile env vars must not be snapshotted into runtime_env: %+v", runtimeEnv) + } +} + // trackingPreparer records whether Prepare was called. type trackingPreparer struct { called bool diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 1ff27f2a94..9f84bd69e9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -137,6 +137,7 @@ func (m *Manager) buildPassthroughEnv(ctx context.Context, execution *AgentExecu env["KANDEV_TASK_ID"] = execution.TaskID env["KANDEV_SESSION_ID"] = execution.SessionID env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID + m.mergeAgentProfileEnv(ctx, execution.AgentProfileID, env) if m.credsMgr != nil { for _, credKey := range requiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go index 5f4f72acd1..b4ec13ace9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go @@ -14,6 +14,7 @@ import ( // mockPassthroughProfileResolver is a mock for testing passthrough verification type mockPassthroughProfileResolver struct { cliPassthrough bool + envVars []settingsmodels.ProfileEnvVar err error } @@ -24,6 +25,7 @@ func (m *mockPassthroughProfileResolver) ResolveProfile(ctx context.Context, pro return &AgentProfileInfo{ ProfileID: profileID, CLIPassthrough: m.cliPassthrough, + EnvVars: m.envVars, }, nil } @@ -421,6 +423,29 @@ func TestManager_ProfileCLIFlagTokens(t *testing.T) { }) } +func TestBuildPassthroughEnv_MergesProfileEnvVars(t *testing.T) { + mgr := newTestManager() + mgr.profileResolver = &mockPassthroughProfileResolver{ + envVars: []settingsmodels.ProfileEnvVar{ + {Key: "PLAIN", Value: "plain-value"}, + {Key: "KANDEV_SESSION_ID", Value: "profile-session"}, + }, + } + + env := mgr.buildPassthroughEnv(context.Background(), &AgentExecution{ + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + }, nil) + + if env["PLAIN"] != "plain-value" { + t.Fatalf("profile env var missing: %+v", env) + } + if env["KANDEV_SESSION_ID"] != "session-1" { + t.Fatalf("profile env var must not override KANDEV_SESSION_ID: %+v", env) + } +} + func TestManager_VerifyPassthroughEnabled(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go index 300614f0d5..c2739b4091 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go @@ -224,7 +224,7 @@ func (m *Manager) finalizeBootMessage(execution *AgentExecution, msg *models.Mes // buildEnvForExecution builds environment variables for any runtime. // This is the unified method used by the runtime interface. -func (m *Manager) buildEnvForExecution(executionID string, req *LaunchRequest, agentConfig agents.Agent) map[string]string { +func (m *Manager) buildEnvForExecution(ctx context.Context, executionID string, req *LaunchRequest, agentConfig agents.Agent, profileInfo *AgentProfileInfo) map[string]string { env := make(map[string]string) // Copy request environment @@ -232,6 +232,12 @@ func (m *Manager) buildEnvForExecution(executionID string, req *LaunchRequest, a env[k] = v } + if profileInfo != nil { + m.mergeAgentProfileEnvFromInfo(ctx, profileInfo, env) + } else { + m.mergeAgentProfileEnv(ctx, req.AgentProfileID, env) + } + // Add standard variables for recovery after backend restart env["KANDEV_INSTANCE_ID"] = executionID env["KANDEV_TASK_ID"] = req.TaskID @@ -252,7 +258,6 @@ func (m *Manager) buildEnvForExecution(executionID string, req *LaunchRequest, a // Add required credentials from agent config if m.credsMgr != nil && agentConfig != nil { - ctx := context.Background() for _, credKey := range agentConfig.Runtime().RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_env.go b/apps/backend/internal/agent/runtime/lifecycle/profile_env.go new file mode 100644 index 0000000000..b49035a6a5 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_env.go @@ -0,0 +1,111 @@ +package lifecycle + +import ( + "context" + + "go.uber.org/zap" + + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" +) + +// metadataKeyProfileEnvResolved caches resolved profile env vars on an execution +// so configureAndStartAgent does not re-resolve secrets on the same launch. +const metadataKeyProfileEnvResolved = "profile_env_resolved" + +// mergeAgentProfileEnv fills missing keys in env from the agent profile's +// env_vars. Existing keys in env (office tokens, executor profile env, etc.) +// are never overwritten. +func (m *Manager) mergeAgentProfileEnv(ctx context.Context, profileID string, env map[string]string) { + if profileID == "" || env == nil || m.profileResolver == nil { + return + } + info, err := m.profileResolver.ResolveProfile(ctx, profileID) + if err != nil || info == nil { + return + } + m.mergeAgentProfileEnvFromInfo(ctx, info, env) +} + +func (m *Manager) mergeAgentProfileEnvFromInfo(ctx context.Context, info *AgentProfileInfo, env map[string]string) { + if info == nil || env == nil || len(info.EnvVars) == 0 { + return + } + resolved := m.resolveAgentProfileEnvVars(ctx, info.EnvVars) + mergeEnvFillMissing(env, resolved) +} + +func (m *Manager) cacheResolvedProfileEnv(execution *AgentExecution, resolved map[string]string) { + if execution == nil || len(resolved) == 0 { + return + } + if execution.Metadata == nil { + execution.Metadata = make(map[string]interface{}) + } + execution.Metadata[metadataKeyProfileEnvResolved] = cloneStringMap(resolved) +} + +func (m *Manager) mergeAgentProfileEnvForExecution(ctx context.Context, execution *AgentExecution, env map[string]string) { + if execution != nil { + if cached, ok := execution.Metadata[metadataKeyProfileEnvResolved].(map[string]string); ok && len(cached) > 0 { + mergeEnvFillMissing(env, cached) + return + } + } + if execution == nil { + return + } + m.mergeAgentProfileEnv(ctx, execution.AgentProfileID, env) +} + +func mergeEnvFillMissing(dst, src map[string]string) { + if len(src) == 0 || dst == nil { + return + } + for k, v := range src { + if v == "" { + continue + } + if _, exists := dst[k]; !exists { + dst[k] = v + } + } +} + +// resolveAgentProfileEnvVars resolves profile env entries. SecretID wins over +// Value; if secret resolution fails, the entry is skipped rather than falling +// back. Literal Value is used only when SecretID is empty, and empty keys are +// ignored. +func (m *Manager) resolveAgentProfileEnvVars(ctx context.Context, envVars []settingsmodels.ProfileEnvVar) map[string]string { + if len(envVars) == 0 { + return nil + } + resolved := make(map[string]string, len(envVars)) + for _, ev := range envVars { + key := ev.Key + if key == "" { + continue + } + if ev.SecretID != "" { + if m.secretStore == nil { + m.logger.Warn("secret store not configured for profile env var", + zap.String("key", key), + zap.String("secret_id", ev.SecretID)) + continue + } + value, err := m.secretStore.Reveal(ctx, ev.SecretID) + if err != nil { + m.logger.Warn("failed to resolve secret for profile env var", + zap.String("key", key), + zap.String("secret_id", ev.SecretID), + zap.Error(err)) + continue + } + resolved[key] = value + continue + } + if ev.Value != "" { + resolved[key] = ev.Value + } + } + return resolved +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_env_test.go b/apps/backend/internal/agent/runtime/lifecycle/profile_env_test.go new file mode 100644 index 0000000000..035a1c5000 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_env_test.go @@ -0,0 +1,42 @@ +package lifecycle + +import ( + "context" + "testing" + + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" +) + +func TestMergeEnvFillMissing(t *testing.T) { + dst := map[string]string{"KANDEV_TASK_ID": "t1", "FOO": "existing"} + mergeEnvFillMissing(dst, map[string]string{"FOO": "new", "BAR": "added"}) + if dst["FOO"] != "existing" { + t.Fatalf("expected FOO to remain existing, got %q", dst["FOO"]) + } + if dst["BAR"] != "added" { + t.Fatalf("expected BAR=added, got %q", dst["BAR"]) + } +} + +func TestResolveAgentProfileEnvVars_SecretAndValue(t *testing.T) { + store := newInMemorySecretStore() + _ = store.Create(context.Background(), &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: "sec-1", Name: "test"}, + Value: "secret-value", + }) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := &Manager{logger: log, secretStore: store} + resolved := m.resolveAgentProfileEnvVars(context.Background(), []settingsmodels.ProfileEnvVar{ + {Key: "PLAIN", Value: "plain"}, + {Key: "FROM_SECRET", SecretID: "sec-1"}, + }) + if resolved["PLAIN"] != "plain" { + t.Fatalf("PLAIN: got %q", resolved["PLAIN"]) + } + if resolved["FROM_SECRET"] != "secret-value" { + t.Fatalf("FROM_SECRET: got %q", resolved["FROM_SECRET"]) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go index f68c7086c5..df3355edfd 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go @@ -48,6 +48,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str DangerouslySkipPermissions: profile.DangerouslySkipPermissions, AllowIndexing: profile.AllowIndexing, CLIFlags: profile.CLIFlags, + EnvVars: profile.EnvVars, CLIPassthrough: profile.CLIPassthrough, NativeSessionResume: nativeSessionResume, SupportsMCP: agent.SupportsMCP, diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 3140f4c942..9951fd0fa9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -446,6 +446,8 @@ type AgentProfileInfo struct { // CLIFlags is the resolved user-configurable list of CLI flags for this // profile. Passed verbatim to cliflags.Resolve at launch time. CLIFlags []settingsmodels.CLIFlag + // EnvVars are user-configured environment variables for this profile. + EnvVars []settingsmodels.ProfileEnvVar // Deprecated: legacy permission fields, no longer consulted by the launch // path. Kept so existing call sites compile during the transition. diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index dbdee2e048..49505fd5e8 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -79,6 +79,7 @@ type CreateAgentProfileRequest struct { // from the agent's curated PermissionSettings() catalogue (all disabled // by default) so a fresh profile opens with the agent's suggestions. CLIFlags []dto.CLIFlagDTO + EnvVars []dto.ProfileEnvVarDTO } func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) { @@ -176,6 +177,9 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa return nil, err } } + if err := validateProfileEnvVarDTOs(profileReq.EnvVars); err != nil { + return nil, err + } cliFlags := cliFlagsFromDTO(profileReq.CLIFlags) if profileReq.CLIFlags == nil { cliFlags = seedCLIFlags(agentConfig) @@ -187,6 +191,7 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa Model: profileReq.Model, Mode: profileReq.Mode, CLIFlags: cliFlags, + EnvVars: envVarsFromDTO(profileReq.EnvVars), } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 50b4a1ad7e..0251ebba79 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -34,14 +34,15 @@ func buildCommandString(cmd []string) string { } var ( - ErrAgentNotFound = errors.New("agent not found") - ErrAgentAlreadyExists = errors.New("agent already exists") - ErrAgentProfileNotFound = errors.New("agent profile not found") - ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") - ErrModelRequired = errors.New("model is required for agent profiles") - ErrLogoNotAvailable = errors.New("logo not available for agent") - ErrInvalidSlug = errors.New("display name must produce a valid slug") - ErrCommandRequired = errors.New("command is required") + ErrAgentNotFound = errors.New("agent not found") + ErrAgentAlreadyExists = errors.New("agent already exists") + ErrAgentProfileNotFound = errors.New("agent profile not found") + ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") + ErrModelRequired = errors.New("model is required for agent profiles") + ErrLogoNotAvailable = errors.New("logo not available for agent") + ErrInvalidSlug = errors.New("display name must produce a valid slug") + ErrCommandRequired = errors.New("command is required") + ErrInvalidProfileEnvVars = errors.New("invalid profile env vars") ) type Controller struct { diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 54b25fdbb5..1c7657cff0 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -26,6 +26,7 @@ type CreateProfileRequest struct { // profile opens with the agent's recommended flags (all disabled by // default unless the curated entry specifies Default: true). CLIFlags []dto.CLIFlagDTO + EnvVars []dto.ProfileEnvVarDTO } func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { @@ -50,6 +51,9 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest } else if err := validateCLIFlagDTOs(req.CLIFlags); err != nil { return nil, err } + if err := validateProfileEnvVarDTOs(req.EnvVars); err != nil { + return nil, err + } profile := &models.AgentProfile{ AgentID: req.AgentID, Name: req.Name, @@ -59,6 +63,7 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest AllowIndexing: req.AllowIndexing, CLIPassthrough: req.CLIPassthrough, CLIFlags: cliFlags, + EnvVars: envVarsFromDTO(req.EnvVars), UserModified: true, } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { @@ -110,6 +115,8 @@ type UpdateProfileRequest struct { // CLIFlags replaces the entire list when non-nil. Nil means "leave // unchanged" — the UI always sends the full desired list on save. CLIFlags *[]dto.CLIFlagDTO + // EnvVars replaces the entire list when non-nil. + EnvVars *[]dto.ProfileEnvVarDTO } func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { @@ -143,6 +150,12 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest } profile.CLIFlags = cliFlagsFromDTO(*req.CLIFlags) } + if req.EnvVars != nil { + if err := validateProfileEnvVarDTOs(*req.EnvVars); err != nil { + return nil, err + } + profile.EnvVars = envVarsFromDTO(*req.EnvVars) + } profile.UserModified = true if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { return nil, err @@ -277,6 +290,7 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { Mode: profile.Mode, AllowIndexing: profile.AllowIndexing, CLIFlags: cliFlagsToDTO(profile.CLIFlags), + EnvVars: envVarsToDTO(profile.EnvVars), CLIPassthrough: profile.CLIPassthrough, UserModified: profile.UserModified, WorkspaceID: profile.WorkspaceID, @@ -301,6 +315,95 @@ func cliFlagsFromDTO(in []dto.CLIFlagDTO) []models.CLIFlag { return out } +func envVarsToDTO(in []models.ProfileEnvVar) []dto.ProfileEnvVarDTO { + if len(in) == 0 { + return nil + } + out := make([]dto.ProfileEnvVarDTO, len(in)) + for i, ev := range in { + out[i] = dto.ProfileEnvVarDTO{Key: ev.Key, Value: ev.Value, SecretID: ev.SecretID} + } + return out +} + +func envVarsFromDTO(in []dto.ProfileEnvVarDTO) []models.ProfileEnvVar { + out := make([]models.ProfileEnvVar, 0, len(in)) + for _, ev := range in { + if strings.TrimSpace(ev.Key) == "" { + continue + } + out = append(out, models.ProfileEnvVar{ + Key: strings.TrimSpace(ev.Key), + Value: ev.Value, + SecretID: ev.SecretID, + }) + } + return out +} + +const ( + maxProfileEnvVars = 100 + maxProfileEnvVarKeyLen = 256 + maxProfileEnvVarValueLen = 8 * 1024 + reservedProfileEnvVarKey = "TASK_DESCRIPTION" + reservedProfileEnvVarPrefix = "KANDEV_" +) + +func validateProfileEnvVarDTOs(in []dto.ProfileEnvVarDTO) error { + if len(in) > maxProfileEnvVars { + return fmt.Errorf("%w: at most %d entries allowed", ErrInvalidProfileEnvVars, maxProfileEnvVars) + } + seen := make(map[string]int, len(in)) + for i, ev := range in { + key := strings.TrimSpace(ev.Key) + if err := validateEnvVarKey(key, i, seen); err != nil { + return err + } + seen[key] = i + if err := validateEnvVarValue(ev, i); err != nil { + return err + } + } + return nil +} + +func validateEnvVarKey(key string, i int, seen map[string]int) error { + if key == "" { + return fmt.Errorf("%w: env_vars[%d].key is required", ErrInvalidProfileEnvVars, i) + } + if len(key) > maxProfileEnvVarKeyLen { + return fmt.Errorf("%w: env_vars[%d].key exceeds %d characters", ErrInvalidProfileEnvVars, i, maxProfileEnvVarKeyLen) + } + if strings.ContainsAny(key, "=\x00") { + return fmt.Errorf("%w: env_vars[%d].key must not contain '=' or null bytes", ErrInvalidProfileEnvVars, i) + } + if strings.HasPrefix(key, reservedProfileEnvVarPrefix) || key == reservedProfileEnvVarKey { + return fmt.Errorf("%w: env_vars[%d].key %q is reserved", ErrInvalidProfileEnvVars, i, key) + } + if first, exists := seen[key]; exists { + return fmt.Errorf("%w: env_vars[%d].key duplicates env_vars[%d].key", ErrInvalidProfileEnvVars, i, first) + } + return nil +} + +func validateEnvVarValue(ev dto.ProfileEnvVarDTO, i int) error { + if ev.SecretID != "" && ev.Value != "" { + return fmt.Errorf("%w: env_vars[%d]: set value or secret_id, not both", ErrInvalidProfileEnvVars, i) + } + if ev.SecretID == "" && ev.Value == "" { + return fmt.Errorf("%w: env_vars[%d]: must set either value or secret_id", ErrInvalidProfileEnvVars, i) + } + if ev.Value != "" { + if len(ev.Value) > maxProfileEnvVarValueLen { + return fmt.Errorf("%w: env_vars[%d].value exceeds %d characters", ErrInvalidProfileEnvVars, i, maxProfileEnvVarValueLen) + } + if strings.Contains(ev.Value, "\x00") { + return fmt.Errorf("%w: env_vars[%d].value must not contain null bytes", ErrInvalidProfileEnvVars, i) + } + } + return nil +} + // resolveProfileNameForModel looks up the agent by ID, fetches its model list (using cache), // and returns the display name for the given model ID. Returns empty string on failure. func (c *Controller) resolveProfileNameForModel(ctx context.Context, agentID, modelID string) string { diff --git a/apps/backend/internal/agent/settings/controller/profile_crud_test.go b/apps/backend/internal/agent/settings/controller/profile_crud_test.go index b3364e4592..9b82da8164 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud_test.go @@ -1,10 +1,13 @@ package controller import ( + "context" + "fmt" "testing" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" ) // TestSeedCLIFlags_FromCopilot verifies that a fresh Copilot profile gets @@ -90,3 +93,88 @@ func TestValidateCLIFlagDTOs(t *testing.T) { }) } } + +func TestValidateProfileEnvVarDTOs(t *testing.T) { + cases := []struct { + name string + envVars []dto.ProfileEnvVarDTO + wantErr bool + }{ + {name: "valid value", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar"}}}, + {name: "valid secret", envVars: []dto.ProfileEnvVarDTO{{Key: "TOKEN", SecretID: "sec-1"}}}, + {name: "empty key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: ""}}, wantErr: true}, + {name: "whitespace key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: " "}}, wantErr: true}, + {name: "equals key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD=KEY", Value: "x"}}, wantErr: true}, + {name: "null key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD\x00KEY", Value: "x"}}, wantErr: true}, + {name: "duplicate key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "one"}, {Key: " FOO ", Value: "two"}}, wantErr: true}, + {name: "value and secret rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar", SecretID: "sec-1"}}, wantErr: true}, + {name: "null value rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bad\x00val"}}, wantErr: true}, + {name: "KANDEV prefix rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "KANDEV_TASK_ID", Value: "x"}}, wantErr: true}, + {name: "TASK_DESCRIPTION rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "TASK_DESCRIPTION", Value: "x"}}, wantErr: true}, + {name: "too many entries rejected", envVars: func() []dto.ProfileEnvVarDTO { + out := make([]dto.ProfileEnvVarDTO, maxProfileEnvVars+1) + for i := range out { + out[i] = dto.ProfileEnvVarDTO{Key: fmt.Sprintf("K%d", i), Value: "v"} + } + return out + }(), wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateProfileEnvVarDTOs(tc.envVars) + if tc.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestCreateProfile_PersistsEnvVars(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{ + id: "test-agent", + name: "test-agent", + displayName: "Test Agent", + enabled: true, + }}) + st := newFakeStore() + agent := &models.Agent{ID: "agent-1", Name: "test-agent"} + st.agents[agent.ID] = agent + st.byName[agent.Name] = agent + ctrl.repo = st + + profile, err := ctrl.CreateProfile(context.Background(), CreateProfileRequest{ + AgentID: "agent-1", + Name: "With env", + EnvVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar"}}, + }) + if err != nil { + t.Fatalf("CreateProfile: %v", err) + } + if len(profile.EnvVars) != 1 || profile.EnvVars[0].Key != "FOO" || profile.EnvVars[0].Value != "bar" { + t.Fatalf("response env vars: %+v", profile.EnvVars) + } + if len(st.created) != 1 || len(st.created[0].EnvVars) != 1 || st.created[0].EnvVars[0].Key != "FOO" { + t.Fatalf("stored env vars: %+v", st.created) + } +} + +func TestCreateAgentProfiles_PersistsEnvVars(t *testing.T) { + ctrl := newTestController(nil) + st := newFakeStore() + ctrl.repo = st + + profiles, err := ctrl.createAgentProfiles(context.Background(), "agent-1", "Test Agent", []CreateAgentProfileRequest{{ + Name: "With env", + Model: "model-1", + EnvVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar"}}, + }}, &testAgent{id: "test-agent", name: "test-agent"}) + if err != nil { + t.Fatalf("createAgentProfiles: %v", err) + } + if len(profiles) != 1 || len(profiles[0].EnvVars) != 1 || profiles[0].EnvVars[0].Key != "FOO" { + t.Fatalf("created env vars: %+v", profiles) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 3f4eafbc91..64940502b7 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -7,16 +7,17 @@ import ( ) type AgentProfileDTO struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - Name string `json:"name"` - AgentDisplayName string `json:"agent_display_name"` - Model string `json:"model"` - Mode string `json:"mode,omitempty"` - AllowIndexing bool `json:"allow_indexing"` // Deprecated: use CLIFlags. Retained for legacy clients. - CLIFlags []CLIFlagDTO `json:"cli_flags"` - CLIPassthrough bool `json:"cli_passthrough"` - UserModified bool `json:"user_modified"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + AgentDisplayName string `json:"agent_display_name"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + AllowIndexing bool `json:"allow_indexing"` // Deprecated: use CLIFlags. Retained for legacy clients. + CLIFlags []CLIFlagDTO `json:"cli_flags"` + EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"` + CLIPassthrough bool `json:"cli_passthrough"` + UserModified bool `json:"user_modified"` // WorkspaceID scopes the profile to an office workspace. Empty for // shallow kanban-only profiles. Surfaced so consumers (e.g. test // cleanup helpers) can distinguish office-owned profiles from @@ -38,6 +39,13 @@ type CLIFlagDTO struct { Enabled bool `json:"enabled"` } +// ProfileEnvVarDTO mirrors models.ProfileEnvVar on the wire. +type ProfileEnvVarDTO struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` + SecretID string `json:"secret_id,omitempty"` +} + type TUIConfigDTO struct { Command string `json:"command"` DisplayName string `json:"display_name"` diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 9fae20e683..f44ff068ba 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -176,10 +176,11 @@ type createAgentRequest struct { } type createAgentProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - Mode string `json:"mode,omitempty"` - CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + Name string `json:"name"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` } func (h *Handlers) httpCreateAgent(c *gin.Context) { @@ -203,6 +204,7 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) { Model: profile.Model, Mode: profile.Mode, CLIFlags: profile.CLIFlags, + EnvVars: profile.EnvVars, }) } resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{ @@ -354,12 +356,13 @@ func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) { } type createProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - Mode string `json:"mode,omitempty"` - AllowIndexing bool `json:"allow_indexing"` - CLIPassthrough bool `json:"cli_passthrough"` - CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + Name string `json:"name"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + AllowIndexing bool `json:"allow_indexing"` + CLIPassthrough bool `json:"cli_passthrough"` + CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` } func (h *Handlers) httpCreateProfile(c *gin.Context) { @@ -380,8 +383,13 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { AllowIndexing: body.AllowIndexing, CLIPassthrough: body.CLIPassthrough, CLIFlags: body.CLIFlags, + EnvVars: body.EnvVars, }) if err != nil { + if errors.Is(err, controller.ErrInvalidProfileEnvVars) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } h.logger.Error("failed to create profile", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile"}) return @@ -396,12 +404,13 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { } type updateProfileRequest struct { - Name *string `json:"name,omitempty"` - Model *string `json:"model,omitempty"` - Mode *string `json:"mode,omitempty"` - AllowIndexing *bool `json:"allow_indexing,omitempty"` - CLIPassthrough *bool `json:"cli_passthrough,omitempty"` - CLIFlags *[]dto.CLIFlagDTO `json:"cli_flags,omitempty"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + Mode *string `json:"mode,omitempty"` + AllowIndexing *bool `json:"allow_indexing,omitempty"` + CLIPassthrough *bool `json:"cli_passthrough,omitempty"` + CLIFlags *[]dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars *[]dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` } func (h *Handlers) httpUpdateProfile(c *gin.Context) { @@ -422,12 +431,17 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { AllowIndexing: body.AllowIndexing, CLIPassthrough: body.CLIPassthrough, CLIFlags: body.CLIFlags, + EnvVars: body.EnvVars, }) if err != nil { if err == controller.ErrAgentProfileNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) return } + if errors.Is(err, controller.ErrInvalidProfileEnvVars) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } h.logger.Error("failed to update profile", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"}) return diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 6c2dca4116..1559f91564 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -4,8 +4,12 @@ import ( "time" agentusage "github.com/kandev/kandev/internal/agent/usage" + taskmodels "github.com/kandev/kandev/internal/task/models" ) +// ProfileEnvVar is an environment variable entry on an agent profile. +type ProfileEnvVar = taskmodels.ProfileEnvVar + type Agent struct { ID string `json:"id"` Name string `json:"name"` @@ -69,6 +73,10 @@ type AgentProfile struct { // settings repo handles the conversion via manual scan. CLIFlags []CLIFlag `json:"cli_flags" db:"-"` + // EnvVars are injected into the agent subprocess when this profile runs. + // Stored as a JSON-encoded TEXT column; settings repo handles conversion. + EnvVars []ProfileEnvVar `json:"env_vars,omitempty" db:"-"` + // Deprecated legacy permission fields: retained in the DB schema so rows // load cleanly, but no longer read by the launch path. ACP session modes // and interactive permission_request prompts replace them. diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 25ed9a7060..aaa9407e72 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -79,6 +79,7 @@ func (r *sqliteRepository) initSchema() error { user_modified INTEGER NOT NULL DEFAULT 0, plan TEXT DEFAULT '', cli_flags TEXT DEFAULT NULL, + env_vars TEXT NOT NULL DEFAULT '[]', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, @@ -133,6 +134,7 @@ func (r *sqliteRepository) initSchema() error { r.migrate.Apply("agent_profiles.migrated_from", `ALTER TABLE agent_profiles ADD COLUMN migrated_from TEXT DEFAULT NULL`) // Rows where cli_flags IS NULL are backfilled on first read - see scanAgentProfile. r.migrate.Apply("agent_profiles.cli_flags", `ALTER TABLE agent_profiles ADD COLUMN cli_flags TEXT DEFAULT NULL`) + r.migrate.Apply("agent_profiles.env_vars", `ALTER TABLE agent_profiles ADD COLUMN env_vars TEXT NOT NULL DEFAULT '[]'`) // Migration: drop CHECK(model != '') constraint from agent_profiles. // @@ -249,10 +251,11 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { } defer func() { _ = tx.Rollback() }() - // The old table does not have cli_flags yet (the ADD COLUMN ran earlier in + // The old table does not have cli_flags/env_vars yet (the ADD COLUMN ran earlier in // initSchema but on the old table, which is about to be dropped). So we // include it in the copy only when it exists on the source table. srcHasCLIFlags := columnExists(tx, "agent_profiles", "cli_flags") + srcHasEnvVars := columnExists(tx, "agent_profiles", "env_vars") srcCols := `id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at` @@ -261,6 +264,10 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { srcCols += ", cli_flags" dstCols += ", cli_flags" } + if srcHasEnvVars { + srcCols += ", env_vars" + dstCols += ", env_vars" + } if _, err := tx.Exec(`CREATE TABLE agent_profiles_new ( id TEXT PRIMARY KEY, @@ -277,6 +284,7 @@ func (r *sqliteRepository) recreateAgentProfilesWithoutModelCheck() error { user_modified INTEGER NOT NULL DEFAULT 0, plan TEXT DEFAULT '', cli_flags TEXT DEFAULT NULL, + env_vars TEXT NOT NULL DEFAULT '[]', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, @@ -458,6 +466,10 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode if err != nil { return err } + envVarsJSON, err := envVarsToJSON(profile.EnvVars) + if err != nil { + return err + } enrich, err := enrichmentValues(profile) if err != nil { return err @@ -466,7 +478,7 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode INSERT INTO agent_profiles ( id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, - user_modified, plan, cli_flags, created_at, updated_at, deleted_at, + user_modified, plan, cli_flags, env_vars, created_at, updated_at, deleted_at, workspace_id, role, icon, reports_to, skill_ids, desired_skills, custom_prompt, status, pause_reason, last_run_finished_at, @@ -476,7 +488,7 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, '', ?, ?, ?, ?, + ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -489,7 +501,7 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode nullableString(profile.Mode), nullableString(profile.MigratedFrom), dialect.BoolToInt(profile.AutoApprove), dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), - dialect.BoolToInt(profile.UserModified), cliFlagsJSON, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt, + dialect.BoolToInt(profile.UserModified), cliFlagsJSON, envVarsJSON, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt, enrich.workspaceID, enrich.role, enrich.icon, enrich.reportsTo, enrich.skillIDs, enrich.desiredSkills, enrich.customPrompt, enrich.status, enrich.pauseReason, profile.LastRunFinishedAt, @@ -641,12 +653,27 @@ func cliFlagsToJSON(flags []models.CLIFlag) (string, error) { return string(data), nil } +func envVarsToJSON(envVars []models.ProfileEnvVar) (string, error) { + if envVars == nil { + envVars = []models.ProfileEnvVar{} + } + data, err := json.Marshal(envVars) + if err != nil { + return "", fmt.Errorf("marshal env_vars: %w", err) + } + return string(data), nil +} + func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error { profile.UpdatedAt = time.Now().UTC() cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags) if err != nil { return err } + envVarsJSON, err := envVarsToJSON(profile.EnvVars) + if err != nil { + return err + } enrich, err := enrichmentValues(profile) if err != nil { return err @@ -655,7 +682,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode UPDATE agent_profiles SET name = ?, agent_display_name = ?, model = ?, mode = ?, migrated_from = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, - cli_passthrough = ?, user_modified = ?, cli_flags = ?, updated_at = ?, + cli_passthrough = ?, user_modified = ?, cli_flags = ?, env_vars = ?, updated_at = ?, workspace_id = ?, role = ?, icon = ?, reports_to = ?, skill_ids = ?, desired_skills = ?, custom_prompt = ?, status = ?, pause_reason = ?, last_run_finished_at = ?, @@ -668,7 +695,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode nullableString(profile.Mode), nullableString(profile.MigratedFrom), dialect.BoolToInt(profile.AutoApprove), dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), - dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), cliFlagsJSON, profile.UpdatedAt, + dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), cliFlagsJSON, envVarsJSON, profile.UpdatedAt, enrich.workspaceID, enrich.role, enrich.icon, enrich.reportsTo, enrich.skillIDs, enrich.desiredSkills, enrich.customPrompt, enrich.status, enrich.pauseReason, profile.LastRunFinishedAt, @@ -707,6 +734,7 @@ func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*mod SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, cli_flags, + COALESCE(env_vars, '[]'), created_at, updated_at, deleted_at, COALESCE(workspace_id, ''), COALESCE(role, ''), COALESCE(icon, ''), COALESCE(reports_to, ''), COALESCE(skill_ids, '[]'), @@ -732,6 +760,7 @@ func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, cli_flags, + COALESCE(env_vars, '[]'), created_at, updated_at, deleted_at, COALESCE(workspace_id, ''), COALESCE(role, ''), COALESCE(icon, ''), COALESCE(reports_to, ''), COALESCE(skill_ids, '[]'), @@ -865,6 +894,7 @@ func scanAgentProfile(scanner interface { var userModified int var plan string // unused, kept for backwards compatibility var cliFlagsRaw sql.NullString + var envVarsRaw sql.NullString var role, status string var skipIdleRuns int var failureThreshold int @@ -883,6 +913,7 @@ func scanAgentProfile(scanner interface { &userModified, &plan, &cliFlagsRaw, + &envVarsRaw, &profile.CreatedAt, &profile.UpdatedAt, &profile.DeletedAt, @@ -931,6 +962,11 @@ func scanAgentProfile(scanner interface { return nil, fmt.Errorf("failed to parse cli_flags for profile %s: %w", profile.ID, err) } } + if envVarsRaw.Valid && envVarsRaw.String != "" { + if err := json.Unmarshal([]byte(envVarsRaw.String), &profile.EnvVars); err != nil { + return nil, fmt.Errorf("failed to parse env_vars for profile %s: %w", profile.ID, err) + } + } // When cli_flags is NULL the caller (GetAgentProfile / ListAgentProfiles) // runs applyLegacyBackfill to seed the list scoped to the owning agent. // Leaving CLIFlags as nil here is deliberate: non-nil vs nil discriminates diff --git a/apps/backend/internal/agent/settings/store/sqlite_envvars_test.go b/apps/backend/internal/agent/settings/store/sqlite_envvars_test.go new file mode 100644 index 0000000000..dca9266a4f --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_envvars_test.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func TestAgentProfileEnvVars_Roundtrip(t *testing.T) { + repo := newFreshRepo(t) + ctx := context.Background() + + if err := repo.CreateAgent(ctx, &models.Agent{Name: "claude-acp-env-test"}); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + agent, err := repo.GetAgentByName(ctx, "claude-acp-env-test") + if err != nil { + t.Fatalf("GetAgentByName: %v", err) + } + + profile := &models.AgentProfile{ + AgentID: agent.ID, + Name: "with-env", + AgentDisplayName: "Claude", + Model: "default", + EnvVars: []models.ProfileEnvVar{ + {Key: "ANTHROPIC_BASE_URL", Value: "https://example.test"}, + {Key: "MY_TOKEN", SecretID: "sec-abc"}, + }, + } + if err := repo.CreateAgentProfile(ctx, profile); err != nil { + t.Fatalf("CreateAgentProfile: %v", err) + } + + got, err := repo.GetAgentProfile(ctx, profile.ID) + if err != nil { + t.Fatalf("GetAgentProfile: %v", err) + } + if len(got.EnvVars) != 2 { + t.Fatalf("env_vars len: got %d", len(got.EnvVars)) + } + if got.EnvVars[0].Key != "ANTHROPIC_BASE_URL" || got.EnvVars[0].Value != "https://example.test" { + t.Fatalf("unexpected first env var: %+v", got.EnvVars[0]) + } + if got.EnvVars[1].SecretID != "sec-abc" { + t.Fatalf("unexpected secret env var: %+v", got.EnvVars[1]) + } + + got.EnvVars = []models.ProfileEnvVar{{Key: "ONLY", Value: "one"}} + if err := repo.UpdateAgentProfile(ctx, got); err != nil { + t.Fatalf("UpdateAgentProfile: %v", err) + } + updated, err := repo.GetAgentProfile(ctx, profile.ID) + if err != nil { + t.Fatalf("GetAgentProfile after update: %v", err) + } + if len(updated.EnvVars) != 1 || updated.EnvVars[0].Key != "ONLY" { + t.Fatalf("after update: %+v", updated.EnvVars) + } +} diff --git a/apps/backend/internal/agent/settings/store/sqlite_migration_test.go b/apps/backend/internal/agent/settings/store/sqlite_migration_test.go index 00642b9f74..be1c47bb0c 100644 --- a/apps/backend/internal/agent/settings/store/sqlite_migration_test.go +++ b/apps/backend/internal/agent/settings/store/sqlite_migration_test.go @@ -189,6 +189,34 @@ func TestMigration_LegacyDB_PreservesAllColumns(t *testing.T) { } } +func TestMigration_LegacyDB_PreservesEnvVarsColumn(t *testing.T) { + db := newLegacyDB(t) + ctx := context.Background() + + if _, err := db.Exec(`ALTER TABLE agent_profiles ADD COLUMN env_vars TEXT NOT NULL DEFAULT '[]'`); err != nil { + t.Fatalf("add env_vars to legacy schema: %v", err) + } + _, _ = db.Exec(`INSERT INTO agents (id, name, created_at, updated_at) VALUES ('a1', 'test-agent', datetime('now'), datetime('now'))`) + _, err := db.Exec(`INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, env_vars, created_at, updated_at) + VALUES ('p1', 'a1', 'Test Profile', 'Test', 'some-model', '[{"key":"FOO","value":"bar"}]', datetime('now'), datetime('now'))`) + if err != nil { + t.Fatalf("seed profile with env vars: %v", err) + } + + repo, err := newSQLiteRepository(db, db, nil, false) + if err != nil { + t.Fatalf("newSQLiteRepository: %v", err) + } + + profile, err := repo.GetAgentProfile(ctx, "p1") + if err != nil { + t.Fatalf("get profile: %v", err) + } + if len(profile.EnvVars) != 1 || profile.EnvVars[0].Key != "FOO" || profile.EnvVars[0].Value != "bar" { + t.Fatalf("env_vars not preserved: %+v", profile.EnvVars) + } +} + // TestMigration_LegacyDB_MCPConfigSurvives verifies that agent_profile_mcp_configs // rows (which FK-reference agent_profiles) survive the table recreation. func TestMigration_LegacyDB_MCPConfigSurvives(t *testing.T) { diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index e623f397a7..53faa75877 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -6,6 +6,7 @@ import type { AgentProfile, AgentProfileMcpConfig, CLIFlag, + ProfileEnvVar, McpServerDef, ListAgentsResponse, ListAgentDiscoveryResponse, @@ -73,6 +74,7 @@ export async function createAgentAction(payload: { mode?: string; cli_passthrough: boolean; cli_flags?: CLIFlag[]; + env_vars?: ProfileEnvVar[]; } & ProfilePermissions >; }): Promise { @@ -110,6 +112,7 @@ export async function createAgentProfileAction( mode?: string; cli_passthrough: boolean; cli_flags?: CLIFlag[]; + env_vars?: ProfileEnvVar[]; } & ProfilePermissions, ): Promise { const raw = await fetchJson(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, { @@ -128,6 +131,7 @@ export async function updateAgentProfileAction( allow_indexing?: boolean; cli_passthrough?: boolean; cli_flags?: CLIFlag[]; + env_vars?: ProfileEnvVar[]; }, ): Promise { const raw = await fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts index 02c19bf674..05397979f1 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts @@ -12,10 +12,23 @@ import type { McpServerDef, PermissionSetting, ModelConfig, + ProfileEnvVar, } from "@/lib/types/http"; import { permissionsToProfilePatch, arePermissionsDirty } from "@/lib/agent-permissions"; import { areCLIFlagsEqual } from "@/lib/cli-flags"; +function areEnvVarsEqual(a?: ProfileEnvVar[], b?: ProfileEnvVar[]): boolean { + const left = a ?? []; + const right = b ?? []; + if (left.length !== right.length) return false; + return left.every( + (ev, i) => + ev.key === right[i]?.key && + (ev.value ?? "") === (right[i]?.value ?? "") && + (ev.secret_id ?? "") === (right[i]?.secret_id ?? ""), + ); +} + type DraftMcpConfig = { enabled: boolean; servers: string; @@ -138,6 +151,7 @@ export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentC ...permissionsToProfilePatch(profile), cli_passthrough: profile.cliPassthrough ?? false, cli_flags: profile.cliFlags ?? [], + env_vars: profile.envVars ?? [], })), }); @@ -192,6 +206,7 @@ async function saveExistingProfiles( ...permissionsToProfilePatch(profile), cli_passthrough: profile.cliPassthrough ?? false, cli_flags: profile.cliFlags ?? [], + env_vars: profile.envVars ?? [], }); await saveMcpForProfile({ draftProfile: profile, @@ -209,6 +224,7 @@ async function saveExistingProfiles( ...permissionsToProfilePatch(profile), cli_passthrough: profile.cliPassthrough ?? false, cli_flags: profile.cliFlags ?? [], + env_vars: profile.envVars ?? [], }); nextProfiles.push(updatedProfile); continue; @@ -276,6 +292,7 @@ export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boole (draft.mode ?? "") !== (saved.mode ?? "") || arePermissionsDirty({ allow_indexing: draftAllow }, { allow_indexing: savedAllow }) || draft.cliPassthrough !== saved.cliPassthrough || - !areCLIFlagsEqual(draft.cliFlags ?? [], saved.cliFlags ?? []) + !areCLIFlagsEqual(draft.cliFlags ?? [], saved.cliFlags ?? []) || + !areEnvVarsEqual(draft.envVars, saved.envVars) ); } diff --git a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx index 68eebdf724..05eeccc04f 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx +++ b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx @@ -16,6 +16,8 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator"; import { ProfileFormFields } from "@/components/settings/profile-form-fields"; +import { ProfileEnvVarsSection } from "@/components/settings/agent-profile-page"; +import { CustomCLIFlagsCard } from "@/components/settings/cli-flags-field"; import type { Agent, ModelConfig, PermissionSetting, PassthroughConfig } from "@/lib/types/http"; import { ProfileMcpConfigCard } from "./profile-mcp-config-card"; import type { DraftProfile, DraftAgent } from "./agent-save-helpers"; @@ -130,6 +132,16 @@ export function ProfileCardItem({ onRemove={() => onRemoveProfile(profile.id)} canRemove={draftAgent.profiles.length > 1} lockPassthrough={Boolean(draftAgent.tui_config)} + hideCustomCLIFlags + /> + onProfileChange(profile.id, { cliFlags: next })} + permissionSettings={permissionSettings} + /> + onProfileChange(profile.id, patch)} /> ; cliPassthrough: boolean; cliFlags: CLIFlag[]; + envVars?: ProfileEnvVar[]; + secrets?: { id: string; name: string }[]; }; +/** + * POSIX-style single-quote escaping: wrap in single quotes and escape + * embedded single quotes via `'\''`. Used so the previewed env-var prefix + * is copy-pasteable into a shell. + */ +function shellQuote(value: string): string { + if (value === "") return "''"; + if (/^[A-Za-z0-9_\-./:=@]+$/.test(value)) return value; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildEnvPrefix( + envVars: ProfileEnvVar[] | undefined, + secrets: { id: string; name: string }[] | undefined, +): string { + if (!envVars || envVars.length === 0) return ""; + const secretNameById = new Map((secrets ?? []).map((s) => [s.id, s.name])); + const parts = envVars.map((ev) => { + if (ev.secret_id) { + const name = secretNameById.get(ev.secret_id) ?? "secret"; + return `${ev.key}=$${name}`; + } + return `${ev.key}=${shellQuote(ev.value ?? "")}`; + }); + return `${parts.join(" ")} `; +} + function CommandPreviewLoading() { return (
@@ -44,21 +73,23 @@ function CommandPreviewEmpty() { type CommandPreviewContentProps = { preview: CommandPreviewResponse; cliPassthrough: boolean; + envPrefix: string; }; -function CommandPreviewContent({ preview, cliPassthrough }: CommandPreviewContentProps) { +function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPreviewContentProps) { const [copied, setCopied] = useState(false); + const displayCommand = `${envPrefix}${preview.command_string ?? ""}`; const handleCopy = async () => { - if (!preview.command_string) return; + if (!displayCommand) return; try { - await navigator.clipboard.writeText(preview.command_string); + await navigator.clipboard.writeText(displayCommand); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { const textArea = document.createElement("textarea"); - textArea.value = preview.command_string; + textArea.value = displayCommand; document.body.appendChild(textArea); textArea.select(); document.execCommand("copy"); @@ -72,7 +103,7 @@ function CommandPreviewContent({ preview, cliPassthrough }: CommandPreviewConten <>
-          {preview.command_string}
+          {displayCommand}
         
); diff --git a/apps/web/components/settings/cli-flags-field.tsx b/apps/web/components/settings/cli-flags-field.tsx index f92a22fe67..ff1d1be46b 100644 --- a/apps/web/components/settings/cli-flags-field.tsx +++ b/apps/web/components/settings/cli-flags-field.tsx @@ -3,11 +3,70 @@ import { useId, useMemo, useState } from "react"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; import type { CLIFlag, PermissionSetting } from "@/lib/types/http"; +/** + * Editor-side representation of a single custom CLI flag. The persisted + * `CLIFlag.flag` string is shell-tokenised by the backend, so a single + * stored value like `--add-dir /shared` becomes two argv tokens. We + * surface that split as separate Flag + Value inputs so the user doesn't + * have to think about quoting, and re-concatenate on save. Description + * is preserved through the round-trip but no longer shown in the UI. + */ +type CustomFlagRow = { + flag: string; + value: string; + description: string; + enabled: boolean; +}; + +// Strip surrounding single or double quotes added by shellQuoteValue for display. +function stripOuterQuotes(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; +} + +// POSIX single-quote a value that contains whitespace so the backend's +// shell tokeniser treats it as one argv token. +function shellQuoteValue(v: string): string { + if (!v || !/\s/.test(v)) return v; + return `'${v.replace(/'/g, "'\\''")}'`; +} + +function flagToRow(f: CLIFlag): CustomFlagRow { + const trimmed = f.flag.trim(); + const ws = trimmed.search(/\s/); + if (ws === -1) { + return { flag: trimmed, value: "", description: f.description, enabled: f.enabled }; + } + return { + flag: trimmed.slice(0, ws), + // Strip surrounding quotes added by shellQuoteValue so the input shows the raw value. + value: stripOuterQuotes(trimmed.slice(ws + 1)), + description: f.description, + enabled: f.enabled, + }; +} + +function rowToFlag(r: CustomFlagRow): CLIFlag { + const flagText = r.flag.trim(); + // Do not trim value here — trailing spaces must survive inline editing. + // shellQuoteValue wraps values containing whitespace so the backend shell + // tokeniser produces a single argv token. + const quotedValue = shellQuoteValue(r.value); + return { + flag: quotedValue.trim() ? `${flagText} ${quotedValue}` : flagText, + description: r.description, + enabled: r.enabled, + }; +} + type CLIFlagsFieldProps = { flags: CLIFlag[]; onChange: (next: CLIFlag[]) => void; @@ -80,12 +139,11 @@ export function CLIFlagsField({ onChange([...flags, { flag: setting.flag, description: setting.description, enabled }]); }; - const toggleAt = (index: number, enabled: boolean) => { - onChange(flags.map((f, i) => (i === index ? { ...f, enabled } : f))); + const updateRowAt = (index: number, next: CLIFlag) => { + onChange(flags.map((f, i) => (i === index ? next : f))); }; const removeAt = (index: number) => onChange(flags.filter((_, i) => i !== index)); - const appendCustom = (flag: string, description: string) => - onChange([...flags, { flag, description, enabled: true }]); + const appendCustom = (next: CLIFlag) => onChange([...flags, next]); return (
@@ -100,10 +158,9 @@ export function CLIFlagsField({ {!hideCustomFlags && ( )}
@@ -175,34 +232,17 @@ function CuratedFlagsSection({ function CustomFlagsSection({ customFlags, - onToggle, + onUpdateRow, onRemove, onAdd, - compact, }: { customFlags: Array<{ flag: CLIFlag; index: number }>; - onToggle: (index: number, enabled: boolean) => void; + onUpdateRow: (index: number, next: CLIFlag) => void; onRemove: (index: number) => void; - onAdd: (flag: string, description: string) => void; - compact: boolean; + onAdd: (next: CLIFlag) => void; }) { - const labelCls = compact ? "text-xs" : undefined; return ( -
-
- - {customFlags.length > 0 && ( - - {customFlags.filter((e) => e.flag.enabled).length} of {customFlags.length} enabled - - )} -
-

- Flags passed to the agent CLI on launch. Only enabled entries are applied. Use quotes for - values with spaces, e.g.{" "} - {`--msg "hello world"`}. -

- +
{customFlags.length === 0 ? (

No CLI flags configured. Add one below. @@ -211,10 +251,10 @@ function CustomFlagsSection({

    {customFlags.map(({ flag, index }) => ( ))} @@ -228,60 +268,67 @@ function CustomFlagsSection({ function CLIFlagRow({ flag, index, - onToggle, + onUpdateRow, onRemove, }: { flag: CLIFlag; index: number; - onToggle: (i: number, enabled: boolean) => void; - onRemove: (i: number) => void; + onUpdateRow: (index: number, next: CLIFlag) => void; + onRemove: (index: number) => void; }) { + const row = flagToRow(flag); + const update = (patch: Partial) => { + onUpdateRow(index, rowToFlag({ ...row, ...patch })); + }; return ( -
  • -
    - - {flag.flag} - - {flag.description &&

    {flag.description}

    } -
    -
    - onToggle(index, checked)} - data-testid={`cli-flag-enabled-${index}`} - aria-label={`${flag.enabled ? "Disable" : "Enable"} ${flag.flag}`} - /> - -
    +
  • + update({ flag: e.target.value })} + placeholder="--my-flag" + className="flex-[2] font-mono text-xs" + data-testid={`cli-flag-flag-${index}`} + /> + update({ value: e.target.value })} + placeholder="value (optional)" + className="flex-[3] font-mono text-xs" + data-testid={`cli-flag-value-${index}`} + /> + update({ enabled: checked })} + data-testid={`cli-flag-enabled-${index}`} + aria-label={`${row.enabled ? "Disable" : "Enable"} ${row.flag}`} + /> +
  • ); } -function CLIFlagsAddForm({ onAdd }: { onAdd: (flag: string, description: string) => void }) { +function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) { const uid = useId(); const flagId = `${uid}-flag`; - const descId = `${uid}-desc`; + const valueId = `${uid}-value`; const [newFlag, setNewFlag] = useState(""); - const [newDesc, setNewDesc] = useState(""); + const [newValue, setNewValue] = useState(""); const commit = () => { const trimmed = newFlag.trim(); if (trimmed === "") return; - onAdd(trimmed, newDesc.trim()); + onAdd(rowToFlag({ flag: trimmed, value: newValue.trim(), description: "", enabled: true })); setNewFlag(""); - setNewDesc(""); + setNewValue(""); }; const onEnter = (e: React.KeyboardEvent) => { if (e.key === "Enter" && newFlag.trim() !== "") { @@ -291,7 +338,7 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (flag: string, description: string) }; return (
    -
    +
    @@ -299,21 +346,23 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (flag: string, description: string) id={flagId} value={newFlag} onChange={(e) => setNewFlag(e.target.value)} - placeholder="--my-flag or --key=value" + placeholder="--my-flag" + className="font-mono text-xs" data-testid="cli-flag-new-flag-input" onKeyDown={onEnter} />
    -
    -
    ); } + +type CustomCLIFlagsCardProps = { + flags: CLIFlag[]; + onChange: (next: CLIFlag[]) => void; + permissionSettings?: Record; +}; + +/** + * Standalone card surfacing only the user-authored (custom) CLI flags, + * with their values exposed as a dedicated input. Curated flags + * (Switch toggles derived from PermissionSettings) stay inline within + * ProfileFormFields so they sit alongside the related profile fields. + */ +export function CustomCLIFlagsCard({ + flags, + onChange, + permissionSettings, +}: CustomCLIFlagsCardProps) { + const curatedFlagTexts = useMemo( + () => new Set(extractCuratedSettings(permissionSettings).map((s) => s.flag)), + [permissionSettings], + ); + const customFlags = useMemo( + () => + flags + .map((f, i) => ({ flag: f, index: i })) + .filter((e) => !curatedFlagTexts.has(e.flag.flag)), + [flags, curatedFlagTexts], + ); + const enabledCount = customFlags.filter((e) => e.flag.enabled).length; + + const onUpdateRow = (index: number, next: CLIFlag) => { + onChange(flags.map((f, i) => (i === index ? next : f))); + }; + const onRemove = (index: number) => onChange(flags.filter((_, i) => i !== index)); + const onAdd = (next: CLIFlag) => onChange([...flags, next]); + + return ( + + +
    +
    + Agent CLI flags + + Flags passed to the agent CLI on launch. Only enabled entries are applied. + +
    + {customFlags.length > 0 && ( + + {enabledCount} of {customFlags.length} enabled + + )} +
    +
    + + + +
    + ); +} diff --git a/apps/web/components/settings/profile-edit/env-vars-card.tsx b/apps/web/components/settings/profile-edit/env-vars-card.tsx index a311eb42c8..10e7216d0e 100644 --- a/apps/web/components/settings/profile-edit/env-vars-card.tsx +++ b/apps/web/components/settings/profile-edit/env-vars-card.tsx @@ -1,10 +1,11 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useId, useState } from "react"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import type { ProfileEnvVar } from "@/lib/types/http"; @@ -36,6 +37,43 @@ export function rowsToEnvVars(rows: EnvVarRow[]): ProfileEnvVar[] { }); } +function ValueOrSecretInput({ + row, + index, + secrets, + onUpdate, +}: { + row: EnvVarRow; + index: number; + secrets: { id: string; name: string }[]; + onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void; +}) { + if (row.mode === "value") { + return ( + onUpdate(index, "value", e.target.value)} + placeholder="value" + className="flex-[3] font-mono text-xs" + /> + ); + } + return ( + + ); +} + function EnvVarRowComponent({ row, index, @@ -50,7 +88,7 @@ function EnvVarRowComponent({ onRemove: (index: number) => void; }) { return ( -
    +
  • onUpdate(index, "key", e.target.value)} @@ -66,96 +104,200 @@ function EnvVarRowComponent({ Secret - {row.mode === "value" ? ( - onUpdate(index, "value", e.target.value)} - placeholder="value" - className="flex-[3] font-mono text-xs" - /> - ) : ( - - )} + +
  • + ); +} + +function DraftValueInput({ + draft, + valueId, + secrets, + onEnter, + setDraft, +}: { + draft: EnvVarRow; + valueId: string; + secrets: { id: string; name: string }[]; + onEnter: (e: React.KeyboardEvent) => void; + setDraft: React.Dispatch>; +}) { + if (draft.mode === "value") { + return ( + setDraft((d) => ({ ...d, value: e.target.value }))} + placeholder="value" + className="font-mono text-xs" + data-testid="env-var-new-value-input" + onKeyDown={onEnter} + /> + ); + } + return ( + + ); +} + +function EnvVarAddForm({ + onAdd, + secrets, +}: { + onAdd: (row: EnvVarRow) => void; + secrets: { id: string; name: string }[]; +}) { + const uid = useId(); + const keyId = `${uid}-key`; + const modeId = `${uid}-mode`; + const valueId = `${uid}-value`; + const [draft, setDraft] = useState({ + key: "", + mode: "value", + value: "", + secretId: "", + }); + + const isAddDisabled = + draft.key.trim() === "" || (draft.mode === "secret" && draft.secretId === ""); + + const commit = useCallback(() => { + if (isAddDisabled) return; + onAdd({ ...draft, key: draft.key.trim() }); + setDraft({ key: "", mode: "value", value: "", secretId: "" }); + }, [draft, isAddDisabled, onAdd]); + + const onEnter = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isAddDisabled) { + e.preventDefault(); + commit(); + } + }; + + return ( +
    +
    + + setDraft((d) => ({ ...d, key: e.target.value }))} + placeholder="KEY" + className="font-mono text-xs" + data-testid="env-var-new-key-input" + onKeyDown={onEnter} + /> +
    +
    + + +
    +
    + + +
    +
    ); } -type EnvVarsCardProps = { +type EnvVarsFieldProps = { rows: EnvVarRow[]; secrets: { id: string; name: string }[]; - onAdd: () => void; + onAdd: (row: EnvVarRow) => void; onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void; onRemove: (index: number) => void; }; -export function EnvVarsCard({ rows, secrets, onAdd, onUpdate, onRemove }: EnvVarsCardProps) { +function EnvVarsFieldBody({ rows, secrets, onAdd, onUpdate, onRemove }: EnvVarsFieldProps) { return ( - - -
    -
    - Environment Variables - - Injected into the execution environment. Variables can reference secrets for sensitive - values. - -
    - -
    -
    - - {rows.length === 0 && ( -

    No environment variables configured.

    - )} - {rows.map((row, idx) => ( - - ))} -
    -
    +
    + {rows.length === 0 ? ( +

    + No environment variables configured. Add one below. +

    + ) : ( +
      + {rows.map((row, idx) => ( + + ))} +
    + )} + +
    ); } export function useEnvVarRows(initialEnvVars?: ProfileEnvVar[]) { const [envVarRows, setEnvVarRows] = useState(() => envVarsToRows(initialEnvVars)); - const addEnvVar = useCallback(() => { - setEnvVarRows((prev) => [...prev, { key: "", mode: "value", value: "", secretId: "" }]); + const addEnvVar = useCallback((row: EnvVarRow) => { + setEnvVarRows((prev) => [...prev, row]); }, []); const removeEnvVar = useCallback((index: number) => { @@ -168,3 +310,29 @@ export function useEnvVarRows(initialEnvVars?: ProfileEnvVar[]) { return { envVarRows, addEnvVar, removeEnvVar, updateEnvVar }; } + +export function EnvVarsCard(props: EnvVarsFieldProps) { + return ( + + +
    +
    + Environment Variables + + Injected into the execution environment. Use Secret mode for tokens and API keys; + literal values are stored in the profile JSON. + +
    + {props.rows.length > 0 && ( + + {props.rows.length} configured + + )} +
    +
    + + + +
    + ); +} diff --git a/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx b/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx new file mode 100644 index 0000000000..2757e86c5b --- /dev/null +++ b/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useSecrets } from "@/hooks/domains/settings/use-secrets"; +import type { AgentProfile, ProfileEnvVar } from "@/lib/types/http"; +import { + EnvVarsCard, + envVarsToRows, + rowsToEnvVars, + type EnvVarRow, +} from "@/components/settings/profile-edit/env-vars-card"; + +export function areEnvVarsEqual(a?: ProfileEnvVar[], b?: ProfileEnvVar[]): boolean { + const left = a ?? []; + const right = b ?? []; + if (left.length !== right.length) return false; + return left.every( + (ev, i) => + ev.key === right[i]?.key && + (ev.value ?? "") === (right[i]?.value ?? "") && + (ev.secret_id ?? "") === (right[i]?.secret_id ?? ""), + ); +} + +type ProfileEnvVarsEditorProps = { + envVars?: ProfileEnvVar[]; + secrets: { id: string; name: string }[]; + onChange: (envVars: ProfileEnvVar[]) => void; +}; + +export function ProfileEnvVarsEditor({ envVars, secrets, onChange }: ProfileEnvVarsEditorProps) { + // `synced` is what we've acknowledged from the parent (either via the prop + // or our own last emission). When the prop diverges from it we re-seed + // local row state; that's how external prop resets propagate without + // wiping in-progress draft rows on every echo. + const [synced, setSynced] = useState(envVars ?? []); + const [rows, setRows] = useState(() => envVarsToRows(envVars)); + + const incoming = envVars ?? []; + if (!areEnvVarsEqual(incoming, synced)) { + setSynced(incoming); + setRows(envVarsToRows(incoming)); + } + + const commit = useCallback( + (next: EnvVarRow[]) => { + setRows(next); + const cleaned = rowsToEnvVars(next); + if (areEnvVarsEqual(cleaned, synced)) return; + setSynced(cleaned); + onChange(cleaned); + }, + [synced, onChange], + ); + + const handleAdd = useCallback( + (row: EnvVarRow) => { + commit([...rows, row]); + }, + [rows, commit], + ); + + const handleUpdate = useCallback( + (index: number, field: keyof EnvVarRow, val: string) => { + commit(rows.map((row, i) => (i === index ? { ...row, [field]: val } : row))); + }, + [rows, commit], + ); + + const handleRemove = useCallback( + (index: number) => { + commit(rows.filter((_, i) => i !== index)); + }, + [rows, commit], + ); + + return ( + + ); +} + +type ProfileEnvVarsSectionProps = { + envVars?: ProfileEnvVar[]; + onChange: (patch: Partial) => void; +}; + +export function ProfileEnvVarsSection({ envVars, onChange }: ProfileEnvVarsSectionProps) { + const { items: secrets } = useSecrets(); + const handleChange = useCallback( + (next: ProfileEnvVar[]) => onChange({ envVars: next }), + [onChange], + ); + + return ; +} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 47398df6a4..206f8ee943 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -279,6 +279,7 @@ export class ApiClient { mode?: string; cli_passthrough?: boolean; cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>; + env_vars?: Array<{ key: string; value?: string; secret_id?: string }>; }, ): Promise<{ id: string; @@ -290,6 +291,7 @@ export class ApiClient { mode: opts.mode, cli_passthrough: opts.cli_passthrough ?? false, cli_flags: opts.cli_flags, + env_vars: opts.env_vars, }); } @@ -315,6 +317,7 @@ export class ApiClient { mode?: string; cli_passthrough?: boolean; cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>; + env_vars?: Array<{ key: string; value?: string; secret_id?: string }>; }, ): Promise { await this.request("PATCH", `/api/v1/agent-profiles/${profileId}`, patch); diff --git a/apps/web/e2e/tests/settings/agent-profile-cli-flags.spec.ts b/apps/web/e2e/tests/settings/agent-profile-cli-flags.spec.ts index fd907a11b2..a966f003ce 100644 --- a/apps/web/e2e/tests/settings/agent-profile-cli-flags.spec.ts +++ b/apps/web/e2e/tests/settings/agent-profile-cli-flags.spec.ts @@ -23,9 +23,8 @@ test.describe("Agent profile — CLI flags", () => { await testPage.goto(`/settings/agents/${agent.name}/profiles/${profileId}`); - // The CLIFlagsField component is always rendered once the profile form - // loads — regardless of whether the agent has curated suggestions. - const field = testPage.getByTestId("cli-flags-field"); + // The Agent CLI flags card is always rendered on the profile editor. + const field = testPage.getByTestId("custom-cli-flags-card"); await expect(field).toBeVisible({ timeout: 15_000 }); // Header and helper copy anchoring the section for the user. @@ -49,19 +48,19 @@ test.describe("Agent profile — CLI flags", () => { try { await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); - await expect(testPage.getByTestId("cli-flags-field")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("custom-cli-flags-card")).toBeVisible({ timeout: 15_000 }); // Add a custom flag via the UI. const flagText = "--my-custom-flag=value"; await testPage.getByTestId("cli-flag-new-flag-input").fill(flagText); - await testPage.getByTestId("cli-flag-new-desc-input").fill("My custom flag for testing"); await testPage.getByTestId("cli-flag-add-button").click(); - // The row should appear immediately, enabled by default. The row's - // positional index depends on how many curated flags the agent seeded - // at profile creation, so locate the new row by its flag text rather - // than by position. - await expect(testPage.getByTestId("cli-flags-list")).toContainText(flagText); + // The row should appear immediately, enabled by default. Flag text lives + // in an element; use toHaveValue on the last flag-input because + // the newly added flag is always appended last. + await expect(testPage.locator('[data-testid^="cli-flag-flag-"]').last()).toHaveValue( + flagText, + ); // Save via the dirty-state save button; wait for unsaved badge to clear. const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); @@ -71,8 +70,10 @@ test.describe("Agent profile — CLI flags", () => { // Reload — the row must still be there. await testPage.reload(); - await expect(testPage.getByTestId("cli-flags-field")).toBeVisible({ timeout: 15_000 }); - await expect(testPage.getByTestId("cli-flags-list")).toContainText(flagText); + await expect(testPage.getByTestId("custom-cli-flags-card")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.locator('[data-testid^="cli-flag-flag-"]').last()).toHaveValue( + flagText, + ); // Direct DB-path verification: fetch the profile via the API and assert // the cli_flags JSON contains our entry, enabled. @@ -80,7 +81,6 @@ test.describe("Agent profile — CLI flags", () => { const found = stored.cli_flags.find((f) => f.flag === flagText); expect(found, `cli_flags should include ${flagText}`).toBeDefined(); expect(found?.enabled).toBe(true); - expect(found?.description).toBe("My custom flag for testing"); } finally { await apiClient.deleteAgentProfile(profile.id, true); } @@ -101,7 +101,7 @@ test.describe("Agent profile — CLI flags", () => { try { await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); - await expect(testPage.getByTestId("cli-flags-field")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("custom-cli-flags-card")).toBeVisible({ timeout: 15_000 }); // The first row should be enabled; toggle it off. const toggle = testPage.getByTestId("cli-flag-enabled-0"); @@ -146,7 +146,7 @@ test.describe("Agent profile — CLI flags", () => { try { await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); - await expect(testPage.getByTestId("cli-flags-field")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("custom-cli-flags-card")).toBeVisible({ timeout: 15_000 }); // Two rows present initially. await expect(testPage.getByTestId("cli-flag-row-0")).toBeVisible(); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.test.ts b/apps/web/lib/api/domains/agent-profile-normalize.test.ts index 491ad05fc6..c569f4ba92 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.test.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { normalizeAgentProfile, toAgentProfilePayload } from "./agent-profile-normalize"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; +const sampleEnvVar = { key: "ANTHROPIC_BASE_URL", value: "https://api.example" }; + describe("normalizeAgentProfile", () => { it("converts snake_case wire payload to canonical camelCase", () => { const wire = { @@ -13,6 +15,7 @@ describe("normalizeAgentProfile", () => { mode: "acp", allow_indexing: true, cli_flags: [{ flag: "--verbose", description: "v", enabled: true }], + env_vars: [sampleEnvVar], cli_passthrough: false, user_modified: true, created_at: "2026-01-01T00:00:00Z", @@ -28,6 +31,7 @@ describe("normalizeAgentProfile", () => { mode: "acp", allowIndexing: true, cliFlags: [{ flag: "--verbose", description: "v", enabled: true }], + envVars: [sampleEnvVar], cliPassthrough: false, userModified: true, createdAt: "2026-01-01T00:00:00Z", @@ -38,6 +42,7 @@ describe("normalizeAgentProfile", () => { it("falls back to safe defaults for missing fields", () => { const result = normalizeAgentProfile({ id: "x", name: "y" }); expect(result.cliFlags).toEqual([]); + expect(result.envVars).toEqual([]); expect(result.cliPassthrough).toBe(false); expect(result.allowIndexing).toBe(false); expect(result.agentDisplayName).toBe(""); @@ -63,6 +68,7 @@ describe("toAgentProfilePayload", () => { name: "default", cliPassthrough: false, cliFlags: [], + envVars: [sampleEnvVar], }); expect(payload).toEqual({ id: "p1", @@ -70,6 +76,7 @@ describe("toAgentProfilePayload", () => { name: "default", cli_passthrough: false, cli_flags: [], + env_vars: [sampleEnvVar], }); }); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index e265678b0f..2041f49417 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -6,6 +6,7 @@ // Wired via thin wrappers around the server actions / WS payloads in // `app/actions/agents.ts` and `lib/ws/handlers/agents.ts`. +import type { ProfileEnvVar } from "@/lib/types/http"; import type { AgentProfile, AgentProfilePayload, CLIFlag } from "@/lib/types/agent-profile"; import { agentProfileId } from "@/lib/types/ids"; @@ -26,6 +27,11 @@ function pickFlags(raw: RawProfile): CLIFlag[] { return Array.isArray(value) ? (value as CLIFlag[]) : []; } +function pickEnvVars(raw: RawProfile): ProfileEnvVar[] { + const value = raw.envVars ?? raw.env_vars; + return Array.isArray(value) ? (value as ProfileEnvVar[]) : []; +} + /** * Convert a kanban snake_case payload (or a partially-camelCased one) into * the canonical `AgentProfile`. Office-orchestration fields are left @@ -42,6 +48,7 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile { mode: (profile.mode as string | undefined) ?? undefined, allowIndexing: pickBool(profile, "allowIndexing", "allow_indexing"), cliFlags: pickFlags(profile), + envVars: pickEnvVars(profile), cliPassthrough: pickBool(profile, "cliPassthrough", "cli_passthrough"), userModified: (profile.userModified ?? profile.user_modified) as boolean | undefined, createdAt: pickString(profile, "createdAt", "created_at"), @@ -65,6 +72,7 @@ export function toAgentProfilePayload( if (profile.mode !== undefined) payload.mode = profile.mode; if (profile.allowIndexing !== undefined) payload.allow_indexing = profile.allowIndexing; if (profile.cliFlags !== undefined) payload.cli_flags = profile.cliFlags; + if (profile.envVars !== undefined) payload.env_vars = profile.envVars; if (profile.cliPassthrough !== undefined) payload.cli_passthrough = profile.cliPassthrough; if (profile.userModified !== undefined) payload.user_modified = profile.userModified; if (profile.createdAt !== undefined) payload.created_at = profile.createdAt; diff --git a/apps/web/lib/types/agent-profile.ts b/apps/web/lib/types/agent-profile.ts index 6f9badc9fd..ed05921e4d 100644 --- a/apps/web/lib/types/agent-profile.ts +++ b/apps/web/lib/types/agent-profile.ts @@ -1,3 +1,4 @@ +import type { ProfileEnvVar } from "./http"; import type { AgentProfileId, WorkspaceId } from "./ids"; // Canonical AgentProfile (ADR 0005, Wave E): @@ -68,6 +69,8 @@ export type AgentProfile = { allowIndexing: boolean; /** User-configurable CLI flags passed to the agent subprocess. */ cliFlags: CLIFlag[]; + /** Environment variables injected when this profile starts an agent session. */ + envVars?: ProfileEnvVar[]; cliPassthrough: boolean; userModified?: boolean; @@ -135,6 +138,7 @@ export type AgentProfilePayload = { mode?: string; allow_indexing: boolean; cli_flags: CLIFlag[]; + env_vars?: ProfileEnvVar[]; cli_passthrough: boolean; user_modified?: boolean; created_at: string;