diff --git a/internal/cli/run.go b/internal/cli/run.go index 1285cb1d95..d28e5b2807 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -239,6 +239,9 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo string, printer *ui }() } defer func() { + // Collect OpenShell logs before sandbox deletion for post-mortem debugging. + collectOpenshellLogs(sandboxName, runDir, printer) + cleanupStart := time.Now() printer.StepStart("Cleaning up sandbox") if err := sandbox.Delete(sandboxName); err != nil { @@ -901,6 +904,50 @@ func buildScanContextCommand(repoDir, traceID string) string { ) } +// collectOpenshellLogs extracts OpenShell logs (sandbox and gateway sources) +// into /logs/ before sandbox deletion. Failures are warned but never +// block the run — log collection is best-effort. +func collectOpenshellLogs(sandboxName, runDir string, printer *ui.Printer) { + if runDir == "" { + return + } + + logsDir := filepath.Join(runDir, "logs") + if err := os.MkdirAll(logsDir, 0o755); err != nil { + printer.StepWarn("Failed to create logs directory: " + err.Error()) + return + } + + printer.StepStart("Collecting OpenShell logs") + collected := 0 + + sources := []struct { + name string + file string + }{ + {"sandbox", "openshell-sandbox.log"}, + {"gateway", "openshell-gateway.log"}, + } + + for _, src := range sources { + output, err := sandbox.CollectLogs(sandboxName, src.name) + if err != nil { + printer.StepWarn(fmt.Sprintf("Could not collect %s logs: %s", src.name, err.Error())) + continue + } + logPath := filepath.Join(logsDir, src.file) + if err := os.WriteFile(logPath, []byte(output), 0o644); err != nil { + printer.StepWarn(fmt.Sprintf("Could not write %s: %s", src.file, err.Error())) + continue + } + collected++ + } + + if collected > 0 { + printer.StepDone(fmt.Sprintf("Collected %d OpenShell log source(s) to %s", collected, logsDir)) + } +} + // relOrAbs returns path relative to base, falling back to the absolute path if Rel fails. func relOrAbs(base, path string) string { rel, err := filepath.Rel(base, path) diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 520c9abf12..add92d872b 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -1,10 +1,15 @@ package cli import ( + "io" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/ui" ) func TestRunCommand_RequiresAgentName(t *testing.T) { @@ -81,6 +86,29 @@ func TestBuildScanContextCommand_SourcesEnv(t *testing.T) { assert.Contains(t, cmd, "-exec fullsend scan context") } +func TestCollectOpenshellLogs_EmptyRunDir(t *testing.T) { + // Should be a no-op when runDir is empty — no panic, no error. + printer := ui.New(io.Discard) + collectOpenshellLogs("test-sandbox", "", printer) +} + +func TestCollectOpenshellLogs_CreatesLogsDir(t *testing.T) { + // collectOpenshellLogs should create the logs/ directory and attempt + // log collection. openshell is not available in test, so we expect + // warnings but no panic. + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run") + require.NoError(t, os.MkdirAll(runDir, 0o755)) + + printer := ui.New(io.Discard) + collectOpenshellLogs("nonexistent-sandbox", runDir, printer) + + // The logs directory should be created even if collection fails. + logsDir := filepath.Join(runDir, "logs") + _, err := os.Stat(logsDir) + assert.NoError(t, err, "logs directory should exist") +} + func TestEnvToList_Sorted(t *testing.T) { env := map[string]string{ "Z_VAR": "z", diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 3e283f243a..2da531e347 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -339,6 +339,24 @@ func SCPFrom(sshConfigPath, sandboxName, remotePath, localPath string) error { return nil } +// CollectLogs runs `openshell logs --source -n 0` and returns +// the log output. The -n 0 flag requests all available log lines (no limit). +// This is a host-side command that talks to the gateway — no SSH needed. +func CollectLogs(name, source string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "openshell", "logs", name, "--source", source, "-n", "0") + out, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("openshell logs %q --source %s timed out after 30s", name, source) + } + return "", fmt.Errorf("openshell logs %q --source %s: %s", name, source, string(out)) + } + return string(out), nil +} + // ExtractTranscripts copies Claude transcript files (.jsonl) from the sandbox // to a local output directory. func ExtractTranscripts(sshConfigPath, sandboxName, agentName, outputDir string) error { diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index f69e2b3f85..09617cbf72 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -104,6 +104,21 @@ func TestBuildProviderArgs_EmptyCredential(t *testing.T) { assert.Empty(t, secrets) } +func TestCollectLogs_OpenshellNotInPath(t *testing.T) { + t.Setenv("PATH", "") + + _, err := CollectLogs("nonexistent-sandbox", "sandbox") + assert.Error(t, err) +} + +func TestCollectLogs_InvalidSource(t *testing.T) { + // When openshell is not in PATH, any source should fail. + t.Setenv("PATH", "") + + _, err := CollectLogs("test-sandbox", "invalid-source") + assert.Error(t, err) +} + func TestPathTraversalContainment(t *testing.T) { // Simulate the containment check used in ExtractOutputFiles. localDir := "/tmp/output"