Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func newRunCmd() *cobra.Command {
var fullsendBinary string
var envFiles []string
var noPostScript bool
var debugFilter string

cmd := &cobra.Command{
Use: "run <agent-name>",
Expand All @@ -47,7 +48,7 @@ func newRunCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
agentName := args[0]
printer := ui.New(os.Stdout)
return runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary, envFiles, noPostScript, printer)
return runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary, envFiles, noPostScript, debugFilter, printer)
},
}

Expand All @@ -57,13 +58,15 @@ func newRunCmd() *cobra.Command {
cmd.Flags().StringVar(&fullsendBinary, "fullsend-binary", "", "path to a Linux fullsend binary to copy into the sandbox (default: current executable)")
cmd.Flags().StringArrayVar(&envFiles, "env-file", nil, "load environment variables from a dotenv file (repeatable)")
cmd.Flags().BoolVar(&noPostScript, "no-post-script", false, "skip post-script execution (agent still runs full inference)")
cmd.Flags().StringVar(&debugFilter, "debug", "", `enable Claude Code debug logging with optional category filter (e.g. "api,hooks")`)
cmd.Flags().Lookup("debug").NoOptDefVal = "*"
_ = cmd.MarkFlagRequired("fullsend-dir")
_ = cmd.MarkFlagRequired("target-repo")

return cmd
}

func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary string, envFiles []string, noPostScript bool, printer *ui.Printer) (runErr error) {
func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary string, envFiles []string, noPostScript bool, debug string, printer *ui.Printer) (runErr error) {
printer.Banner()
printer.Blank()
printer.Header("Running agent: " + agentName)
Expand Down Expand Up @@ -421,7 +424,7 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str
for _, p := range h.Plugins {
pluginDirs = append(pluginDirs, fmt.Sprintf("%s/plugins/%s", sandbox.SandboxClaudeConfig, filepath.Base(p)))
}
claudeCmd := buildClaudeCommand(agentBaseName, h.Model, repoDir, pluginDirs)
claudeCmd := buildClaudeCommand(agentBaseName, h.Model, repoDir, pluginDirs, debug)

timeout := time.Duration(h.TimeoutMinutes) * time.Minute
if timeout == 0 {
Expand Down Expand Up @@ -536,6 +539,14 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str
printer.StepDone(fmt.Sprintf("Transcripts extracted (%.1fs)", time.Since(transcriptStart).Seconds()))
}

// Extract debug log if --debug was enabled.
if debug != "" {
debugDst := filepath.Join(iterDir, claudeDebugLog)
if err := sandbox.DownloadFile(sandboxName, sandbox.SandboxWorkspace+"/"+claudeDebugLog, debugDst); err == nil {
printer.StepInfo("Extracted claude-debug.log")
}
}

// 9d. Extract target repo back to host. SafeDownload removes symlinks
// and .git/hooks/ after download to prevent sandbox escape.
repoExtractStart := time.Now()
Expand Down Expand Up @@ -1055,7 +1066,7 @@ func refreshOIDCToken(ctx context.Context, sandboxName, oidcURL, oidcAuth string
return nil
}

func buildClaudeCommand(agentName, model, repoDir string, pluginDirs []string) string {
func buildClaudeCommand(agentName, model, repoDir string, pluginDirs []string, debug string) string {
envFile := sandbox.SandboxWorkspace + "/.env"

// Defense-in-depth: escape single quotes even though Validate() rejects them.
Expand All @@ -1075,15 +1086,25 @@ func buildClaudeCommand(agentName, model, repoDir string, pluginDirs []string) s
pluginDirFlags = strings.Join(pluginDirParts, " ") + " "
}

debugFlags := ""
if debug == "*" {
debugFlags = fmt.Sprintf("--debug-file %s/%s ", sandbox.SandboxWorkspace, claudeDebugLog)
} else if debug != "" {
debugFlags = fmt.Sprintf("--debug-file %s/%s --debug '%s' ",
sandbox.SandboxWorkspace, claudeDebugLog, strings.ReplaceAll(debug, "'", "'\\''"))
}
Comment thread
gklein marked this conversation as resolved.

return fmt.Sprintf(
// --verbose increases log output in the job log. If artifact upload is
// added to this workflow, consider whether verbose output should be
// redacted or made conditional via an env var.
"cd %s && . %s && claude --print --verbose --output-format stream-json %s%s--agent '%s' --dangerously-skip-permissions 'Run the agent task'",
repoDir, envFile, modelFlag, pluginDirFlags, safe,
"cd %s && . %s && claude --print --verbose --output-format stream-json %s%s%s--agent '%s' --dangerously-skip-permissions 'Run the agent task'",
Comment thread
gklein marked this conversation as resolved.
repoDir, envFile, debugFlags, modelFlag, pluginDirFlags, safe,
)
}

const claudeDebugLog = "claude-debug.log"
Comment thread
rh-hemartin marked this conversation as resolved.
Outdated

// maxContextScanDepth is the maximum directory depth for scanning context
// files. Shared between host-side (scanRepoContextFiles) and sandbox-side
// (buildScanContextCommand) scans to ensure parity.
Expand Down
32 changes: 25 additions & 7 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,49 +81,67 @@ func TestRunCommand_HasTargetRepoFlag(t *testing.T) {
}

func TestBuildClaudeCommand_Basic(t *testing.T) {
cmd := buildClaudeCommand("hello-world", "", "/tmp/workspace/repo", nil)
cmd := buildClaudeCommand("hello-world", "", "/tmp/workspace/repo", nil, "")
assert.Contains(t, cmd, "cd /tmp/workspace/repo")
assert.Contains(t, cmd, "--agent 'hello-world'")
assert.NotContains(t, cmd, "--model")
assert.NotContains(t, cmd, "--plugin-dir")
}

func TestBuildClaudeCommand_WithModel(t *testing.T) {
cmd := buildClaudeCommand("hello-world", "sonnet", "/tmp/workspace/repo", nil)
cmd := buildClaudeCommand("hello-world", "sonnet", "/tmp/workspace/repo", nil, "")
assert.Contains(t, cmd, "--model 'sonnet'")
assert.Contains(t, cmd, "--agent 'hello-world'")
}

func TestBuildClaudeCommand_EscapesQuotes(t *testing.T) {
cmd := buildClaudeCommand("test'name", "", "/tmp/workspace/repo", nil)
cmd := buildClaudeCommand("test'name", "", "/tmp/workspace/repo", nil, "")
assert.NotContains(t, cmd, "'test'name'")
assert.Contains(t, cmd, "'test'\\''name'")
}

func TestBuildClaudeCommand_WithPluginDirs(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{"/tmp/claude-config/plugins/gopls-lsp"})
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{"/tmp/claude-config/plugins/gopls-lsp"}, "")
assert.Contains(t, cmd, "--plugin-dir '/tmp/claude-config/plugins/gopls-lsp'")
}

func TestBuildClaudeCommand_MultiplePluginDirs(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{
"/tmp/claude-config/plugins/gopls-lsp",
"/tmp/claude-config/plugins/other-lsp",
})
}, "")
assert.Contains(t, cmd, "--plugin-dir '/tmp/claude-config/plugins/gopls-lsp'")
assert.Contains(t, cmd, "--plugin-dir '/tmp/claude-config/plugins/other-lsp'")
}

func TestBuildClaudeCommand_PluginDirEscapesQuotes(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{"/tmp/path'with'quotes"})
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", []string{"/tmp/path'with'quotes"}, "")
assert.Contains(t, cmd, "--plugin-dir '/tmp/path'\\''with'\\''quotes'")
}

func TestBuildClaudeCommand_NoPlugins(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", nil)
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", nil, "")
assert.NotContains(t, cmd, "--plugin-dir")
}

func TestBuildClaudeCommand_DebugAll(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", nil, "*")
assert.Contains(t, cmd, "--debug-file /tmp/workspace/claude-debug.log")
assert.NotContains(t, cmd, "--debug '")
}

func TestBuildClaudeCommand_DebugFiltered(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", nil, "api,hooks")
assert.Contains(t, cmd, "--debug-file /tmp/workspace/claude-debug.log")
assert.Contains(t, cmd, "--debug 'api,hooks'")
}

func TestBuildClaudeCommand_DebugDisabled(t *testing.T) {
cmd := buildClaudeCommand("agent", "", "/tmp/workspace/repo", nil, "")
assert.NotContains(t, cmd, "--debug-file")
assert.NotContains(t, cmd, "--debug")
}

func TestBuildPluginConfigs_SinglePlugin(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "gopls-lsp")
Expand Down
Loading