feat(agents): add configurable command prefix for sandboxed ACP launch (#1888)

This commit is contained in:
Mathieu Virbel
2026-07-24 03:29:37 -06:00
committed by GitHub
parent 2751b932df
commit b4239d885e
34 changed files with 1040 additions and 84 deletions
@@ -154,6 +154,11 @@ type CommandOptions struct {
// AgentProfile.CLIFlags (only Enabled entries, shell-tokenised). Appended
// verbatim to the built command by every agent's BuildCommand.
CLIFlagTokens []string
// CommandPrefixTokens are user-configured launcher tokens derived from
// AgentProfile.CommandPrefix (shell-tokenised). Prepended to the built
// command so the agent subprocess runs wrapped in a sandbox launcher
// (e.g. ["greywall", "--"] → "greywall -- npx -y <pkg> …").
CommandPrefixTokens []string
// Runtime is the execution backend hosting the agent subprocess.
// Agents whose binary lives in a different place inside a container
// than on the host (currently only MockAgent) consult
@@ -24,10 +24,24 @@ func NewCommandBuilder() *CommandBuilder {
// each BuildCommand needing to remember to opt in.
func (cb *CommandBuilder) BuildCommand(ag agents.Agent, opts agents.CommandOptions) agents.Command {
cmd := ag.BuildCommand(opts)
if len(opts.CLIFlagTokens) == 0 {
if len(opts.CLIFlagTokens) > 0 {
cmd = cmd.With().Flag(opts.CLIFlagTokens...).Build()
}
return prependCommandPrefix(cmd, opts.CommandPrefixTokens)
}
// prependCommandPrefix wraps the built command in a launcher prefix so the
// agent subprocess runs under a sandbox (e.g. ["greywall", "--"] turns
// "npx -y <pkg>" into "greywall -- npx -y <pkg>"). Returns cmd unchanged when
// there is no prefix.
func prependCommandPrefix(cmd agents.Command, prefixTokens []string) agents.Command {
if len(prefixTokens) == 0 {
return cmd
}
return cmd.With().Flag(opts.CLIFlagTokens...).Build()
args := make([]string, 0, len(prefixTokens)+len(cmd.Args()))
args = append(args, prefixTokens...)
args = append(args, cmd.Args()...)
return agents.NewCommand(args...)
}
// BuildCommandString builds a command as a single string (for standalone mode)
@@ -52,6 +66,7 @@ func (cb *CommandBuilder) BuildContinueCommandString(ag agents.Agent, opts agent
Settings(ag.PermissionSettings(), opts.PermissionValues).
Flag(opts.CLIFlagTokens...).
Build()
cmd = prependCommandPrefix(cmd, opts.CommandPrefixTokens)
return strings.Join(cmd.Args(), " ")
}
@@ -657,8 +657,13 @@ func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) e
// Continue — the process may already be stopped
}
// 3. Rebuild agent command without resume flags and reset execution state
freshCmd, freshContinueCmd := m.buildFreshAgentCommand(ctx, execution, agentConfig)
// 3. Rebuild agent command without resume flags and reset execution state.
// Fail closed: if the launcher prefix can't be resolved, abort the restart
// rather than relaunch a sandboxed agent unwrapped.
freshCmd, freshContinueCmd, err := m.buildFreshAgentCommand(ctx, execution, agentConfig)
if err != nil {
return fmt.Errorf("failed to rebuild agent command for restart: %w", err)
}
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
exec.ACPSessionID = ""
exec.Status = v1.AgentStatusStarting
@@ -1492,13 +1497,21 @@ func (m *Manager) stopPassthroughProcess(ctx context.Context, executionID string
// buildFreshAgentCommand rebuilds the agent command without resume flags by going through
// the standard BuildCommandString pipeline with an empty SessionID. This works for all
// agent types because each agent's BuildCommand respects SessionID="" to skip resume flags.
func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) (initial, continueCmd string) {
//
// It fails closed: if the profile cannot be resolved, or a configured
// command_prefix cannot be tokenised, it returns an error so a context reset
// never relaunches a sandboxed agent without its wrapper.
func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) (initial, continueCmd string, err error) {
var profileInfo *AgentProfileInfo
if execution.AgentProfileID != "" && m.profileResolver != nil {
pi, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID)
if err == nil {
profileInfo = pi
pi, resolveErr := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID)
if resolveErr != nil {
// A profile was expected but could not be resolved. We cannot tell
// whether it carried a sandbox prefix, so refuse to relaunch rather
// than risk running unwrapped.
return "", "", fmt.Errorf("resolve profile %s for restart: %w", execution.AgentProfileID, resolveErr)
}
profileInfo = pi
}
model := ""
@@ -1515,16 +1528,26 @@ func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentEx
model = override
}
// Preserve the profile's cli_flags and command_prefix across restarts. A
// context reset that dropped the launcher prefix would relaunch a
// sandboxed agent unwrapped — the exact protection the prefix exists for.
cliFlagTokens, commandPrefixTokens, err := m.resolveProfileLaunchTokens(profileInfo)
if err != nil {
return "", "", err
}
opts := agents.CommandOptions{
Model: model,
SessionID: "", // Fresh start — no resume flags
AutoApprove: autoApprove,
PermissionValues: permissionValues,
Model: model,
SessionID: "", // Fresh start — no resume flags
AutoApprove: autoApprove,
PermissionValues: permissionValues,
CLIFlagTokens: cliFlagTokens,
CommandPrefixTokens: commandPrefixTokens,
// Runtime is "standalone" / "docker" / "sprites" — MockAgent
// reads this to pick a bare name (container PATH lookup) vs.
// an absolute host path.
Runtime: execution.RuntimeName,
}
return m.commandBuilder.BuildCommandString(agentConfig, opts),
m.commandBuilder.BuildContinueCommandString(agentConfig, opts)
m.commandBuilder.BuildContinueCommandString(agentConfig, opts), nil
}
@@ -210,27 +210,55 @@ type agentCommands struct {
continue_ string // continue command for one-shot agents (empty if not applicable)
}
// resolveProfileLaunchTokens resolves the user-configured cli_flags argv tokens
// and the launcher command_prefix tokens for a profile. Both must be applied on
// every command build — the initial launch AND fresh restarts (context reset) —
// or a sandboxed profile would silently relaunch without its wrapper.
//
// cli_flags are best-effort: a malformed entry is logged and dropped so a typo
// doesn't block the task. command_prefix is a sandbox boundary, so it fails
// CLOSED: a profile that configured a prefix which cannot be resolved returns an
// error and aborts the launch rather than running the agent unwrapped.
func (m *Manager) resolveProfileLaunchTokens(profileInfo *AgentProfileInfo) (cliFlagTokens, commandPrefixTokens []string, err error) {
if profileInfo == nil {
return nil, nil, nil
}
if tokens, resolveErr := cliflags.Resolve(profileInfo.CLIFlags); resolveErr != nil {
m.logger.Warn("failed to resolve cli_flags for profile, launching without user-configured flags",
zap.String("profile_id", profileInfo.ProfileID),
zap.Error(resolveErr))
} else {
cliFlagTokens = tokens
}
if strings.TrimSpace(profileInfo.CommandPrefix) != "" {
tokens, tokErr := cliflags.Tokenise(profileInfo.CommandPrefix)
if tokErr != nil || len(tokens) == 0 {
return nil, nil, fmt.Errorf("resolve command_prefix for profile %s: %w",
profileInfo.ProfileID, tokErr)
}
commandPrefixTokens = tokens
}
return cliFlagTokens, commandPrefixTokens, nil
}
// buildAgentCommand builds the agent command strings for the execution.
// Returns both the initial command and the continue command (for one-shot agents like Amp).
func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfileInfo, agentConfig agents.Agent, preferNative bool) agentCommands {
// Returns an error when a configured command_prefix cannot be resolved, so a
// sandboxed profile fails closed instead of launching unwrapped.
func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfileInfo, agentConfig agents.Agent, preferNative bool) (agentCommands, error) {
model := ""
autoApprove := false
permissionValues := make(map[string]bool)
var cliFlagTokens []string
if profileInfo != nil {
model = profileInfo.Model
autoApprove = profileInfo.AutoApprove
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
tokens, err := cliflags.Resolve(profileInfo.CLIFlags)
if err != nil {
m.logger.Warn("failed to resolve cli_flags for profile, launching without user-configured flags",
zap.String("profile_id", profileInfo.ProfileID),
zap.Error(err))
} else {
cliFlagTokens = tokens
}
}
cliFlagTokens, commandPrefixTokens, err := m.resolveProfileLaunchTokens(profileInfo)
if err != nil {
return agentCommands{}, err
}
// Allow model override from request (for dynamic model switching)
if req.ModelOverride != "" {
@@ -244,18 +272,19 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil
sessionID = ""
}
cmdOpts := agents.CommandOptions{
Model: model,
SessionID: sessionID,
AutoApprove: autoApprove,
PermissionValues: permissionValues,
CLIFlagTokens: cliFlagTokens,
Runtime: models.ExecutorType(req.ExecutorType).Runtime(),
PreferNativeBinary: preferNative,
Model: model,
SessionID: sessionID,
AutoApprove: autoApprove,
PermissionValues: permissionValues,
CLIFlagTokens: cliFlagTokens,
CommandPrefixTokens: commandPrefixTokens,
Runtime: models.ExecutorType(req.ExecutorType).Runtime(),
PreferNativeBinary: preferNative,
}
return agentCommands{
initial: m.commandBuilder.BuildCommandString(agentConfig, cmdOpts),
continue_: m.commandBuilder.BuildContinueCommandString(agentConfig, cmdOpts),
}
}, nil
}
// launchResolveWorkspacePath resolves the effective workspace path for non-worktree executors.
@@ -890,7 +919,10 @@ func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *Agen
return nil, fmt.Errorf("agent type %q is disabled", agentTypeName)
}
preferNative := m.preferNativeBinary(agentConfig, execution.RuntimeName, execution.Metadata)
cmds := m.buildAgentCommand(req, profileInfo, agentConfig, preferNative)
cmds, err := m.buildAgentCommand(req, profileInfo, agentConfig, preferNative)
if err != nil {
return nil, err
}
execution.AgentCommand = cmds.initial
execution.ContinueCommand = cmds.continue_
if req.ACPSessionID != "" && execution.ACPSessionID == "" {
@@ -1016,7 +1048,21 @@ 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)
execution, err := m.buildExecutionFromInstance(req, execReq, execInstance, rt, profileInfo, agentConfig, prepResult)
if err != nil {
// Command resolution failed (e.g. a configured command_prefix could not
// be tokenised). The execution isn't built yet, so stop the runtime
// instance directly to avoid leaking it, then fail closed.
if rt != nil && execInstance != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if stopErr := rt.StopInstance(cleanupCtx, execInstance, false); stopErr != nil {
m.logger.Warn("failed to stop runtime instance after command resolution error",
zap.Error(stopErr))
}
cancel()
}
return nil, err
}
if profileInfo != nil && len(profileInfo.EnvVars) > 0 {
m.cacheResolvedProfileEnv(execution, m.resolveAgentProfileEnvVars(ctx, profileInfo.EnvVars))
}
@@ -1051,7 +1097,7 @@ func (m *Manager) buildExecutionFromInstance(
profileInfo *AgentProfileInfo,
agentConfig agents.Agent,
prepResult *EnvPrepareResult,
) *AgentExecution {
) (*AgentExecution, error) {
execution := execInstance.ToAgentExecution(execReq)
execution.RuntimeName = rt.Name()
if req.ACPSessionID != "" {
@@ -1066,10 +1112,13 @@ func (m *Manager) buildExecutionFromInstance(
// promoteWorkspaceExecution's call site rather than re-deriving from the
// requested ExecutorType.
preferNative := m.preferNativeBinary(agentConfig, execution.RuntimeName, execReq.Metadata)
cmds := m.buildAgentCommand(req, profileInfo, agentConfig, preferNative)
cmds, err := m.buildAgentCommand(req, profileInfo, agentConfig, preferNative)
if err != nil {
return nil, err
}
execution.AgentCommand = cmds.initial
execution.ContinueCommand = cmds.continue_
return execution
return execution, nil
}
// registerAndPublishExecution does the post-spawn lockstep dance: track in the
@@ -91,7 +91,8 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) {
t.Run("CanRecover=true with ACPSessionID includes --resume", func(t *testing.T) {
ag := &resumeTestAgent{canRecover: &canRecoverTrue}
req := &LaunchRequest{ACPSessionID: "sess-123"}
cmds := mgr.buildAgentCommand(req, nil, ag, false)
cmds, err := mgr.buildAgentCommand(req, nil, ag, false)
require.NoError(t, err)
require.Contains(t, cmds.initial, "--resume")
require.Contains(t, cmds.initial, "sess-123")
})
@@ -99,7 +100,8 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) {
t.Run("CanRecover=false with ACPSessionID omits --resume", func(t *testing.T) {
ag := &resumeTestAgent{canRecover: &canRecoverFalse}
req := &LaunchRequest{ACPSessionID: "sess-123"}
cmds := mgr.buildAgentCommand(req, nil, ag, false)
cmds, err := mgr.buildAgentCommand(req, nil, ag, false)
require.NoError(t, err)
require.False(t, strings.Contains(cmds.initial, "--resume"),
"expected no --resume flag, got: %s", cmds.initial)
require.False(t, strings.Contains(cmds.initial, "sess-123"),
@@ -109,7 +111,8 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) {
t.Run("CanRecover=true with empty ACPSessionID omits --resume", func(t *testing.T) {
ag := &resumeTestAgent{canRecover: &canRecoverTrue}
req := &LaunchRequest{ACPSessionID: ""}
cmds := mgr.buildAgentCommand(req, nil, ag, false)
cmds, err := mgr.buildAgentCommand(req, nil, ag, false)
require.NoError(t, err)
require.False(t, strings.Contains(cmds.initial, "--resume"),
"expected no --resume flag when ACPSessionID is empty, got: %s", cmds.initial)
})
@@ -117,7 +120,8 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) {
t.Run("CanRecover=nil (default true) with ACPSessionID includes --resume", func(t *testing.T) {
ag := &resumeTestAgent{canRecover: nil}
req := &LaunchRequest{ACPSessionID: "sess-456"}
cmds := mgr.buildAgentCommand(req, nil, ag, false)
cmds, err := mgr.buildAgentCommand(req, nil, ag, false)
require.NoError(t, err)
require.Contains(t, cmds.initial, "--resume")
require.Contains(t, cmds.initial, "sess-456")
})
@@ -145,7 +149,8 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) {
{Flag: "--add-dir /shared", Enabled: true}, // must be split
},
}
cmds := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
require.NoError(t, err)
require.Contains(t, cmds.initial, "--allow-all-tools")
require.NotContains(t, cmds.initial, "--allow-all-paths")
@@ -162,7 +167,8 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) {
{Flag: `--broken "unterminated`, Enabled: true},
},
}
cmds := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
require.NoError(t, err)
// The bad flag is dropped entirely; the launch still produces the
// agent's base command so a user with a typo still gets their task
// to run, just without the flag they intended.
@@ -170,11 +176,72 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) {
})
t.Run("nil profile produces bare command", func(t *testing.T) {
cmds := mgr.buildAgentCommand(&LaunchRequest{}, nil, ag, false)
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, nil, ag, false)
require.NoError(t, err)
require.Equal(t, "copilot --acp", cmds.initial)
})
}
func TestBuildAgentCommand_CommandPrefix(t *testing.T) {
mgr := newTestManager(t)
ag := &cliFlagTestAgent{}
t.Run("prefix is prepended before the agent command", func(t *testing.T) {
profile := &AgentProfileInfo{
ProfileID: "p1",
CommandPrefix: "greywall --",
}
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
require.NoError(t, err)
require.Equal(t, "greywall -- copilot --acp", cmds.initial)
})
t.Run("prefix precedes appended cli flags", func(t *testing.T) {
profile := &AgentProfileInfo{
ProfileID: "p2",
CommandPrefix: "greywall --",
CLIFlags: []settingsmodels.CLIFlag{{Flag: "--allow-all-tools", Enabled: true}},
}
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
require.NoError(t, err)
require.Equal(t, "greywall -- copilot --acp --allow-all-tools", cmds.initial)
})
t.Run("empty prefix leaves the command unwrapped", func(t *testing.T) {
profile := &AgentProfileInfo{ProfileID: "p3"}
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
require.NoError(t, err)
require.Equal(t, "copilot --acp", cmds.initial)
})
t.Run("malformed prefix fails closed instead of launching unwrapped", func(t *testing.T) {
profile := &AgentProfileInfo{
ProfileID: "p4",
CommandPrefix: `greywall "unterminated`,
}
_, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false)
require.Error(t, err, "a configured prefix that cannot be resolved must abort the launch")
})
t.Run("prefix also wraps the continue-session command", func(t *testing.T) {
// One-shot agents (e.g. Amp) build a separate continue command; it must
// carry the launcher prefix too, after any cli flags.
continueAgent := &cliFlagTestAgent{testAgent{runtimeConfig: &agents.RuntimeConfig{
SessionConfig: agents.SessionConfig{
ContinueSessionCmd: agents.NewCommand("amp", "threads", "continue"),
},
}}}
profile := &AgentProfileInfo{
ProfileID: "p5",
CommandPrefix: "greywall --",
CLIFlags: []settingsmodels.CLIFlag{{Flag: "--allow-all-tools", Enabled: true}},
}
cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, continueAgent, false)
require.NoError(t, err)
require.Equal(t, "greywall -- amp threads continue --allow-all-tools", cmds.continue_)
})
}
func TestBuildEnvForExecution_ResolvesSecretBackedProfileEnv(t *testing.T) {
store := newInMemorySecretStore()
if err := store.Create(context.Background(), &secrets.SecretWithValue{
@@ -76,6 +76,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
AllowIndexing: profile.AllowIndexing,
CLIFlags: profile.CLIFlags,
CommandPrefix: profile.CommandPrefix,
EnvVars: profile.EnvVars,
CLIPassthrough: profile.CLIPassthrough,
NativeSessionResume: nativeSessionResume,
@@ -636,6 +636,9 @@ 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
// CommandPrefix is an optional launcher prefix (e.g. "greywall --")
// shell-tokenised and prepended to the agent command at launch time.
CommandPrefix string
// EnvVars are user-configured environment variables for this profile.
EnvVars []settingsmodels.ProfileEnvVar
@@ -169,6 +169,7 @@ type CommandPreviewRequest struct {
PermissionSettings map[string]bool
CLIPassthrough bool
CLIFlags []dto.CLIFlagDTO
CommandPrefix string
}
// PreviewAgentCommand generates a preview of the CLI command that will be executed
@@ -207,6 +208,14 @@ func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string,
if len(cliFlagTokens) > 0 {
cmd = cmd.With().Flag(cliFlagTokens...).Build()
}
// Prepend the launcher prefix last, mirroring
// lifecycle.CommandBuilder.BuildCommand. Tolerate malformed input
// silently — the preview is informational. Passthrough never applies
// the prefix (neither does the launch path), so this stays in the ACP
// branch only.
if prefixTokens, err := cliflags.Tokenise(req.CommandPrefix); err == nil && len(prefixTokens) > 0 {
cmd = agents.NewCommand(append(prefixTokens, cmd.Args()...)...)
}
}
return &dto.CommandPreviewResponse{
@@ -80,6 +80,9 @@ type CreateAgentProfileRequest struct {
// by default) so a fresh profile opens with the agent's suggestions.
CLIFlags []dto.CLIFlagDTO
EnvVars []dto.ProfileEnvVarDTO
// CommandPrefix is an optional launcher prefix prepended to the agent
// command (e.g. "greywall --"). Shell-tokenised at launch time.
CommandPrefix string
}
func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) {
@@ -109,6 +112,14 @@ func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*
if err != nil {
return nil, err
}
// Validate every profile request BEFORE inserting the agent row so an
// invalid profile (e.g. a malformed command_prefix) returns a 400 without
// leaving an orphaned agent behind.
for i := range req.Profiles {
if err := validateCreateProfileRequest(req.Profiles[i]); err != nil {
return nil, err
}
}
agent := &models.Agent{
Name: matched.Name,
WorkspaceID: req.WorkspaceID,
@@ -169,15 +180,25 @@ func (c *Controller) findMatchedAvailability(name string, results []discovery.Av
return nil, fmt.Errorf("unknown agent: %s", name)
}
// validateCreateProfileRequest runs all save-time validation for a nested
// agent-create profile. Kept separate so CreateAgent can validate every profile
// before inserting the agent row (avoiding an orphaned agent on a bad profile).
func validateCreateProfileRequest(p CreateAgentProfileRequest) error {
if p.CLIFlags != nil {
if err := validateCLIFlagDTOs(p.CLIFlags); err != nil {
return err
}
}
if err := validateProfileEnvVarDTOs(p.EnvVars); err != nil {
return err
}
return validateCommandPrefix(p.CommandPrefix)
}
func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayName string, profileReqs []CreateAgentProfileRequest, agentConfig agents.Agent) ([]*models.AgentProfile, error) {
profiles := make([]*models.AgentProfile, 0, len(profileReqs))
for _, profileReq := range profileReqs {
if profileReq.CLIFlags != nil {
if err := validateCLIFlagDTOs(profileReq.CLIFlags); err != nil {
return nil, err
}
}
if err := validateProfileEnvVarDTOs(profileReq.EnvVars); err != nil {
if err := validateCreateProfileRequest(profileReq); err != nil {
return nil, err
}
cliFlags := cliFlagsFromDTO(profileReq.CLIFlags)
@@ -192,6 +213,7 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa
Mode: profileReq.Mode,
CLIFlags: cliFlags,
EnvVars: envVarsFromDTO(profileReq.EnvVars),
CommandPrefix: strings.TrimSpace(profileReq.CommandPrefix),
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
@@ -43,6 +43,7 @@ var (
ErrInvalidSlug = errors.New("display name must produce a valid slug")
ErrCommandRequired = errors.New("command is required")
ErrInvalidProfileEnvVars = errors.New("invalid profile env vars")
ErrInvalidCommandPrefix = errors.New("invalid command prefix")
)
type Controller struct {
@@ -30,6 +30,9 @@ type CreateProfileRequest struct {
// default unless the curated entry specifies Default: true).
CLIFlags []dto.CLIFlagDTO
EnvVars []dto.ProfileEnvVarDTO
// CommandPrefix is an optional launcher prefix prepended to the agent
// command (e.g. "greywall --"). Shell-tokenised at launch time.
CommandPrefix string
}
func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) {
@@ -57,6 +60,9 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest
if err := validateProfileEnvVarDTOs(req.EnvVars); err != nil {
return nil, err
}
if err := validateCommandPrefix(req.CommandPrefix); err != nil {
return nil, err
}
profile := &models.AgentProfile{
AgentID: req.AgentID,
Name: req.Name,
@@ -69,6 +75,7 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest
CLIPassthrough: req.CLIPassthrough,
CLIFlags: cliFlags,
EnvVars: envVarsFromDTO(req.EnvVars),
CommandPrefix: strings.TrimSpace(req.CommandPrefix),
UserModified: true,
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
@@ -131,6 +138,9 @@ type UpdateProfileRequest struct {
CLIFlags *[]dto.CLIFlagDTO
// EnvVars replaces the entire list when non-nil.
EnvVars *[]dto.ProfileEnvVarDTO
// CommandPrefix replaces the value when non-nil. Nil means "leave
// unchanged" — the UI always sends the desired value on save.
CommandPrefix *string
}
func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) {
@@ -176,6 +186,12 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest
}
profile.EnvVars = envVarsFromDTO(*req.EnvVars)
}
if req.CommandPrefix != nil {
if err := validateCommandPrefix(*req.CommandPrefix); err != nil {
return nil, err
}
profile.CommandPrefix = strings.TrimSpace(*req.CommandPrefix)
}
profile.UserModified = true
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
return nil, err
@@ -212,6 +228,32 @@ func validateCLIFlagDTOs(in []dto.CLIFlagDTO) error {
return nil
}
// validateCommandPrefix rejects a launcher prefix with malformed shell tokens
// (unterminated quotes, trailing backslash). An empty/whitespace-only prefix is
// valid — it means "run the agent command unwrapped". Tokenising here keeps the
// launch path's cliflags.Tokenise error branch unreachable in practice, so a
// bad prefix surfaces at save time rather than silently dropping at task start.
func validateCommandPrefix(prefix string) error {
if strings.TrimSpace(prefix) == "" {
return nil
}
tokens, err := cliflags.Tokenise(prefix)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidCommandPrefix, err)
}
if len(tokens) == 0 || tokens[0] == "" {
return fmt.Errorf("%w: must start with a launcher command", ErrInvalidCommandPrefix)
}
// The first token is the launcher executable. A leading '-' means the user
// typed a flag first (e.g. "--foo greywall"), which would run the flag as
// the program — almost certainly a mistake, and a confusing failure mode
// for a sandbox boundary.
if strings.HasPrefix(tokens[0], "-") {
return fmt.Errorf("%w: first token %q must be a launcher command, not a flag", ErrInvalidCommandPrefix, tokens[0])
}
return nil
}
func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) (*dto.AgentProfileDTO, error) {
profile, err := c.repo.GetAgentProfile(ctx, id)
if err != nil {
@@ -403,6 +445,7 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO {
CLIFlags: cliFlagsToDTO(profile.CLIFlags),
EnvVars: envVarsToDTO(profile.EnvVars),
CLIPassthrough: profile.CLIPassthrough,
CommandPrefix: profile.CommandPrefix,
UserModified: profile.UserModified,
WorkspaceID: profile.WorkspaceID,
CreatedAt: profile.CreatedAt,
@@ -2,6 +2,7 @@ package controller
import (
"context"
"errors"
"fmt"
"testing"
@@ -121,6 +122,43 @@ func TestValidateCLIFlagDTOs(t *testing.T) {
}
}
// TestValidateCommandPrefix accepts an empty prefix (run unwrapped) and
// well-formed launcher strings, and rejects malformed shell tokens.
func TestValidateCommandPrefix(t *testing.T) {
cases := []struct {
name string
prefix string
wantErr bool
}{
{name: "empty accepted", prefix: ""},
{name: "whitespace accepted", prefix: " "},
{name: "simple launcher accepted", prefix: "greywall --"},
{name: "quoted arg accepted", prefix: `sandbox-exec -f "my profile.sb"`},
{name: "unterminated quote rejected", prefix: `greywall "unterminated`, wantErr: true},
{name: "trailing backslash rejected", prefix: `greywall foo\`, wantErr: true},
{name: "only-empty-quotes rejected", prefix: `""`, wantErr: true},
{name: "leading flag rejected", prefix: `--foo greywall`, wantErr: true},
{name: "leading dash rejected", prefix: `-x sandbox`, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateCommandPrefix(tc.prefix)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if !errors.Is(err, ErrInvalidCommandPrefix) {
// The handlers map only ErrInvalidCommandPrefix to HTTP 400;
// an unwrapped error would fall through to a 500.
t.Errorf("error does not wrap ErrInvalidCommandPrefix: %v", err)
}
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestValidateProfileEnvVarDTOs(t *testing.T) {
cases := []struct {
name string
@@ -250,3 +288,41 @@ func TestCreateAgentProfiles_PersistsEnvVars(t *testing.T) {
t.Fatalf("created env vars: %+v", profiles)
}
}
// TestCreateAgentProfiles_PersistsCommandPrefix confirms a valid prefix on a
// nested-create profile is trimmed and stored.
func TestCreateAgentProfiles_PersistsCommandPrefix(t *testing.T) {
ctrl := newTestController(nil)
st := newFakeStore()
ctrl.repo = st
profiles, err := ctrl.createAgentProfiles(context.Background(), "agent-1", "Test Agent", []CreateAgentProfileRequest{{
Name: "Sandboxed",
Model: "model-1",
CommandPrefix: " greywall -- ",
}}, &testAgent{id: "test-agent", name: "test-agent"})
if err != nil {
t.Fatalf("createAgentProfiles: %v", err)
}
if len(profiles) != 1 || profiles[0].CommandPrefix != "greywall --" {
t.Fatalf("created command prefix: %+v", profiles)
}
}
// TestCreateAgentProfiles_RejectsBadCommandPrefix confirms a malformed prefix on
// a nested-create profile returns ErrInvalidCommandPrefix (mapped to HTTP 400)
// rather than a generic error, and that the validate-first pass surfaces it.
func TestCreateAgentProfiles_RejectsBadCommandPrefix(t *testing.T) {
ctrl := newTestController(nil)
st := newFakeStore()
ctrl.repo = st
_, err := ctrl.createAgentProfiles(context.Background(), "agent-1", "Test Agent", []CreateAgentProfileRequest{{
Name: "Bad",
Model: "model-1",
CommandPrefix: `greywall "unterminated`,
}}, &testAgent{id: "test-agent", name: "test-agent"})
if !errors.Is(err, ErrInvalidCommandPrefix) {
t.Fatalf("expected ErrInvalidCommandPrefix, got %v", err)
}
}
@@ -34,7 +34,10 @@ type AgentProfileDTO struct {
CLIFlags []CLIFlagDTO `json:"cli_flags"`
EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"`
CLIPassthrough bool `json:"cli_passthrough"`
UserModified bool `json:"user_modified"`
// CommandPrefix is an optional launcher prefix prepended to the agent
// command (e.g. "greywall --"). Shell-tokenised at launch time.
CommandPrefix string `json:"command_prefix,omitempty"`
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
@@ -273,6 +276,7 @@ type CommandPreviewRequest struct {
Model string `json:"model"`
PermissionSettings map[string]bool `json:"permission_settings"`
CLIPassthrough bool `json:"cli_passthrough"`
CommandPrefix string `json:"command_prefix,omitempty"`
}
// CommandPreviewResponse is the response for the command preview endpoint
@@ -184,11 +184,12 @@ 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"`
EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,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"`
CommandPrefix string `json:"command_prefix,omitempty"`
}
func (h *Handlers) httpCreateAgent(c *gin.Context) {
@@ -208,11 +209,12 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) {
return
}
profiles = append(profiles, controller.CreateAgentProfileRequest{
Name: profile.Name,
Model: profile.Model,
Mode: profile.Mode,
CLIFlags: profile.CLIFlags,
EnvVars: profile.EnvVars,
Name: profile.Name,
Model: profile.Model,
Mode: profile.Mode,
CLIFlags: profile.CLIFlags,
EnvVars: profile.EnvVars,
CommandPrefix: profile.CommandPrefix,
})
}
resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{
@@ -221,6 +223,10 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) {
Profiles: profiles,
})
if err != nil {
if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.logger.Error("failed to create agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -373,6 +379,7 @@ type createProfileRequest struct {
CLIPassthrough bool `json:"cli_passthrough"`
CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"`
EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"`
CommandPrefix string `json:"command_prefix,omitempty"`
}
func (h *Handlers) httpCreateProfile(c *gin.Context) {
@@ -396,9 +403,10 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) {
CLIPassthrough: body.CLIPassthrough,
CLIFlags: body.CLIFlags,
EnvVars: body.EnvVars,
CommandPrefix: body.CommandPrefix,
})
if err != nil {
if errors.Is(err, controller.ErrInvalidProfileEnvVars) {
if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -425,6 +433,7 @@ type updateProfileRequest struct {
CLIPassthrough *bool `json:"cli_passthrough,omitempty"`
CLIFlags *[]dto.CLIFlagDTO `json:"cli_flags,omitempty"`
EnvVars *[]dto.ProfileEnvVarDTO `json:"env_vars,omitempty"`
CommandPrefix *string `json:"command_prefix,omitempty"`
}
func (h *Handlers) httpUpdateProfile(c *gin.Context) {
@@ -448,13 +457,14 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) {
CLIPassthrough: body.CLIPassthrough,
CLIFlags: body.CLIFlags,
EnvVars: body.EnvVars,
CommandPrefix: body.CommandPrefix,
})
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) {
if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -507,6 +517,7 @@ type commandPreviewRequest struct {
PermissionSettings map[string]bool `json:"permission_settings"`
CLIPassthrough bool `json:"cli_passthrough"`
CLIFlags []dto.CLIFlagDTO `json:"cli_flags"`
CommandPrefix string `json:"command_prefix,omitempty"`
}
func (h *Handlers) httpPreviewAgentCommand(c *gin.Context) {
@@ -527,6 +538,7 @@ func (h *Handlers) httpPreviewAgentCommand(c *gin.Context) {
PermissionSettings: body.PermissionSettings,
CLIPassthrough: body.CLIPassthrough,
CLIFlags: body.CLIFlags,
CommandPrefix: body.CommandPrefix,
})
if err != nil {
h.logger.Error("failed to preview agent command", zap.Error(err))
@@ -64,6 +64,13 @@ type AgentProfile struct {
// CLIPassthrough enables TUI-passthrough execution style. Orthogonal to ACP.
CLIPassthrough bool `json:"cli_passthrough" db:"cli_passthrough"`
// CommandPrefix is an optional launcher prefix prepended to the agent
// subprocess command. It lets a user wrap the ACP agent in a sandbox
// launcher (e.g. "greywall --" produces "greywall -- npx -y <pkg> …").
// The raw string is shell-tokenised at launch time, mirroring CLIFlags.
// Empty means the agent command runs unwrapped.
CommandPrefix string `json:"command_prefix" db:"command_prefix"`
// AllowIndexing is retained for backward compatibility with existing
// auggie profiles. The launch path no longer consults it — it is read
// only by the legacy migration shim that seeds CLIFlags on the first
@@ -104,6 +104,7 @@ func (r *sqliteRepository) initSchema() error {
budget_monthly_cents INTEGER NOT NULL DEFAULT 0,
settings TEXT NOT NULL DEFAULT '{}',
permissions TEXT NOT NULL DEFAULT '{}',
command_prefix TEXT NOT NULL DEFAULT '',
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
@@ -152,6 +153,12 @@ func (r *sqliteRepository) initSchema() error {
// Each ALTER is idempotent — duplicate-column errors are swallowed.
r.migrateOfficeEnrichmentColumns()
// command_prefix is added after the table-recreation migration above so a
// legacy DB that recreates agent_profiles (to drop the model CHECK) still
// ends up with the column — the recreation copies only pre-existing
// columns, so an ADD COLUMN that ran before it would be lost.
r.migrate.Apply("agent_profiles.command_prefix", `ALTER TABLE agent_profiles ADD COLUMN command_prefix TEXT NOT NULL DEFAULT ''`)
return nil
}
@@ -489,7 +496,8 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
status, pause_reason, last_run_finished_at,
max_concurrent_sessions, cooldown_sec, skip_idle_runs,
consecutive_failures, failure_threshold,
executor_preference, budget_monthly_cents, settings, permissions
executor_preference, budget_monthly_cents, settings, permissions,
command_prefix
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?,
@@ -499,7 +507,8 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
?, ?, ?,
?, ?, ?,
?, ?,
?, ?, ?, ?
?, ?, ?, ?,
?
)
`),
profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model,
@@ -513,6 +522,7 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
enrich.maxConcurrentSessions, profile.CooldownSec, dialect.BoolToInt(profile.SkipIdleRuns),
profile.ConsecutiveFailures, enrich.failureThreshold,
enrich.executorPreference, profile.BudgetMonthlyCents, enrich.settings, enrich.permissions,
profile.CommandPrefix,
)
return err
}
@@ -743,7 +753,8 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
max_concurrent_sessions = ?, cooldown_sec = ?, skip_idle_runs = ?,
consecutive_failures = ?, failure_threshold = ?,
executor_preference = ?,
budget_monthly_cents = ?, settings = ?, permissions = ?
budget_monthly_cents = ?, settings = ?, permissions = ?,
command_prefix = ?
WHERE id = ? AND deleted_at IS NULL
`), profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model,
nullableString(profile.Mode), nullableString(profile.MigratedFrom),
@@ -757,6 +768,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
profile.ConsecutiveFailures, enrich.failureThreshold,
enrich.executorPreference,
profile.BudgetMonthlyCents, enrich.settings, enrich.permissions,
profile.CommandPrefix,
profile.ID)
if err != nil {
return err
@@ -805,7 +817,8 @@ const agentProfileSelectColumns = `
COALESCE(skip_idle_runs, 0), COALESCE(consecutive_failures, 0),
COALESCE(failure_threshold, 3), COALESCE(executor_preference, ''),
COALESCE(budget_monthly_cents, 0),
COALESCE(settings, '{}'), COALESCE(permissions, '{}')
COALESCE(settings, '{}'), COALESCE(permissions, '{}'),
COALESCE(command_prefix, '')
FROM agent_profiles`
func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) {
@@ -1015,6 +1028,7 @@ func scanAgentProfile(scanner interface {
&profile.BudgetMonthlyCents,
&profile.Settings,
&profile.Permissions,
&profile.CommandPrefix,
); err != nil {
return nil, err
}
@@ -0,0 +1,82 @@
package store
import (
"context"
"testing"
"github.com/kandev/kandev/internal/agent/settings/models"
)
// TestCommandPrefix_RoundTrip verifies command_prefix survives insert → read →
// update → read, and that a profile saved without one reads back empty.
func TestCommandPrefix_RoundTrip(t *testing.T) {
repo := newFreshRepo(t)
ctx := context.Background()
if err := repo.CreateAgent(ctx, &models.Agent{Name: "claude-acp"}); err != nil {
t.Fatalf("create agent: %v", err)
}
agent, err := repo.GetAgentByName(ctx, "claude-acp")
if err != nil {
t.Fatalf("get agent: %v", err)
}
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: "sandboxed",
AgentDisplayName: "Claude",
CommandPrefix: "greywall --",
}
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
t.Fatalf("create profile: %v", err)
}
got, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if got.CommandPrefix != "greywall --" {
t.Fatalf("command_prefix mismatch: got %q", got.CommandPrefix)
}
// Update: clear the prefix.
got.CommandPrefix = ""
if err := repo.UpdateAgentProfile(ctx, got); err != nil {
t.Fatalf("update profile: %v", err)
}
got2, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("re-get profile: %v", err)
}
if got2.CommandPrefix != "" {
t.Errorf("expected cleared command_prefix, got %q", got2.CommandPrefix)
}
}
// TestCommandPrefix_DefaultsEmpty verifies a profile created without a prefix
// reads back an empty string rather than erroring on the NOT NULL column.
func TestCommandPrefix_DefaultsEmpty(t *testing.T) {
repo := newFreshRepo(t)
ctx := context.Background()
if err := repo.CreateAgent(ctx, &models.Agent{Name: "copilot-acp"}); err != nil {
t.Fatalf("create agent: %v", err)
}
agent, err := repo.GetAgentByName(ctx, "copilot-acp")
if err != nil {
t.Fatalf("get agent: %v", err)
}
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: "default",
AgentDisplayName: "Copilot",
}
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
t.Fatalf("create profile: %v", err)
}
got, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if got.CommandPrefix != "" {
t.Errorf("expected empty command_prefix, got %q", got.CommandPrefix)
}
}
+4
View File
@@ -74,6 +74,7 @@ export async function createAgentAction(payload: {
mode?: string;
cli_passthrough: boolean;
cli_flags?: CLIFlag[];
command_prefix?: string;
env_vars?: ProfileEnvVar[];
} & ProfilePermissions
>;
@@ -113,6 +114,7 @@ export async function createAgentProfileAction(
config_options?: Record<string, string>;
cli_passthrough: boolean;
cli_flags?: CLIFlag[];
command_prefix?: string;
env_vars?: ProfileEnvVar[];
} & ProfilePermissions,
): Promise<AgentProfile> {
@@ -134,6 +136,7 @@ export async function updateAgentProfileAction(
auto_approve?: boolean;
cli_passthrough?: boolean;
cli_flags?: CLIFlag[];
command_prefix?: string;
env_vars?: ProfileEnvVar[];
},
): Promise<AgentProfile> {
@@ -221,6 +224,7 @@ export type CommandPreviewRequest = {
permission_settings: Record<string, boolean>;
cli_passthrough: boolean;
cli_flags: CLIFlag[];
command_prefix?: string;
};
export type CommandPreviewResponse = {
@@ -48,7 +48,9 @@ const draftFrom = (saved: AgentProfile, overrides: Partial<DraftProfile> = {}):
});
const ALLOW_ALL_TOOLS_FLAG = "--allow-all-tools";
const COMMAND_PREFIX = "greywall --";
const PERSISTED_PROFILE_ID = toAgentProfileId("persisted-profile");
const DRAFT_PROFILE_ID = toAgentProfileId("draft-profile");
beforeEach(() => {
vi.clearAllMocks();
@@ -107,6 +109,16 @@ describe("toAgentProfilePatch", () => {
expect(toAgentProfilePatch({ cli_passthrough: false })).toEqual({ cliPassthrough: false });
expect(toAgentProfilePatch({})).toEqual({});
});
it("maps command_prefix to commandPrefix", () => {
expect(toAgentProfilePatch({ command_prefix: COMMAND_PREFIX })).toEqual({
commandPrefix: COMMAND_PREFIX,
});
});
it("maps a cleared command_prefix (empty string) rather than dropping it", () => {
expect(toAgentProfilePatch({ command_prefix: "" })).toEqual({ commandPrefix: "" });
});
});
describe("isProfileDirty", () => {
@@ -175,12 +187,35 @@ describe("isProfileDirty", () => {
const draft = draftFrom(baseProfile, { auto_approve: true, autoApprove: false });
expect(isProfileDirty(draft, baseProfile)).toBe(false);
});
it("returns true when a command prefix is set on a profile that had none", () => {
const draft = draftFrom(baseProfile, { commandPrefix: COMMAND_PREFIX });
expect(isProfileDirty(draft, baseProfile)).toBe(true);
});
it("returns true when a previously saved command prefix is cleared", () => {
const saved: AgentProfile = { ...baseProfile, commandPrefix: COMMAND_PREFIX };
const draft = draftFrom(saved, { commandPrefix: "" });
expect(isProfileDirty(draft, saved)).toBe(true);
});
it("treats an undefined command prefix as equal to an empty string", () => {
const saved: AgentProfile = { ...baseProfile, commandPrefix: undefined };
const draft = draftFrom(saved, { commandPrefix: "" });
expect(isProfileDirty(draft, saved)).toBe(false);
});
it("returns false when the command prefix is unchanged", () => {
const saved: AgentProfile = { ...baseProfile, commandPrefix: COMMAND_PREFIX };
const draft = draftFrom(saved, { commandPrefix: COMMAND_PREFIX });
expect(isProfileDirty(draft, saved)).toBe(false);
});
});
describe("mergeSavedAgentDraft", () => {
it("remaps created profile IDs while preserving edits made during save", () => {
const submittedProfile = draftFrom(baseProfile, {
id: toAgentProfileId("draft-profile"),
id: DRAFT_PROFILE_ID,
name: "Submitted name",
});
const submitted = agentWithProfiles([submittedProfile]);
@@ -206,7 +241,7 @@ describe("mergeSavedAgentDraft", () => {
it("keeps existing profiles while remapping a newly created profile", () => {
const existing = draftFrom(baseProfile, { id: toAgentProfileId("existing") });
const submittedProfile = draftFrom(baseProfile, {
id: toAgentProfileId("draft-profile"),
id: DRAFT_PROFILE_ID,
name: "Submitted",
});
const persisted = { ...submittedProfile, id: PERSISTED_PROFILE_ID };
@@ -229,7 +264,7 @@ describe("mergeSavedAgentDraft", () => {
describe("saveNewAgent", () => {
it("reconciles a created agent so a failed MCP write retries without another create", async () => {
const draftProfile = draftFrom(baseProfile, {
id: toAgentProfileId("draft-profile"),
id: DRAFT_PROFILE_ID,
mcp_config: {
enabled: true,
servers: '{"mcpServers":{"playwright":{"command":"npx"}}}',
@@ -326,6 +361,84 @@ describe("saveExistingAgent", () => {
});
});
describe("command prefix save payloads", () => {
it("includes the command prefix when saving a dirty existing profile", async () => {
const savedAgent = agentWithProfiles([baseProfile]);
const draftProfile = draftFrom(baseProfile, { commandPrefix: COMMAND_PREFIX });
const draftAgent = agentWithProfiles([draftProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(updateAgentProfileAction).mockResolvedValue({
...baseProfile,
commandPrefix: COMMAND_PREFIX,
});
await saveExistingAgent(draftAgent, savedAgent, false, callbacks);
expect(updateAgentProfileAction).toHaveBeenCalledWith(
baseProfile.id,
expect.objectContaining({ command_prefix: COMMAND_PREFIX }),
);
});
it("saves an empty command prefix when a previously-saved prefix is cleared", async () => {
const savedProfileWithPrefix: AgentProfile = { ...baseProfile, commandPrefix: COMMAND_PREFIX };
const savedAgent = agentWithProfiles([savedProfileWithPrefix]);
const draftProfile = draftFrom(savedProfileWithPrefix, { commandPrefix: "" });
const draftAgent = agentWithProfiles([draftProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(updateAgentProfileAction).mockResolvedValue({ ...baseProfile, commandPrefix: "" });
await saveExistingAgent(draftAgent, savedAgent, false, callbacks);
expect(updateAgentProfileAction).toHaveBeenCalledWith(
baseProfile.id,
expect.objectContaining({ command_prefix: "" }),
);
});
it("includes the command prefix when creating a new agent's profile", async () => {
const draftProfile = draftFrom(baseProfile, {
id: DRAFT_PROFILE_ID,
commandPrefix: COMMAND_PREFIX,
});
const draftAgent = agentWithProfiles([draftProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(createAgentAction).mockResolvedValue(
agentWithProfiles([{ ...draftProfile, id: PERSISTED_PROFILE_ID }]),
);
await saveNewAgent(draftAgent, callbacks);
expect(createAgentAction).toHaveBeenCalledWith(
expect.objectContaining({
profiles: [expect.objectContaining({ command_prefix: COMMAND_PREFIX })],
}),
);
});
it("includes the command prefix when adding a new profile to an existing agent", async () => {
const savedAgent = agentWithProfiles([baseProfile]);
const newProfile = draftFrom(baseProfile, {
id: toAgentProfileId("draft-new-profile"),
name: "New profile",
commandPrefix: COMMAND_PREFIX,
});
const draftAgent = agentWithProfiles([baseProfile, newProfile]);
const { callbacks } = createTestCallbacks(draftAgent);
vi.mocked(createAgentProfileAction).mockResolvedValue({
...newProfile,
id: PERSISTED_PROFILE_ID,
});
await saveExistingAgent(draftAgent, savedAgent, false, callbacks);
expect(createAgentProfileAction).toHaveBeenCalledWith(
savedAgent.id,
expect.objectContaining({ command_prefix: COMMAND_PREFIX }),
);
});
});
function agentWithProfiles(profiles: DraftProfile[]): DraftAgent {
return {
id: "agent-1",
@@ -36,6 +36,7 @@ export function toAgentProfilePatch(patch: Partial<ProfileFormData>): Partial<Ag
if (patch.auto_approve !== undefined) next.autoApprove = patch.auto_approve;
if (patch.cli_passthrough !== undefined) next.cliPassthrough = patch.cli_passthrough;
if (patch.cli_flags !== undefined) next.cliFlags = patch.cli_flags;
if (patch.command_prefix !== undefined) next.commandPrefix = patch.command_prefix;
return next;
}
@@ -209,6 +210,7 @@ export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentC
...permissionsToProfilePatch(profile),
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
env_vars: profile.envVars ?? [],
})),
});
@@ -284,6 +286,7 @@ async function savePersistedProfile(
...permissionsToProfilePatch(profile),
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
env_vars: profile.envVars ?? [],
});
}
@@ -315,6 +318,7 @@ async function saveExistingProfiles(
...permissionsToProfilePatch(profile),
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
env_vars: profile.envVars ?? [],
});
profileIds.set(profile.id, createdProfile.id);
@@ -481,6 +485,15 @@ export function mergeSavedAgentDraft(
return { ...saved, ...current, id: saved.id, name: saved.name, profiles };
}
function isProfileCliConfigDirty(draft: DraftProfile, saved: AgentProfile): boolean {
return (
draft.cliPassthrough !== saved.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], saved.cliFlags ?? []) ||
(draft.commandPrefix ?? "") !== (saved.commandPrefix ?? "") ||
!areEnvVarsEqual(draft.envVars, saved.envVars)
);
}
export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boolean {
if (!saved) return true;
return (
@@ -489,8 +502,6 @@ export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boole
(draft.mode ?? "") !== (saved.mode ?? "") ||
!areConfigOptionsEqual(draft.configOptions, saved.configOptions) ||
arePermissionsDirty(draft, saved) ||
draft.cliPassthrough !== saved.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], saved.cliFlags ?? []) ||
!areEnvVarsEqual(draft.envVars, saved.envVars)
isProfileCliConfigDirty(draft, saved)
);
}
@@ -42,6 +42,7 @@ function profileFormData(
allow_indexing: permissions.allow_indexing,
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
};
}
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { CommandPreviewCard } from "./command-preview-card";
import type { CommandPreviewResponse } from "@/app/actions/agents";
const previewAgentCommandActionMock = vi.fn();
vi.mock("@/app/actions/agents", () => ({
previewAgentCommandAction: (...args: unknown[]) => previewAgentCommandActionMock(...args),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function response(commandString: string): CommandPreviewResponse {
return { supported: true, command: commandString.split(" "), command_string: commandString };
}
describe("CommandPreviewCard", () => {
it("ignores a stale response that resolves after a newer request", async () => {
// The initial render's request is left pending (simulating a slow
// network response for the "old" settings). Once it has actually fired
// (past the 300ms debounce), the settings change so a second, newer
// request goes out and resolves quickly. The stale first response must
// not overwrite the newer preview once it eventually resolves.
let resolveStale: (value: CommandPreviewResponse) => void = () => {};
const stalePromise = new Promise<CommandPreviewResponse>((resolve) => {
resolveStale = resolve;
});
previewAgentCommandActionMock.mockImplementationOnce(() => stalePromise);
const { rerender } = render(
<CommandPreviewCard
agentName="claude"
model="claude-sonnet-4-5"
permissionSettings={{}}
cliPassthrough={false}
cliFlags={[]}
commandPrefix=""
/>,
);
// Wait past the debounce so the stale request has actually fired.
await waitFor(() => expect(previewAgentCommandActionMock).toHaveBeenCalledTimes(1));
previewAgentCommandActionMock.mockImplementationOnce(async () => response("agent --new"));
rerender(
<CommandPreviewCard
agentName="claude"
model="claude-sonnet-4-5"
permissionSettings={{}}
cliPassthrough={false}
cliFlags={[]}
commandPrefix="greywall --"
/>,
);
await waitFor(() => expect(screen.getByText("agent --new")).toBeTruthy());
// The stale request finally resolves — it must not clobber the newer preview.
resolveStale(response("agent --stale"));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.getByText("agent --new")).toBeTruthy();
expect(screen.queryByText("agent --stale")).not.toBeTruthy();
});
});
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo, useRef } from "react";
import { IconCopy, IconCheck, IconTerminal2 } from "@tabler/icons-react";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
@@ -14,6 +14,7 @@ type CommandPreviewCardProps = {
permissionSettings: Record<string, boolean>;
cliPassthrough: boolean;
cliFlags: CLIFlag[];
commandPrefix?: string;
envVars?: ProfileEnvVar[];
secrets?: { id: string; name: string }[];
};
@@ -136,6 +137,7 @@ export function CommandPreviewCard({
permissionSettings,
cliPassthrough,
cliFlags,
commandPrefix,
envVars,
secrets,
}: CommandPreviewCardProps) {
@@ -143,13 +145,19 @@ export function CommandPreviewCard({
const [preview, setPreview] = useState<CommandPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Guards against out-of-order responses: a slow request for an older
// settings snapshot can resolve after a newer request has already been
// fired (or resolved). Each effect run stamps a unique sequence number;
// a response is only applied if it's still the most recent request.
const latestRequestId = useRef(0);
const settingsKey = useMemo(
() => JSON.stringify({ model, permissionSettings, cliPassthrough, cliFlags }),
[model, permissionSettings, cliPassthrough, cliFlags],
() => JSON.stringify({ model, permissionSettings, cliPassthrough, cliFlags, commandPrefix }),
[model, permissionSettings, cliPassthrough, cliFlags, commandPrefix],
);
useEffect(() => {
const requestId = ++latestRequestId.current;
setLoading(true);
setError(null);
@@ -160,19 +168,22 @@ export function CommandPreviewCard({
permission_settings: permissionSettings,
cli_passthrough: cliPassthrough,
cli_flags: cliFlags,
command_prefix: commandPrefix,
});
if (requestId !== latestRequestId.current) return;
setPreview(response);
setError(null);
} catch (err) {
if (requestId !== latestRequestId.current) return;
setError(err instanceof Error ? err.message : "Failed to load command preview");
setPreview(null);
} finally {
setLoading(false);
if (requestId === latestRequestId.current) setLoading(false);
}
}, 300);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- settingsKey already includes model, permissionSettings, cliPassthrough, cliFlags
// eslint-disable-next-line react-hooks/exhaustive-deps -- settingsKey already includes model, permissionSettings, cliPassthrough, cliFlags, commandPrefix
}, [agentName, settingsKey]);
return (
@@ -101,6 +101,7 @@ function buildAgentSettings(
mode: profile.mode ?? aa.model_config.current_mode_id ?? "",
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
...perms,
},
dirty: false,
@@ -267,6 +268,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
...permissionsToProfilePatch(s.formData),
cli_passthrough: s.formData.cli_passthrough,
cli_flags: s.formData.cli_flags,
command_prefix: s.formData.command_prefix,
}),
),
);
@@ -156,6 +156,7 @@ function ProfileSettingsCard({
allow_indexing: permissionValues.allow_indexing,
cli_passthrough: draft.cliPassthrough,
cli_flags: draft.cliFlags ?? [],
command_prefix: draft.commandPrefix ?? "",
}}
baselineProfile={{
name: savedProfile.name,
@@ -166,6 +167,7 @@ function ProfileSettingsCard({
allow_indexing: savedPermissionValues.allow_indexing,
cli_passthrough: savedProfile.cliPassthrough,
cli_flags: savedProfile.cliFlags ?? [],
command_prefix: savedProfile.commandPrefix ?? "",
}}
onChange={handleFormChange}
modelConfig={modelConfig}
@@ -216,6 +218,7 @@ function useProfileEditorState(
arePermissionsDirty(draft, savedProfile, permissionSettings) ||
draft.cliPassthrough !== savedProfile.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []) ||
(draft.commandPrefix ?? "") !== (savedProfile.commandPrefix ?? "") ||
!areEnvVarsEqual(draft.envVars, savedProfile.envVars),
[draft, savedProfile, permissionSettings],
);
@@ -271,6 +274,7 @@ function useProfileSave({
...permissionsToProfilePatch(draft),
cli_passthrough: draft.cliPassthrough,
cli_flags: draft.cliFlags,
command_prefix: draft.commandPrefix ?? "",
env_vars: draft.envVars ?? [],
});
setSavedProfile(updated);
@@ -474,6 +478,7 @@ function ProfileEditorBody({
permissionSettings={{ allow_indexing: draft.allowIndexing }}
cliPassthrough={draft.cliPassthrough}
cliFlags={draft.cliFlags ?? []}
commandPrefix={draft.commandPrefix}
envVars={draft.envVars}
secrets={secrets}
/>
@@ -0,0 +1,79 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CommandPrefixField } from "./command-prefix-field";
import type { ProfileFormData } from "./profile-form-fields";
afterEach(cleanup);
const DIRTY_ATTRIBUTE = "data-settings-dirty";
const PREFIX = "greywall --";
function formData(overrides: Partial<ProfileFormData> = {}): ProfileFormData {
return {
name: "Profile",
model: "mock-fast",
mode: "",
cli_passthrough: false,
cli_flags: [],
command_prefix: "",
...overrides,
} as ProfileFormData;
}
function dirtyState(): string | null {
return screen
.getByTestId("command-prefix-input")
.closest(`[${DIRTY_ATTRIBUTE}]`)
?.getAttribute(DIRTY_ATTRIBUTE) as string | null;
}
describe("CommandPrefixField", () => {
it("is clean when the value matches the baseline", () => {
render(
<CommandPrefixField
profile={formData({ command_prefix: PREFIX })}
baselineProfile={formData({ command_prefix: PREFIX })}
onChange={vi.fn()}
/>,
);
expect(dirtyState()).toBe("false");
});
it("marks the field dirty when a prefix is set that was previously empty", () => {
render(
<CommandPrefixField
profile={formData({ command_prefix: PREFIX })}
baselineProfile={formData({ command_prefix: "" })}
onChange={vi.fn()}
/>,
);
expect(dirtyState()).toBe("true");
});
it("marks the field dirty when a previously-saved prefix is cleared", () => {
render(
<CommandPrefixField
profile={formData({ command_prefix: "" })}
baselineProfile={formData({ command_prefix: PREFIX })}
onChange={vi.fn()}
/>,
);
expect(dirtyState()).toBe("true");
});
it("reports the cleared value through onChange", () => {
const onChange = vi.fn();
render(
<CommandPrefixField
profile={formData({ command_prefix: PREFIX })}
baselineProfile={formData({ command_prefix: PREFIX })}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId("command-prefix-input"), { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith({ command_prefix: "" });
});
});
@@ -0,0 +1,41 @@
"use client";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import type { ProfileFormData } from "@/components/settings/profile-form-fields";
export function CommandPrefixField({
profile,
baselineProfile,
onChange,
}: {
profile: ProfileFormData;
baselineProfile?: ProfileFormData;
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
return (
<div
className="space-y-2"
data-settings-dirty={
Boolean(baselineProfile) &&
(profile.command_prefix ?? "") !== (baselineProfile?.command_prefix ?? "")
}
data-settings-dirty-level="container"
>
<Label htmlFor="profile-command-prefix">Command prefix</Label>
<Input
id="profile-command-prefix"
data-testid="command-prefix-input"
value={profile.command_prefix ?? ""}
onChange={(event) => onChange({ command_prefix: event.target.value })}
placeholder="e.g. greywall --"
/>
<p className="text-xs text-muted-foreground">
Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g.{" "}
<code>greywall --</code>). The value is shell-tokenised. Leave empty to run the agent
directly. Applies to ACP sessions only — it has no effect when the profile uses TUI
passthrough.
</p>
</div>
);
}
@@ -0,0 +1,54 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { ProfileFormFields, type ProfileFormData } from "./profile-form-fields";
import type { ModelConfig } from "@/lib/types/http";
afterEach(cleanup);
const modelConfig: ModelConfig = {
default_model: "mock-fast",
available_models: [{ id: "mock-fast", name: "Mock Fast" }],
supports_dynamic_models: false,
};
function formData(overrides: Partial<ProfileFormData> = {}): ProfileFormData {
return {
name: "Profile",
model: "mock-fast",
mode: "",
cli_passthrough: false,
cli_flags: [],
command_prefix: "",
...overrides,
} as ProfileFormData;
}
function renderForm(profile: ProfileFormData) {
return render(
<TooltipProvider>
<ProfileFormFields
profile={profile}
onChange={vi.fn()}
modelConfig={modelConfig}
permissionSettings={{}}
passthroughConfig={null}
agentName="mock-agent"
/>
</TooltipProvider>,
);
}
describe("ProfileFormFields command prefix visibility", () => {
it("shows the command prefix field for an ACP (non-passthrough) profile", () => {
renderForm(formData({ cli_passthrough: false }));
expect(screen.queryByTestId("command-prefix-input")).not.toBeNull();
});
it("hides the command prefix field for a TUI-passthrough profile", () => {
renderForm(formData({ cli_passthrough: true, command_prefix: "greywall --" }));
expect(screen.queryByTestId("command-prefix-input")).toBeNull();
});
});
@@ -25,6 +25,7 @@ import {
type PermissionKey,
} from "@/lib/agent-permissions";
import { CLIFlagsField } from "@/components/settings/cli-flags-field";
import { CommandPrefixField } from "@/components/settings/command-prefix-field";
import {
CommandsButton,
findActiveMode,
@@ -48,6 +49,7 @@ export type ProfileFormData = {
config_options?: Record<string, string>;
cli_passthrough: boolean;
cli_flags: CLIFlag[];
command_prefix?: string;
} & Record<PermissionKey, boolean>;
export type ProfileFormFieldsProps = {
@@ -613,6 +615,14 @@ export function ProfileFormFields({
hideCustomFlags={hideCustomCLIFlags}
/>
</div>
{!profile.cli_passthrough && (
<CommandPrefixField
profile={profile}
baselineProfile={baselineProfile}
onChange={onChange}
/>
)}
</div>
);
}
+3
View File
@@ -423,6 +423,7 @@ export class ApiClient {
config_options?: Record<string, string>;
cli_passthrough?: boolean;
cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>;
command_prefix?: string;
env_vars?: Array<{ key: string; value?: string; secret_id?: string }>;
},
): Promise<{
@@ -436,6 +437,7 @@ export class ApiClient {
config_options: opts.config_options,
cli_passthrough: opts.cli_passthrough ?? false,
cli_flags: opts.cli_flags,
command_prefix: opts.command_prefix,
env_vars: opts.env_vars,
});
}
@@ -463,6 +465,7 @@ export class ApiClient {
config_options?: Record<string, string>;
cli_passthrough?: boolean;
cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>;
command_prefix?: string;
env_vars?: Array<{ key: string; value?: string; secret_id?: string }>;
},
): Promise<void> {
@@ -45,4 +45,61 @@ test.describe("Mobile agent profile config selector", () => {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
test("sets and saves a command prefix on the agent profile page", async ({
testPage,
apiClient,
}) => {
test.setTimeout(90_000);
const { agents } = await apiClient.listAgents();
const agent = agents[0];
const profile = await apiClient.createAgentProfile(agent.id, "Mobile Command Prefix Profile", {
model: "mock-fast",
});
try {
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
const prefixInput = testPage.getByTestId("command-prefix-input");
await expect(prefixInput).toBeVisible({ timeout: 15_000 });
await prefixInput.fill("greywall --");
const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first();
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
await saveButton.click();
await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 });
// Reload — the saved prefix must still be there.
await testPage.reload();
await expect(testPage.getByTestId("command-prefix-input")).toHaveValue("greywall --", {
timeout: 15_000,
});
// Direct DB-path verification: fetch the profile and assert the
// persisted command_prefix.
const stored = await apiClient.getAgentProfile(profile.id);
expect((stored as unknown as { command_prefix?: string }).command_prefix).toBe("greywall --");
// Clearing a previously-saved prefix must persist an empty value.
await testPage.getByTestId("command-prefix-input").fill("");
const saveButtonAfterClear = testPage
.getByRole("button", { name: /^Save( changes)?$/i })
.first();
await expect(saveButtonAfterClear).toBeEnabled({ timeout: 10_000 });
await saveButtonAfterClear.click();
await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 });
await testPage.reload();
await expect(testPage.getByTestId("command-prefix-input")).toHaveValue("", {
timeout: 15_000,
});
const storedAfterClear = await apiClient.getAgentProfile(profile.id);
expect(
(storedAfterClear as unknown as { command_prefix?: string }).command_prefix ?? "",
).toBe("");
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
});
@@ -3,11 +3,13 @@ import { normalizeAgentProfile, toAgentProfilePayload } from "./agent-profile-no
import { agentProfileId as toAgentProfileId } from "@/lib/types/ids";
const sampleEnvVar = { key: "ANTHROPIC_BASE_URL", value: "https://api.example" };
const SAMPLE_ID = "p1";
const SAMPLE_PREFIX = "greywall --";
describe("normalizeAgentProfile", () => {
it("converts snake_case wire payload to canonical camelCase", () => {
const wire = {
id: "p1",
id: SAMPLE_ID,
agent_id: "claude",
name: "default",
agent_display_name: "Claude Code",
@@ -24,7 +26,7 @@ describe("normalizeAgentProfile", () => {
};
const result = normalizeAgentProfile(wire);
expect(result).toEqual({
id: "p1",
id: SAMPLE_ID,
name: "default",
agentId: "claude",
agentDisplayName: "Claude Code",
@@ -53,7 +55,7 @@ describe("normalizeAgentProfile", () => {
it("accepts already-camelCase input", () => {
const result = normalizeAgentProfile({
id: "p1",
id: SAMPLE_ID,
name: "default",
agentId: "codex",
cliPassthrough: true,
@@ -61,12 +63,50 @@ describe("normalizeAgentProfile", () => {
expect(result.agentId).toBe("codex");
expect(result.cliPassthrough).toBe(true);
});
it("maps command_prefix to commandPrefix", () => {
const result = normalizeAgentProfile({
id: SAMPLE_ID,
name: "default",
command_prefix: SAMPLE_PREFIX,
});
expect(result.commandPrefix).toBe(SAMPLE_PREFIX);
});
it("accepts already-camelCase commandPrefix", () => {
const result = normalizeAgentProfile({
id: SAMPLE_ID,
name: "default",
commandPrefix: SAMPLE_PREFIX,
});
expect(result.commandPrefix).toBe(SAMPLE_PREFIX);
});
it("leaves commandPrefix undefined when absent", () => {
const result = normalizeAgentProfile({ id: "x", name: "y" });
expect(result.commandPrefix).toBeUndefined();
});
it("ignores a non-string command_prefix instead of propagating the raw value", () => {
const nullResult = normalizeAgentProfile({ id: "x", name: "y", command_prefix: null });
expect(nullResult.commandPrefix).toBeUndefined();
const numberResult = normalizeAgentProfile({ id: "x", name: "y", command_prefix: 42 });
expect(numberResult.commandPrefix).toBeUndefined();
const objectResult = normalizeAgentProfile({
id: "x",
name: "y",
commandPrefix: { flag: "greywall" },
});
expect(objectResult.commandPrefix).toBeUndefined();
});
});
describe("toAgentProfilePayload", () => {
it("converts canonical camelCase back to snake_case wire shape", () => {
const payload = toAgentProfilePayload({
id: toAgentProfileId("p1"),
id: toAgentProfileId(SAMPLE_ID),
agentId: "claude",
name: "default",
cliPassthrough: false,
@@ -74,7 +114,7 @@ describe("toAgentProfilePayload", () => {
envVars: [sampleEnvVar],
});
expect(payload).toEqual({
id: "p1",
id: SAMPLE_ID,
agent_id: "claude",
name: "default",
cli_passthrough: false,
@@ -84,8 +124,17 @@ describe("toAgentProfilePayload", () => {
});
it("omits undefined fields rather than emitting nullish keys", () => {
const payload = toAgentProfilePayload({ id: toAgentProfileId("p1"), name: "x" });
expect(payload).toEqual({ id: "p1", name: "x" });
const payload = toAgentProfilePayload({ id: toAgentProfileId(SAMPLE_ID), name: "x" });
expect(payload).toEqual({ id: SAMPLE_ID, name: "x" });
expect("agent_id" in payload).toBe(false);
});
it("maps commandPrefix to command_prefix", () => {
const payload = toAgentProfilePayload({
id: toAgentProfileId(SAMPLE_ID),
name: "default",
commandPrefix: SAMPLE_PREFIX,
});
expect(payload).toEqual({ id: SAMPLE_ID, name: "default", command_prefix: SAMPLE_PREFIX });
});
});
@@ -22,6 +22,11 @@ function pickBool(raw: RawProfile, camel: string, snake: string, fallback = fals
return typeof value === "boolean" ? value : fallback;
}
function pickOptionalString(raw: RawProfile, camel: string, snake: string): string | undefined {
const value = raw[camel] ?? raw[snake];
return typeof value === "string" ? value : undefined;
}
function pickFlags(raw: RawProfile): CLIFlag[] {
const value = raw.cliFlags ?? raw.cli_flags;
return Array.isArray(value) ? (value as CLIFlag[]) : [];
@@ -60,6 +65,7 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile {
allowIndexing: pickBool(profile, "allowIndexing", "allow_indexing"),
autoApprove: pickBool(profile, "autoApprove", "auto_approve"),
cliFlags: pickFlags(profile),
commandPrefix: pickOptionalString(profile, "commandPrefix", "command_prefix"),
envVars: pickEnvVars(profile),
cliPassthrough: pickBool(profile, "cliPassthrough", "cli_passthrough"),
userModified: (profile.userModified ?? profile.user_modified) as boolean | undefined,
@@ -96,6 +102,7 @@ export function toAgentProfilePayload(
setPayloadField(payload, "allow_indexing", profile.allowIndexing);
setPayloadField(payload, "auto_approve", profile.autoApprove);
setPayloadField(payload, "cli_flags", profile.cliFlags);
setPayloadField(payload, "command_prefix", profile.commandPrefix);
setPayloadField(payload, "env_vars", profile.envVars);
setPayloadField(payload, "cli_passthrough", profile.cliPassthrough);
setPayloadField(payload, "user_modified", profile.userModified);
+7
View File
@@ -85,6 +85,12 @@ export type AgentProfile = {
autoApprove: boolean;
/** User-configurable CLI flags passed to the agent subprocess. */
cliFlags: CLIFlag[];
/**
* Free-form tokens prepended to the agent subprocess launch command, e.g.
* `greywall --`. Used to wrap the ACP subprocess in a sandbox launcher.
* Shell-tokenised; empty means the agent runs directly.
*/
commandPrefix?: string;
/** Environment variables injected when this profile starts an agent session. */
envVars?: ProfileEnvVar[];
cliPassthrough: boolean;
@@ -156,6 +162,7 @@ export type AgentProfilePayload = {
allow_indexing: boolean;
auto_approve: boolean;
cli_flags: CLIFlag[];
command_prefix?: string;
env_vars?: ProfileEnvVar[];
cli_passthrough: boolean;
user_modified?: boolean;