feat: add per-agent-profile environment variables (#1040)

This commit is contained in:
Evgeny Klinnikov
2026-05-25 10:14:45 +03:00
committed by GitHub
parent d86481142d
commit 90c652c8e2
33 changed files with 1476 additions and 247 deletions
@@ -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)
@@ -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
@@ -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 != "" {
@@ -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
@@ -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
@@ -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
}
@@ -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"])
}
}
@@ -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,
@@ -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.
@@ -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
@@ -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 {
@@ -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 {
@@ -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)
}
}
+18 -10
View File
@@ -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"`
@@ -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
@@ -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.
@@ -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
@@ -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)
}
}
@@ -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) {
+4
View File
@@ -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<Agent> {
@@ -110,6 +112,7 @@ export async function createAgentProfileAction(
mode?: string;
cli_passthrough: boolean;
cli_flags?: CLIFlag[];
env_vars?: ProfileEnvVar[];
} & ProfilePermissions,
): Promise<AgentProfile> {
const raw = await fetchJson<unknown>(`${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<AgentProfile> {
const raw = await fetchJson<unknown>(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, {
@@ -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)
);
}
@@ -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
/>
<CustomCLIFlagsCard
flags={profile.cliFlags ?? []}
onChange={(next) => onProfileChange(profile.id, { cliFlags: next })}
permissionSettings={permissionSettings}
/>
<ProfileEnvVarsSection
envVars={profile.envVars}
onChange={(patch) => onProfileChange(profile.id, patch)}
/>
<ProfileMcpConfigCard
profileId={profile.id}
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { Skeleton } from "@kandev/ui/skeleton";
import { previewAgentCommandAction, type CommandPreviewResponse } from "@/app/actions/agents";
import type { CLIFlag } from "@/lib/types/http";
import type { CLIFlag, ProfileEnvVar } from "@/lib/types/http";
type CommandPreviewCardProps = {
agentName: string;
@@ -14,8 +14,37 @@ type CommandPreviewCardProps = {
permissionSettings: Record<string, boolean>;
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 (
<div className="space-y-2">
@@ -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
<>
<div className="relative">
<pre className="overflow-x-auto rounded-md bg-muted p-4 pr-12 font-mono text-xs">
<code className="whitespace-pre-wrap break-all">{preview.command_string}</code>
<code className="whitespace-pre-wrap break-all">{displayCommand}</code>
</pre>
<Button
variant="ghost"
@@ -105,7 +136,10 @@ export function CommandPreviewCard({
permissionSettings,
cliPassthrough,
cliFlags,
envVars,
secrets,
}: CommandPreviewCardProps) {
const envPrefix = useMemo(() => buildEnvPrefix(envVars, secrets), [envVars, secrets]);
const [preview, setPreview] = useState<CommandPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -157,7 +191,11 @@ export function CommandPreviewCard({
{loading && <CommandPreviewLoading />}
{error && <CommandPreviewError error={error} />}
{!loading && !error && preview && (
<CommandPreviewContent preview={preview} cliPassthrough={cliPassthrough} />
<CommandPreviewContent
preview={preview}
cliPassthrough={cliPassthrough}
envPrefix={envPrefix}
/>
)}
{!loading && !error && !preview && <CommandPreviewEmpty />}
</CardContent>
@@ -0,0 +1,43 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useState } from "react";
import { ProfileEnvVarsEditor } from "@/components/settings/agent-profile-page";
import type { ProfileEnvVar } from "@/lib/types/http";
afterEach(cleanup);
function EnvVarsHarness({ initialEnvVars = [] }: { initialEnvVars?: ProfileEnvVar[] }) {
const [envVars, setEnvVars] = useState<ProfileEnvVar[]>(initialEnvVars);
const [changeCount, setChangeCount] = useState(0);
return (
<>
<ProfileEnvVarsEditor
envVars={envVars}
secrets={[]}
onChange={(nextEnvVars) => {
setChangeCount((count) => count + 1);
setEnvVars(nextEnvVars);
}}
/>
<span data-testid="change-count">{changeCount}</span>
</>
);
}
describe("ProfileEnvVarsEditor", () => {
it("does not emit unchanged env vars on mount", () => {
render(<EnvVarsHarness initialEnvVars={[{ key: "FOO", value: "bar" }]} />);
expect(screen.getByTestId("change-count").textContent).toBe("0");
});
it("emits exactly one change when adding a row via the add form", async () => {
render(<EnvVarsHarness />);
fireEvent.change(screen.getByTestId("env-var-new-key-input"), { target: { value: "FOO" } });
fireEvent.click(screen.getByTestId("env-var-add-button"));
await waitFor(() => expect(screen.getByTestId("change-count").textContent).toBe("1"));
});
});
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { IconTrash } from "@tabler/icons-react";
@@ -18,6 +18,17 @@ import {
AgentProfileDeleteConfirmDialog,
AgentProfileDeleteConflictDialog,
} from "@/components/settings/agent-profile-delete-dialog";
import {
ProfileEnvVarsSection,
areEnvVarsEqual,
} from "@/components/settings/profile-edit/profile-env-vars-section";
import { CustomCLIFlagsCard } from "@/components/settings/cli-flags-field";
export {
ProfileEnvVarsEditor,
ProfileEnvVarsSection,
} from "@/components/settings/profile-edit/profile-env-vars-section";
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
import type {
Agent,
AgentProfile,
@@ -153,6 +164,7 @@ function ProfileSettingsCard({
passthroughConfig={passthroughConfig}
agentName={agent.name}
lockPassthrough={Boolean(agent.tui_config)}
hideCustomCLIFlags
/>
</CardContent>
</Card>
@@ -201,7 +213,8 @@ function useProfileEditorState(profile: AgentProfile) {
(draft.mode ?? "") !== (savedProfile.mode ?? "") ||
draft.allowIndexing !== savedProfile.allowIndexing ||
draft.cliPassthrough !== savedProfile.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []),
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []) ||
!areEnvVarsEqual(draft.envVars, savedProfile.envVars),
[draft, savedProfile],
);
@@ -255,6 +268,7 @@ function useProfileSave({
allow_indexing: draft.allowIndexing,
cli_passthrough: draft.cliPassthrough,
cli_flags: draft.cliFlags,
env_vars: draft.envVars ?? [],
});
setSavedProfile(updated);
setDraft(updated);
@@ -341,6 +355,106 @@ function useProfileDelete(
};
}
type ProfileDeleteDialogsProps = {
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
handleDeleteProfile: () => void;
conflictSessions: ActiveSessionInfo[] | null;
setConflictSessions: (sessions: ActiveSessionInfo[] | null) => void;
handleForceDelete: () => void;
};
function ProfileDeleteDialogs({
showDeleteConfirm,
setShowDeleteConfirm,
handleDeleteProfile,
conflictSessions,
setConflictSessions,
handleForceDelete,
}: ProfileDeleteDialogsProps) {
return (
<>
<AgentProfileDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={(open) => {
if (!open) setShowDeleteConfirm(false);
}}
onConfirm={handleDeleteProfile}
/>
<AgentProfileDeleteConflictDialog
activeSessions={conflictSessions}
onOpenChange={(open) => {
if (!open) setConflictSessions(null);
}}
onConfirm={handleForceDelete}
/>
</>
);
}
type ProfileEditorBodyProps = {
agent: Agent;
draft: AgentProfile;
updateDraft: (patch: Partial<AgentProfile>) => void;
modelConfig: ModelConfig;
permissionSettings: Record<string, PermissionSetting>;
passthroughConfig: PassthroughConfig | null;
secrets: { id: string; name: string }[];
initialMcpConfig?: AgentProfileMcpConfig | null;
onToastError: (error: unknown) => void;
};
function ProfileEditorBody({
agent,
draft,
updateDraft,
modelConfig,
permissionSettings,
passthroughConfig,
secrets,
initialMcpConfig,
onToastError,
}: ProfileEditorBodyProps) {
return (
<>
<ProfileSettingsCard
agent={agent}
draft={draft}
onDraftChange={updateDraft}
modelConfig={modelConfig}
permissionSettings={permissionSettings}
passthroughConfig={passthroughConfig}
/>
<CustomCLIFlagsCard
flags={draft.cliFlags ?? []}
onChange={(next) => updateDraft({ cliFlags: next })}
permissionSettings={permissionSettings}
/>
<ProfileEnvVarsSection envVars={draft.envVars} onChange={updateDraft} />
<CommandPreviewCard
agentName={agent.name}
model={draft.model}
permissionSettings={{ allow_indexing: draft.allowIndexing }}
cliPassthrough={draft.cliPassthrough}
cliFlags={draft.cliFlags ?? []}
envVars={draft.envVars}
secrets={secrets}
/>
<ProfileMcpConfigCard
profileId={draft.id}
supportsMcp={agent.supports_mcp}
initialConfig={initialMcpConfig}
onToastError={onToastError}
/>
</>
);
}
function ProfileEditor({
agent,
profile,
@@ -352,8 +466,23 @@ function ProfileEditor({
const { toast } = useToast();
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
const syncAgentsToStore = useSyncAgentsToStore();
const { items: secrets } = useSecrets();
const { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty } =
useProfileEditorState(profile);
const updateDraft = useCallback(
(patch: Partial<AgentProfile>) => {
setDraft((current) => {
if (patch.envVars !== undefined && areEnvVarsEqual(patch.envVars, current.envVars)) {
// envVars unchanged — apply the rest of the patch (if any) but skip envVars.
const { envVars: _ignored, ...rest } = patch;
if (Object.keys(rest).length === 0) return current;
return { ...current, ...rest };
}
return { ...current, ...patch };
});
},
[setDraft],
);
const handleSave = useProfileSave({
agent,
draft,
@@ -388,29 +517,15 @@ function ProfileEditor({
<Separator />
<ProfileSettingsCard
<ProfileEditorBody
agent={agent}
draft={draft}
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
updateDraft={updateDraft}
modelConfig={modelConfig}
permissionSettings={permissionSettings}
passthroughConfig={passthroughConfig}
/>
<CommandPreviewCard
agentName={agent.name}
model={draft.model}
permissionSettings={{
allow_indexing: draft.allowIndexing,
}}
cliPassthrough={draft.cliPassthrough}
cliFlags={draft.cliFlags ?? []}
/>
<ProfileMcpConfigCard
profileId={profile.id}
supportsMcp={agent.supports_mcp}
initialConfig={initialMcpConfig}
secrets={secrets}
initialMcpConfig={initialMcpConfig}
onToastError={(error) =>
toast({
title: "Failed to save MCP config",
@@ -422,20 +537,13 @@ function ProfileEditor({
<DeleteProfileCard onDelete={requestDelete} />
<AgentProfileDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={(open) => {
if (!open) setShowDeleteConfirm(false);
}}
onConfirm={handleDeleteProfile}
/>
<AgentProfileDeleteConflictDialog
activeSessions={conflictSessions}
onOpenChange={(open) => {
if (!open) setConflictSessions(null);
}}
onConfirm={handleForceDelete}
<ProfileDeleteDialogs
showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm}
handleDeleteProfile={handleDeleteProfile}
conflictSessions={conflictSessions}
setConflictSessions={setConflictSessions}
handleForceDelete={handleForceDelete}
/>
</div>
);
+191 -77
View File
@@ -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 (
<div className={isCompact ? "space-y-3" : "space-y-4"} data-testid="cli-flags-field">
@@ -100,10 +158,9 @@ export function CLIFlagsField({
{!hideCustomFlags && (
<CustomFlagsSection
customFlags={customFlags}
onToggle={toggleAt}
onUpdateRow={updateRowAt}
onRemove={removeAt}
onAdd={appendCustom}
compact={isCompact}
/>
)}
</div>
@@ -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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className={labelCls}>Agent CLI flags</Label>
{customFlags.length > 0 && (
<span className="text-[10px] text-muted-foreground" data-testid="cli-flags-count">
{customFlags.filter((e) => e.flag.enabled).length} of {customFlags.length} enabled
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Flags passed to the agent CLI on launch. Only enabled entries are applied. Use quotes for
values with spaces, e.g.{" "}
<code className="bg-muted px-1 rounded">{`--msg "hello world"`}</code>.
</p>
<div className="space-y-3">
{customFlags.length === 0 ? (
<p className="text-xs italic text-muted-foreground" data-testid="cli-flags-empty">
No CLI flags configured. Add one below.
@@ -211,10 +251,10 @@ function CustomFlagsSection({
<ul className="space-y-2" data-testid="cli-flags-list">
{customFlags.map(({ flag, index }) => (
<CLIFlagRow
key={`${flag.flag}-${index}`}
key={index}
flag={flag}
index={index}
onToggle={onToggle}
onUpdateRow={onUpdateRow}
onRemove={onRemove}
/>
))}
@@ -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<CustomFlagRow>) => {
onUpdateRow(index, rowToFlag({ ...row, ...patch }));
};
return (
<li
className="flex items-start justify-between gap-3 rounded-md border p-3"
data-testid={`cli-flag-row-${index}`}
>
<div className="flex-1 min-w-0 space-y-1">
<code className="text-sm font-semibold break-all" data-testid={`cli-flag-text-${index}`}>
{flag.flag}
</code>
{flag.description && <p className="text-xs text-muted-foreground">{flag.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={flag.enabled}
onCheckedChange={(checked) => onToggle(index, checked)}
data-testid={`cli-flag-enabled-${index}`}
aria-label={`${flag.enabled ? "Disable" : "Enable"} ${flag.flag}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="cursor-pointer"
data-testid={`cli-flag-remove-${index}`}
aria-label={`Remove ${flag.flag}`}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
<li className="flex items-center gap-2" data-testid={`cli-flag-row-${index}`}>
<Input
value={row.flag}
onChange={(e) => update({ flag: e.target.value })}
placeholder="--my-flag"
className="flex-[2] font-mono text-xs"
data-testid={`cli-flag-flag-${index}`}
/>
<Input
value={row.value}
onChange={(e) => update({ value: e.target.value })}
placeholder="value (optional)"
className="flex-[3] font-mono text-xs"
data-testid={`cli-flag-value-${index}`}
/>
<Switch
checked={row.enabled}
onCheckedChange={(checked) => update({ enabled: checked })}
data-testid={`cli-flag-enabled-${index}`}
aria-label={`${row.enabled ? "Disable" : "Enable"} ${row.flag}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="h-8 w-8 shrink-0 cursor-pointer"
data-testid={`cli-flag-remove-${index}`}
aria-label={`Remove ${row.flag || "flag"}`}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</li>
);
}
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<HTMLInputElement>) => {
if (e.key === "Enter" && newFlag.trim() !== "") {
@@ -291,7 +338,7 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (flag: string, description: string)
};
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1">
<div className="flex-[2] space-y-1">
<Label className="text-xs" htmlFor={flagId}>
Flag
</Label>
@@ -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}
/>
</div>
<div className="flex-1 space-y-1">
<Label className="text-xs" htmlFor={descId}>
Description (optional)
<div className="flex-[3] space-y-1">
<Label className="text-xs" htmlFor={valueId}>
Value (optional)
</Label>
<Input
id={descId}
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="What this flag does"
data-testid="cli-flag-new-desc-input"
id={valueId}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="value"
className="font-mono text-xs"
data-testid="cli-flag-new-value-input"
onKeyDown={onEnter}
/>
</div>
@@ -326,9 +375,74 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (flag: string, description: string)
className="cursor-pointer"
data-testid="cli-flag-add-button"
>
<IconPlus className="h-4 w-4 mr-1" />
<IconPlus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
);
}
type CustomCLIFlagsCardProps = {
flags: CLIFlag[];
onChange: (next: CLIFlag[]) => void;
permissionSettings?: Record<string, PermissionSetting>;
};
/**
* 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 (
<Card data-testid="custom-cli-flags-card">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Agent CLI flags</CardTitle>
<CardDescription>
Flags passed to the agent CLI on launch. Only enabled entries are applied.
</CardDescription>
</div>
{customFlags.length > 0 && (
<span className="text-[10px] text-muted-foreground" data-testid="cli-flags-count">
{enabledCount} of {customFlags.length} enabled
</span>
)}
</div>
</CardHeader>
<CardContent>
<CustomFlagsSection
customFlags={customFlags}
onUpdateRow={onUpdateRow}
onRemove={onRemove}
onAdd={onAdd}
/>
</CardContent>
</Card>
);
}
@@ -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 (
<Input
value={row.value}
onChange={(e) => onUpdate(index, "value", e.target.value)}
placeholder="value"
className="flex-[3] font-mono text-xs"
/>
);
}
return (
<Select value={row.secretId} onValueChange={(v) => onUpdate(index, "secretId", v)}>
<SelectTrigger className="flex-[3] text-xs">
<SelectValue placeholder="Select secret..." />
</SelectTrigger>
<SelectContent>
{secrets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function EnvVarRowComponent({
row,
index,
@@ -50,7 +88,7 @@ function EnvVarRowComponent({
onRemove: (index: number) => void;
}) {
return (
<div className="flex items-start gap-2">
<li className="flex items-center gap-2" data-testid={`env-var-row-${index}`}>
<Input
value={row.key}
onChange={(e) => onUpdate(index, "key", e.target.value)}
@@ -66,96 +104,200 @@ function EnvVarRowComponent({
<SelectItem value="secret">Secret</SelectItem>
</SelectContent>
</Select>
{row.mode === "value" ? (
<Input
value={row.value}
onChange={(e) => onUpdate(index, "value", e.target.value)}
placeholder="value"
className="flex-[3] font-mono text-xs"
/>
) : (
<Select value={row.secretId} onValueChange={(v) => onUpdate(index, "secretId", v)}>
<SelectTrigger className="flex-[3] text-xs">
<SelectValue placeholder="Select secret..." />
</SelectTrigger>
<SelectContent>
{secrets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ValueOrSecretInput row={row} index={index} secrets={secrets} onUpdate={onUpdate} />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="h-9 w-9 shrink-0 cursor-pointer"
className="h-8 w-8 shrink-0 cursor-pointer"
data-testid={`env-var-remove-${index}`}
aria-label={`Remove ${row.key || "env var"}`}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</li>
);
}
function DraftValueInput({
draft,
valueId,
secrets,
onEnter,
setDraft,
}: {
draft: EnvVarRow;
valueId: string;
secrets: { id: string; name: string }[];
onEnter: (e: React.KeyboardEvent<HTMLInputElement>) => void;
setDraft: React.Dispatch<React.SetStateAction<EnvVarRow>>;
}) {
if (draft.mode === "value") {
return (
<Input
id={valueId}
value={draft.value}
onChange={(e) => setDraft((d) => ({ ...d, value: e.target.value }))}
placeholder="value"
className="font-mono text-xs"
data-testid="env-var-new-value-input"
onKeyDown={onEnter}
/>
);
}
return (
<Select value={draft.secretId} onValueChange={(v) => setDraft((d) => ({ ...d, secretId: v }))}>
<SelectTrigger id={valueId} className="text-xs" data-testid="env-var-new-secret-select">
<SelectValue placeholder="Select secret..." />
</SelectTrigger>
<SelectContent>
{secrets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
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<EnvVarRow>({
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<HTMLInputElement>) => {
if (e.key === "Enter" && !isAddDisabled) {
e.preventDefault();
commit();
}
};
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex-[2] space-y-1">
<Label className="text-xs" htmlFor={keyId}>
Key
</Label>
<Input
id={keyId}
value={draft.key}
onChange={(e) => setDraft((d) => ({ ...d, key: e.target.value }))}
placeholder="KEY"
className="font-mono text-xs"
data-testid="env-var-new-key-input"
onKeyDown={onEnter}
/>
</div>
<div className="space-y-1">
<Label className="text-xs" htmlFor={modeId}>
Mode
</Label>
<Select
value={draft.mode}
onValueChange={(v) =>
setDraft((d) => ({ ...d, mode: v as "value" | "secret", value: "", secretId: "" }))
}
>
<SelectTrigger id={modeId} className="w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="value">Value</SelectItem>
<SelectItem value="secret">Secret</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-[3] space-y-1">
<Label className="text-xs" htmlFor={valueId}>
{draft.mode === "value" ? "Value" : "Secret"}
</Label>
<DraftValueInput
draft={draft}
valueId={valueId}
secrets={secrets}
onEnter={onEnter}
setDraft={setDraft}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={commit}
disabled={isAddDisabled}
className="cursor-pointer"
data-testid="env-var-add-button"
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
);
}
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>
Injected into the execution environment. Variables can reference secrets for sensitive
values.
</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onAdd}
className="cursor-pointer"
>
<IconPlus className="mr-1 h-3.5 w-3.5" />
Add
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{rows.length === 0 && (
<p className="text-sm text-muted-foreground">No environment variables configured.</p>
)}
{rows.map((row, idx) => (
<EnvVarRowComponent
key={idx}
row={row}
index={idx}
secrets={secrets}
onUpdate={onUpdate}
onRemove={onRemove}
/>
))}
</CardContent>
</Card>
<div className="space-y-3" data-testid="env-vars-field">
{rows.length === 0 ? (
<p className="text-xs italic text-muted-foreground" data-testid="env-vars-empty">
No environment variables configured. Add one below.
</p>
) : (
<ul className="space-y-2" data-testid="env-vars-list">
{rows.map((row, idx) => (
<EnvVarRowComponent
key={idx}
row={row}
index={idx}
secrets={secrets}
onUpdate={onUpdate}
onRemove={onRemove}
/>
))}
</ul>
)}
<EnvVarAddForm onAdd={onAdd} secrets={secrets} />
</div>
);
}
export function useEnvVarRows(initialEnvVars?: ProfileEnvVar[]) {
const [envVarRows, setEnvVarRows] = useState<EnvVarRow[]>(() => 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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>
Injected into the execution environment. Use Secret mode for tokens and API keys;
literal values are stored in the profile JSON.
</CardDescription>
</div>
{props.rows.length > 0 && (
<span className="text-[10px] text-muted-foreground" data-testid="env-vars-count">
{props.rows.length} configured
</span>
)}
</div>
</CardHeader>
<CardContent>
<EnvVarsFieldBody {...props} />
</CardContent>
</Card>
);
}
@@ -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<ProfileEnvVar[]>(envVars ?? []);
const [rows, setRows] = useState<EnvVarRow[]>(() => 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 (
<EnvVarsCard
rows={rows}
secrets={secrets}
onAdd={handleAdd}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
);
}
type ProfileEnvVarsSectionProps = {
envVars?: ProfileEnvVar[];
onChange: (patch: Partial<AgentProfile>) => void;
};
export function ProfileEnvVarsSection({ envVars, onChange }: ProfileEnvVarsSectionProps) {
const { items: secrets } = useSecrets();
const handleChange = useCallback(
(next: ProfileEnvVar[]) => onChange({ envVars: next }),
[onChange],
);
return <ProfileEnvVarsEditor envVars={envVars} secrets={secrets} onChange={handleChange} />;
}
+3
View File
@@ -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<void> {
await this.request("PATCH", `/api/v1/agent-profiles/${profileId}`, patch);
@@ -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 <input> 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();
@@ -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],
});
});
@@ -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;
+4
View File
@@ -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;