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
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@ Replace `SSHStreamReader` (lines 259-284) with:
// ExecStreamReader runs a command inside a sandbox, returning an io.ReadCloser for
// stdout so the caller can parse structured output. Stderr is forwarded to the
// given writer. The caller must read stdout to completion, then call cmd.Wait().
func ExecStreamReader(sandboxName, command string, timeout time.Duration, stderrW io.Writer) (io.ReadCloser, *exec.Cmd, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
func ExecStreamReader(ctx context.Context, sandboxName, command string, timeout time.Duration, stderrW io.Writer) (io.ReadCloser, *exec.Cmd, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
timeoutSecs := fmt.Sprintf("%d", int(timeout.Seconds()))

cmd := exec.CommandContext(ctx, "openshell", "sandbox", "exec",
Expand Down Expand Up @@ -751,7 +751,7 @@ In `runAgentWithProgress` (line 820):
stdout, cmd, cancel, err := sandbox.SSHStreamReader(sshConfigPath, sandboxName, claudeCmd, timeout, os.Stderr)

// After:
stdout, cmd, cancel, err := sandbox.ExecStreamReader(sandboxName, claudeCmd, timeout, os.Stderr)
stdout, cmd, cancel, err := sandbox.ExecStreamReader(ctx, sandboxName, claudeCmd, timeout, os.Stderr)
```

Also update the error message on line 839:
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
go runHeartbeat(printer, agentStart, timeout, heartbeatDone)

var metrics agentruntime.RunMetrics
exitCode, runErr := rt.Run(agentruntime.RunParams{
exitCode, runErr := rt.Run(ctx, agentruntime.RunParams{
SandboxName: sandboxName,
AgentBaseName: agentBaseName,
Model: h.Model,
Expand Down
5 changes: 3 additions & 2 deletions internal/runtime/claude.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package runtime

import (
"context"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -79,9 +80,9 @@ func (r ClaudeRuntime) Bootstrap(input BootstrapInput) error {
return installClaudeHooks(sandboxName, hooksInput.ClaudeSandboxHooks())
}

func (ClaudeRuntime) Run(params RunParams, printer *ui.Printer, start time.Time, metrics *RunMetrics) (int, error) {
func (ClaudeRuntime) Run(ctx context.Context, params RunParams, printer *ui.Printer, start time.Time, metrics *RunMetrics) (int, error) {
cmd := buildRunCommand(params)
stdout, execCmd, cancel, err := sandbox.ExecStreamReader(params.SandboxName, cmd, params.Timeout, os.Stderr)
stdout, execCmd, cancel, err := sandbox.ExecStreamReader(ctx, params.SandboxName, cmd, params.Timeout, os.Stderr)
if err != nil {
return -1, err
}
Expand Down
51 changes: 51 additions & 0 deletions internal/runtime/claude_test.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package runtime

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/fullsend-ai/fullsend/internal/sandbox"
"github.com/fullsend-ai/fullsend/internal/ui"
)

type bootstrapInput struct {
Expand Down Expand Up @@ -283,3 +287,50 @@ func TestBuildPluginConfigs_EmptyPluginList(t *testing.T) {
enabled := settings["enabledPlugins"].(map[string]any)
assert.Len(t, enabled, 0)
}

func TestClaudeRuntime_Run_OpenshellNotInPath(t *testing.T) {
t.Setenv("PATH", "")

var metrics RunMetrics
printer := ui.New(io.Discard)

exitCode, err := ClaudeRuntime{}.Run(context.Background(), RunParams{
SandboxName: "test-sandbox",
AgentBaseName: "test-agent",
RepoDir: "/sandbox/workspace/repo",
Timeout: 10 * time.Second,
}, printer, time.Now(), &metrics)

assert.Error(t, err)
assert.Equal(t, -1, exitCode)
}

func TestClaudeRuntime_Bootstrap_OpenshellNotInPath(t *testing.T) {
t.Setenv("PATH", "")

agentDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(agentDir, "agent.md"), []byte("test"), 0o644))

err := ClaudeRuntime{}.Bootstrap(bootstrapInput{
sandboxName: "test-sandbox",
agentPath: agentDir,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "creating runtime config dirs")
}

func TestClaudeRuntime_ClearIterationArtifacts_OpenshellNotInPath(t *testing.T) {
t.Setenv("PATH", "")

err := ClaudeRuntime{}.ClearIterationArtifacts("test-sandbox")
assert.Error(t, err)
}

func TestClaudeRuntime_ExtractTranscripts_OpenshellNotInPath(t *testing.T) {
t.Setenv("PATH", "")

outputDir := t.TempDir()
err := ClaudeRuntime{}.ExtractTranscripts("test-sandbox", "test-agent", outputDir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "finding transcripts")
}
3 changes: 2 additions & 1 deletion internal/runtime/runtime.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package runtime

import (
"context"
"sync/atomic"
"time"

Expand Down Expand Up @@ -39,7 +40,7 @@ type Runtime interface {
WorkspaceDir() string
EnvExports() []string
Bootstrap(input BootstrapInput) error
Run(params RunParams, printer *ui.Printer, start time.Time, metrics *RunMetrics) (exitCode int, err error)
Run(ctx context.Context, params RunParams, printer *ui.Printer, start time.Time, metrics *RunMetrics) (exitCode int, err error)
ClearIterationArtifacts(sandboxName string) error
}

Expand Down
18 changes: 15 additions & 3 deletions internal/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,15 @@ func Delete(name string) error {
}

// Exec runs a command inside a sandbox and returns stdout, stderr, and exit code.
// It uses context.Background() internally. Use ExecContext for cancellation support.
func Exec(sandboxName, command string, timeout time.Duration) (stdout, stderr string, exitCode int, err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout+10*time.Second)
return ExecContext(context.Background(), sandboxName, command, timeout)
}

// ExecContext is like Exec but accepts a parent context for cancellation.
// Cancelling the parent (e.g. on SIGTERM) terminates the subprocess.
func ExecContext(ctx context.Context, sandboxName, command string, timeout time.Duration) (stdout, stderr string, exitCode int, err error) {
ctx, cancel := context.WithTimeout(ctx, timeout+10*time.Second)
defer cancel()

timeoutSecs := fmt.Sprintf("%d", int(timeout.Seconds()))
Expand Down Expand Up @@ -352,8 +359,13 @@ func Exec(sandboxName, command string, timeout time.Duration) (stdout, stderr st
// ExecStreamReader runs a command inside a sandbox, returning an io.ReadCloser for
// stdout so the caller can parse structured output. Stderr is forwarded to the
// given writer. The caller must read stdout to completion, then call cmd.Wait().
func ExecStreamReader(sandboxName, command string, timeout time.Duration, stderrW io.Writer) (io.ReadCloser, *exec.Cmd, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
//
// The parent context is used as the base for the timeout context, so
// cancelling the parent (e.g. on SIGTERM) terminates the subprocess. This
// allows CLI-level signal handling to propagate into long-running sandbox
// commands.
func ExecStreamReader(ctx context.Context, sandboxName, command string, timeout time.Duration, stderrW io.Writer) (io.ReadCloser, *exec.Cmd, context.CancelFunc, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
timeoutSecs := fmt.Sprintf("%d", int(timeout.Seconds()))

cmd := exec.CommandContext(ctx, "openshell", "sandbox", "exec",
Expand Down
18 changes: 18 additions & 0 deletions internal/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sandbox

import (
"context"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -128,6 +129,23 @@ func TestExec_OpenshellNotInPath(t *testing.T) {
assert.Error(t, err)
}

func TestExecContext_CancelledContext(t *testing.T) {
t.Setenv("PATH", "")

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, _, _, err := ExecContext(ctx, "test-sandbox", "echo hello", 10*time.Second)
assert.Error(t, err)
}

Comment thread
ggallen marked this conversation as resolved.
func TestExecStreamReader_OpenshellNotInPath(t *testing.T) {
t.Setenv("PATH", "")

_, _, _, err := ExecStreamReader(context.Background(), "test-sandbox", "echo hello", 10*time.Second, os.Stderr)
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
assert.Error(t, err)
}

func TestOsRootContainment(t *testing.T) {
dir := t.TempDir()

Expand Down
Loading