Skip to content
Open
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
27 changes: 22 additions & 5 deletions go/cmd/amika/sandbox/sandbox_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ func buildAgentShellCmd(message string, noWait bool, workdir string, agent agent
return cmd
}

func buildRemoteAgentShellCmd(message string, noWait bool, workdir string, agent agentConfig, opts agentRunOpts) string {
agentStr := strings.Join(agentCmdPartsWithOpts(agent, fmt.Sprintf("%q", message), opts, !noWait), " ")
func buildRemoteAgentShellCmd(message string, noWait bool, workdir string, agent agentConfig, opts agentRunOpts, jsonOutput bool) string {
agentStr := strings.Join(agentCmdPartsWithOpts(agent, fmt.Sprintf("%q", message), opts, jsonOutput), " ")
cmd := fmt.Sprintf("cd %s && %s", workdir, agentStr)
if noWait {
sessionName := fmt.Sprintf("amika-agent-send-%d", time.Now().UnixNano())
Expand All @@ -125,12 +125,29 @@ func buildRemoteAgentShellCmd(message string, noWait bool, workdir string, agent
return cmd
}

func runRemoteAgentSend(client *apiclient.Client, name, message string, noWait bool, workdir string, agent agentConfig, opts agentRunOpts, stdout io.Writer) error {
func runRemoteAgentSend(client *apiclient.Client, name, message string, noWait bool, workdir string, agent agentConfig, opts agentRunOpts, stdout, stderr io.Writer) error {
if noWait {
shellCmd := buildRemoteAgentShellCmd(message, noWait, workdir, agent, opts)
shellCmd := buildRemoteAgentShellCmd(message, noWait, workdir, agent, opts, false)
return ssh.ExecSSH(client, name, false, []string{shellCmd})
}

// Server-managed sessions use the synchronous agent-send API so session IDs
// and structured responses stay on the control plane.
if opts.NewSession || opts.SessionID != "" {
return runRemoteAgentSendHTTP(client, name, message, agent, opts, stdout)
}

shellCmd := buildRemoteAgentShellCmd(message, false, workdir, agent, opts, false)
if err := ssh.RunSSH(client, name, []string{shellCmd}, nil, stdout, stderr); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 127 {
return fmt.Errorf("%s CLI not found in sandbox %q; was it created with the right preset?", agent.Binary, name)
}
return fmt.Errorf("agent-send failed for sandbox %q: %w", name, err)
}
return nil
}

func runRemoteAgentSendHTTP(client *apiclient.Client, name, message string, agent agentConfig, opts agentRunOpts, stdout io.Writer) error {
req := apiclient.AgentSendRequest{
Message: message,
NewSession: opts.NewSession,
Expand Down Expand Up @@ -244,7 +261,7 @@ Use --no-wait to send the message and return immediately.`,
newSession, _ := cmd.Flags().GetBool("new-session")
opts := agentRunOpts{SessionID: sessionID, NewSession: newSession}

if err := runRemoteAgentSend(client, name, message, noWait, workdir, agent, opts, os.Stdout); err != nil {
if err := runRemoteAgentSend(client, name, message, noWait, workdir, agent, opts, os.Stdout, os.Stderr); err != nil {
return err
}
if noWait {
Expand Down
21 changes: 14 additions & 7 deletions go/cmd/amika/sandbox/sandbox_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,22 @@ func TestAgentCmdPartsWithOpts(t *testing.T) {
func TestBuildRemoteAgentShellCmd(t *testing.T) {
claude := knownAgents["claude"]

t.Run("wait mode streaming omits json output", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{}, false)
if strings.Contains(got, "--output-format") {
t.Fatalf("cmd = %q, should not contain --output-format when streaming", got)
}
})

t.Run("wait mode includes json output", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{})
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{}, true)
if !strings.Contains(got, "--output-format json") {
t.Fatalf("cmd = %q, want --output-format json", got)
}
})

t.Run("no-wait mode has no json and wraps in tmux", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", true, "/home/amika", claude, agentRunOpts{})
got := buildRemoteAgentShellCmd("hello", true, "/home/amika", claude, agentRunOpts{}, false)
if strings.Contains(got, "--output-format") {
t.Fatalf("cmd = %q, should not contain --output-format in no-wait mode", got)
}
Expand All @@ -205,14 +212,14 @@ func TestBuildRemoteAgentShellCmd(t *testing.T) {
})

t.Run("session id maps to --resume", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{SessionID: "sess-42"})
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{SessionID: "sess-42"}, true)
if !strings.Contains(got, "--resume sess-42") {
t.Fatalf("cmd = %q, want --resume sess-42", got)
}
})

t.Run("new session passes no session flag to claude", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{NewSession: true})
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", claude, agentRunOpts{NewSession: true}, true)
if strings.Contains(got, "--new-session") {
t.Fatalf("cmd = %q, should not contain --new-session", got)
}
Expand All @@ -227,7 +234,7 @@ func TestBuildRemoteAgentShellCmd(t *testing.T) {
codex := knownAgents["codex"]

t.Run("codex wait mode includes --json", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", codex, agentRunOpts{})
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", codex, agentRunOpts{}, true)
if !strings.Contains(got, "--json") {
t.Fatalf("cmd = %q, want --json", got)
}
Expand All @@ -237,7 +244,7 @@ func TestBuildRemoteAgentShellCmd(t *testing.T) {
})

t.Run("codex no-wait wraps in tmux without json", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", true, "/home/amika", codex, agentRunOpts{})
got := buildRemoteAgentShellCmd("hello", true, "/home/amika", codex, agentRunOpts{}, false)
if strings.Contains(got, "--json") {
t.Fatalf("cmd = %q, should not contain --json in no-wait mode", got)
}
Expand All @@ -247,7 +254,7 @@ func TestBuildRemoteAgentShellCmd(t *testing.T) {
})

t.Run("codex session id uses resume subcommand", func(t *testing.T) {
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", codex, agentRunOpts{SessionID: "sess-42"})
got := buildRemoteAgentShellCmd("hello", false, "/home/amika", codex, agentRunOpts{SessionID: "sess-42"}, true)
if !strings.Contains(got, "codex exec resume") {
t.Fatalf("cmd = %q, want 'codex exec resume'", got)
}
Expand Down
22 changes: 2 additions & 20 deletions go/internal/materialize/materialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
package materialize

import (
"bytes"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -69,9 +68,7 @@ func Run(opts Options) error {
cmd.Env = append(os.Environ(), opts.Env...)
}
cmd.Stdout = os.Stdout

var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
cmd.Stderr = os.Stderr

// Print header before execution
if hasScript {
Expand All @@ -86,25 +83,10 @@ func Run(opts Options) error {
if hasCmd {
label = "Command"
}
captured := strings.TrimRight(stderrBuf.String(), "\n")
if captured != "" {
lines := strings.Split(captured, "\n")
quoted := make([]string, len(lines))
for i, line := range lines {
quoted[i] = "> " + line
}
fmt.Fprintf(os.Stderr, "%s failed to run:\n\n%s\n\n", label, strings.Join(quoted, "\n"))
} else {
fmt.Fprintf(os.Stderr, "%s failed to run.\n", label)
}
fmt.Fprintf(os.Stderr, "%s failed to run.\n", label)
return fmt.Errorf("execution failed: %w", err)
}

// On success, write captured stderr through so it's still visible
if stderrBuf.Len() > 0 {
stderrBuf.WriteTo(os.Stderr)
}

// Copy outdir contents to destdir using rsync
rsyncCmd := exec.Command("rsync", "-a", opts.Outdir+"/", opts.Destdir+"/")
rsyncCmd.Stdout = os.Stdout
Expand Down
30 changes: 30 additions & 0 deletions go/internal/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package ssh

import (
"fmt"
"io"
"os"
"os/exec"
"strings"
Expand Down Expand Up @@ -42,3 +43,32 @@ func ExecSSH(client *apiclient.Client, name string, forcePTY bool, extraArgs []s
}
return syscall.Exec(sshBin, append([]string{"ssh"}, sshArgs...), os.Environ())
}

// RunSSH runs a remote command over SSH, streaming stdin/stdout/stderr to the
// provided writers. Unlike ExecSSH, it does not replace the current process,
// so callers can observe output as it arrives and inspect the exit status.
func RunSSH(client *apiclient.Client, name string, extraArgs []string, stdin io.Reader, stdout, stderr io.Writer) error {
info, err := client.GetSSH(name)
if err != nil {
return err
}
if info.SSHDestination == "" {
return fmt.Errorf("server returned empty SSH destination")
}

sshArgs := strings.Fields(info.SSHDestination)
if len(extraArgs) > 0 {
sshArgs = append(sshArgs, extraArgs...)
}

sshBin, err := exec.LookPath("ssh")
if err != nil {
return fmt.Errorf("ssh not found: %w", err)
}

cmd := exec.Command(sshBin, append([]string{"ssh"}, sshArgs...)...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
return cmd.Run()
}
43 changes: 43 additions & 0 deletions go/internal/ssh/ssh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package ssh

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/gofixpoint/amika/go/internal/apiclient"
)

func TestRunSSHStreamsOutput(t *testing.T) {
binDir := t.TempDir()
sshPath := filepath.Join(binDir, "ssh")
script := "#!/bin/sh\nfor arg in \"$@\"; do case \"$arg\" in echo*) eval \"$arg\" ;; esac; done\n"
if err := os.WriteFile(sshPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/ssh") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ssh_destination":"example.com","token":"tok","expires_at":"2099-01-01T00:00:00Z"}`))
}))
t.Cleanup(server.Close)

client := apiclient.NewClient(server.URL, "test-token")
var stdout bytes.Buffer
if err := RunSSH(client, "sb-1", []string{"echo hello"}, nil, &stdout, io.Discard); err != nil {
t.Fatalf("RunSSH() error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "hello" {
t.Fatalf("stdout = %q, want %q", got, "hello")
}
}