feat: add per-agent-profile environment variables (#1040)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+45
-7
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user