From b4239d885ec741cf270e2d80ea6b3db6d9f8e91e Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 24 Jul 2026 03:29:37 -0600 Subject: [PATCH] feat(agents): add configurable command prefix for sandboxed ACP launch (#1888) --- apps/backend/internal/agent/agents/agent.go | 5 + .../agent/runtime/lifecycle/command.go | 19 ++- .../runtime/lifecycle/manager_interaction.go | 45 +++++-- .../agent/runtime/lifecycle/manager_launch.go | 95 ++++++++++---- .../runtime/lifecycle/manager_launch_test.go | 81 ++++++++++-- .../runtime/lifecycle/profile_resolver.go | 1 + .../internal/agent/runtime/lifecycle/types.go | 3 + .../agent/settings/controller/agent_config.go | 9 ++ .../agent/settings/controller/agent_crud.go | 34 ++++- .../agent/settings/controller/controller.go | 1 + .../agent/settings/controller/profile_crud.go | 43 +++++++ .../settings/controller/profile_crud_test.go | 76 +++++++++++ .../internal/agent/settings/dto/dto.go | 6 +- .../agent/settings/handlers/handlers.go | 36 ++++-- .../internal/agent/settings/models/models.go | 7 ++ .../internal/agent/settings/store/sqlite.go | 22 +++- .../store/sqlite_command_prefix_test.go | 82 ++++++++++++ apps/web/app/actions/agents.ts | 4 + .../[agentId]/agent-save-helpers.test.ts | 119 +++++++++++++++++- .../agents/[agentId]/agent-save-helpers.ts | 17 ++- .../agents/[agentId]/agent-setup-parts.tsx | 1 + .../[profileId]/command-preview-card.test.tsx | 69 ++++++++++ .../[profileId]/command-preview-card.tsx | 21 +++- apps/web/components/onboarding-dialog.tsx | 2 + .../settings/agent-profile-page.tsx | 5 + .../settings/command-prefix-field.test.tsx | 79 ++++++++++++ .../settings/command-prefix-field.tsx | 41 ++++++ .../settings/profile-form-fields.test.tsx | 54 ++++++++ .../settings/profile-form-fields.tsx | 10 ++ apps/web/e2e/helpers/api-client.ts | 3 + ...bile-agent-profile-config-selector.spec.ts | 57 +++++++++ .../domains/agent-profile-normalize.test.ts | 63 ++++++++-- .../api/domains/agent-profile-normalize.ts | 7 ++ apps/web/lib/types/agent-profile.ts | 7 ++ 34 files changed, 1040 insertions(+), 84 deletions(-) create mode 100644 apps/backend/internal/agent/settings/store/sqlite_command_prefix_test.go create mode 100644 apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.test.tsx create mode 100644 apps/web/components/settings/command-prefix-field.test.tsx create mode 100644 apps/web/components/settings/command-prefix-field.tsx create mode 100644 apps/web/components/settings/profile-form-fields.test.tsx diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 7e5bc6419e..fe895df0fd 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -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 …"). + 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 diff --git a/apps/backend/internal/agent/runtime/lifecycle/command.go b/apps/backend/internal/agent/runtime/lifecycle/command.go index e713d30840..9b497cdfd9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/command.go +++ b/apps/backend/internal/agent/runtime/lifecycle/command.go @@ -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 " into "greywall -- npx -y "). 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(), " ") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index bd13aca6aa..287119e0f9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -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 } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index e93f6ba893..ac8ae10506 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -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 diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index 327af9812e..b5c1a1771e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -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{ diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go index 1dd5921c40..f3c69807ef 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go @@ -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, diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 15a8df93dd..4d997f9027 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index 3cf632e8ac..15280a1871 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -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{ diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 49505fd5e8..f97d7a6f5e 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 6239832a63..1786d17e07 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -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 { diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 83a41797e1..15b4cf821b 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -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, diff --git a/apps/backend/internal/agent/settings/controller/profile_crud_test.go b/apps/backend/internal/agent/settings/controller/profile_crud_test.go index dc5b820687..7a902a5b72 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index e14bdd348b..2eab5480db 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -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 diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index f5c93dfffc..40229d1f2c 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -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)) diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index d72ac8e035..d6871ea61d 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -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 …"). + // 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 diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index ee7dc2cf90..6e28b87f75 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -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 } diff --git a/apps/backend/internal/agent/settings/store/sqlite_command_prefix_test.go b/apps/backend/internal/agent/settings/store/sqlite_command_prefix_test.go new file mode 100644 index 0000000000..22444eebbd --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_command_prefix_test.go @@ -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) + } +} diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 545f393da2..596ae2cf83 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -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; cli_passthrough: boolean; cli_flags?: CLIFlag[]; + command_prefix?: string; env_vars?: ProfileEnvVar[]; } & ProfilePermissions, ): Promise { @@ -134,6 +136,7 @@ export async function updateAgentProfileAction( auto_approve?: boolean; cli_passthrough?: boolean; cli_flags?: CLIFlag[]; + command_prefix?: string; env_vars?: ProfileEnvVar[]; }, ): Promise { @@ -221,6 +224,7 @@ export type CommandPreviewRequest = { permission_settings: Record; cli_passthrough: boolean; cli_flags: CLIFlag[]; + command_prefix?: string; }; export type CommandPreviewResponse = { diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts index dacbf67de4..f589ff1d0c 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts @@ -48,7 +48,9 @@ const draftFrom = (saved: AgentProfile, overrides: Partial = {}): }); 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", diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts index a841ff80c7..e9ea2b0879 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts @@ -36,6 +36,7 @@ export function toAgentProfilePatch(patch: Partial): Partial ({ + 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((resolve) => { + resolveStale = resolve; + }); + previewAgentCommandActionMock.mockImplementationOnce(() => stalePromise); + + const { rerender } = render( + , + ); + + // Wait past the debounce so the stale request has actually fired. + await waitFor(() => expect(previewAgentCommandActionMock).toHaveBeenCalledTimes(1)); + + previewAgentCommandActionMock.mockImplementationOnce(async () => response("agent --new")); + rerender( + , + ); + + 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(); + }); +}); diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx index 013a6c1f6d..6c81522833 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx @@ -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; 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 ( diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index f6698508cc..5ad8abda3c 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -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, }), ), ); diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index b99f1f613c..24ebf38050 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -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} /> diff --git a/apps/web/components/settings/command-prefix-field.test.tsx b/apps/web/components/settings/command-prefix-field.test.tsx new file mode 100644 index 0000000000..ab535a00bc --- /dev/null +++ b/apps/web/components/settings/command-prefix-field.test.tsx @@ -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 { + 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( + , + ); + + expect(dirtyState()).toBe("false"); + }); + + it("marks the field dirty when a prefix is set that was previously empty", () => { + render( + , + ); + + expect(dirtyState()).toBe("true"); + }); + + it("marks the field dirty when a previously-saved prefix is cleared", () => { + render( + , + ); + + expect(dirtyState()).toBe("true"); + }); + + it("reports the cleared value through onChange", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByTestId("command-prefix-input"), { target: { value: "" } }); + expect(onChange).toHaveBeenCalledWith({ command_prefix: "" }); + }); +}); diff --git a/apps/web/components/settings/command-prefix-field.tsx b/apps/web/components/settings/command-prefix-field.tsx new file mode 100644 index 0000000000..4d772339a2 --- /dev/null +++ b/apps/web/components/settings/command-prefix-field.tsx @@ -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) => void; +}) { + return ( +
+ + onChange({ command_prefix: event.target.value })} + placeholder="e.g. greywall --" + /> +

+ Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g.{" "} + greywall --). 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. +

+
+ ); +} diff --git a/apps/web/components/settings/profile-form-fields.test.tsx b/apps/web/components/settings/profile-form-fields.test.tsx new file mode 100644 index 0000000000..3959a65bf1 --- /dev/null +++ b/apps/web/components/settings/profile-form-fields.test.tsx @@ -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 { + return { + name: "Profile", + model: "mock-fast", + mode: "", + cli_passthrough: false, + cli_flags: [], + command_prefix: "", + ...overrides, + } as ProfileFormData; +} + +function renderForm(profile: ProfileFormData) { + return render( + + + , + ); +} + +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(); + }); +}); diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index 39c8fa5222..c95fe26402 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -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; cli_passthrough: boolean; cli_flags: CLIFlag[]; + command_prefix?: string; } & Record; export type ProfileFormFieldsProps = { @@ -613,6 +615,14 @@ export function ProfileFormFields({ hideCustomFlags={hideCustomCLIFlags} /> + + {!profile.cli_passthrough && ( + + )} ); } diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index ded283e8fa..3af3f657c3 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -423,6 +423,7 @@ export class ApiClient { config_options?: Record; 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; 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 { diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts index 92d2c8fca3..ae4c7f4015 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts @@ -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); + } + }); }); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.test.ts b/apps/web/lib/api/domains/agent-profile-normalize.test.ts index f4838cce27..439295a0b4 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.test.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.test.ts @@ -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 }); + }); }); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index 2a241cf5ef..ad3f3db49d 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -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); diff --git a/apps/web/lib/types/agent-profile.ts b/apps/web/lib/types/agent-profile.ts index 27ef3509c7..a25f3ceb59 100644 --- a/apps/web/lib/types/agent-profile.ts +++ b/apps/web/lib/types/agent-profile.ts @@ -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;