feat(agents): add configurable command prefix for sandboxed ACP launch (#1888)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+69
@@ -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();
|
||||
});
|
||||
});
|
||||
+16
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user