Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
47 changes: 47 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -901,6 +904,50 @@ func buildScanContextCommand(repoDir, traceID string) string {
)
}

// collectOpenshellLogs extracts OpenShell logs (sandbox and gateway sources)
// into <runDir>/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)
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions internal/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,24 @@ func SCPFrom(sshConfigPath, sandboxName, remotePath, localPath string) error {
return nil
}

// CollectLogs runs `openshell logs <name> --source <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 {
Expand Down
15 changes: 15 additions & 0 deletions internal/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading