Skip to content

Commit 9026c9c

Browse files
Merge pull request #141 from langchain-ai/ramonn/sandbox-structured-commands
refactor(sandbox): use structured commands
2 parents efd63c4 + 0eedaf8 commit 9026c9c

8 files changed

Lines changed: 277 additions & 160 deletions

File tree

internal/cmd/sandbox_box.go

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -417,60 +417,59 @@ var sandboxStopCommand = structured.Command[struct{}]{
417417
`),
418418
}
419419

420-
func newSandboxExecCmd() *cobra.Command {
421-
cmd := &cobra.Command{
422-
Use: "exec <name> -- <command>",
423-
Short: "Execute a command inside a sandbox",
424-
Long: `Execute a one-off command inside a running sandbox and print its output.
420+
type sandboxExecInput struct{}
421+
422+
var sandboxExecCommand = structured.Command[sandboxExecInput]{
423+
Use: "exec <name> -- <command>",
424+
Short: "Execute a command inside a sandbox",
425+
Long: `Execute a one-off command inside a running sandbox and print its output.
425426
426427
Examples:
427428
langsmith sandbox exec my-vm -- uname -a
428429
langsmith sandbox exec my-vm -- ls -la /
429430
langsmith sandbox exec my-vm -- cat /etc/os-release`,
430-
Args: cobra.MinimumNArgs(1),
431-
DisableFlagParsing: false,
432-
RunE: func(cmd *cobra.Command, args []string) error {
433-
name := args[0]
434-
435-
// Everything after "--" is the command.
436-
cmdArgs := cmd.ArgsLenAtDash()
437-
if cmdArgs < 0 || cmdArgs >= len(args) {
438-
return fmt.Errorf("usage: langsmith sandbox exec <name> -- <command>")
439-
}
440-
command := args[cmdArgs:]
441-
if len(command) == 0 {
442-
return fmt.Errorf("no command specified")
443-
}
431+
Args: cobra.MinimumNArgs(1),
432+
Input: func(cmd *cobra.Command) sandboxExecInput { return sandboxExecInput{} },
433+
CustomOutput: true,
434+
Action: func(ctx context.Context, cmd *cobra.Command, in sandboxExecInput, args []string) (any, error) {
435+
name := args[0]
444436

445-
ctx := cmd.Context()
446-
if ctx == nil {
447-
ctx = context.Background()
448-
}
449-
c, err := cmdutil.GetClient(cmd)
450-
if err != nil {
451-
return err
452-
}
437+
cmdArgs := cmd.ArgsLenAtDash()
438+
if cmdArgs < 0 || cmdArgs >= len(args) {
439+
return nil, fmt.Errorf("usage: langsmith sandbox exec <name> -- <command>")
440+
}
441+
command := args[cmdArgs:]
442+
if len(command) == 0 {
443+
return nil, fmt.Errorf("no command specified")
444+
}
453445

454-
result, err := c.SDK.Sandboxes.Boxes.Run(ctx, name, langsmith.SandboxBoxRunParams{
455-
Command: langsmith.F(sandboxShellCommand(command)),
456-
})
457-
if err != nil {
458-
return fmt.Errorf("execute: %w", err)
459-
}
446+
c, err := cmdutil.GetClient(cmd)
447+
if err != nil {
448+
return nil, err
449+
}
460450

461-
if result.Stdout != "" {
462-
fmt.Print(result.Stdout)
463-
}
464-
if result.Stderr != "" {
465-
fmt.Fprint(os.Stderr, result.Stderr)
466-
}
467-
if result.ExitCode != 0 {
468-
os.Exit(int(result.ExitCode))
469-
}
470-
return nil
471-
},
472-
}
473-
return cmd
451+
result, err := c.SDK.Sandboxes.Boxes.Run(ctx, name, langsmith.SandboxBoxRunParams{
452+
Command: langsmith.F(sandboxShellCommand(command)),
453+
})
454+
if err != nil {
455+
return nil, fmt.Errorf("execute: %w", err)
456+
}
457+
458+
if result.Stdout != "" {
459+
fmt.Print(result.Stdout)
460+
}
461+
if result.Stderr != "" {
462+
fmt.Fprint(os.Stderr, result.Stderr)
463+
}
464+
if result.ExitCode != 0 {
465+
os.Exit(int(result.ExitCode))
466+
}
467+
return nil, nil
468+
},
469+
}
470+
471+
func newSandboxExecCmd() *cobra.Command {
472+
return sandboxExecCommand.Cobra()
474473
}
475474

476475
func sandboxShellCommand(args []string) string {

internal/cmd/sandbox_console.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,21 @@ import (
1111
"sync"
1212
"syscall"
1313

14+
"github.com/langchain-ai/langsmith-cli/internal/structured"
1415
langsmith "github.com/langchain-ai/langsmith-go"
1516
"github.com/spf13/cobra"
1617
"golang.org/x/term"
1718
)
1819

19-
func newSandboxConsoleCmd() *cobra.Command {
20-
cmd := &cobra.Command{
21-
Use: "console <name>",
22-
Short: "Open an interactive shell inside a sandbox",
23-
Long: `Open an interactive terminal session inside a running sandbox.
20+
type sandboxConsoleInput struct {
21+
Shell string
22+
ForwardSSHAgent bool
23+
}
24+
25+
var sandboxConsoleCommand = structured.Command[*sandboxConsoleInput]{
26+
Use: "console <name>",
27+
Short: "Open an interactive shell inside a sandbox",
28+
Long: `Open an interactive terminal session inside a running sandbox.
2429
2530
Connects via WebSocket to the sandbox daemon and allocates a PTY,
2631
giving you a full interactive shell (bash by default).
@@ -29,18 +34,21 @@ Examples:
2934
langsmith sandbox console my-vm
3035
langsmith sandbox console my-vm --shell /bin/sh
3136
langsmith sandbox console my-vm --forward-ssh-agent`,
32-
Args: cobra.ExactArgs(1),
33-
RunE: func(cmd *cobra.Command, args []string) error {
34-
shell, _ := cmd.Flags().GetString("shell")
35-
forwardSSHAgent, _ := cmd.Flags().GetBool("forward-ssh-agent")
36-
return runConsole(args[0], shell, forwardSSHAgent)
37-
},
38-
}
39-
40-
cmd.Flags().String("shell", "", "Shell to use (default: sandbox default, usually /bin/bash)")
41-
cmd.Flags().Bool("forward-ssh-agent", false, "Forward the local SSH agent (SSH_AUTH_SOCK) into the sandbox")
37+
Args: cobra.ExactArgs(1),
38+
Input: func(cmd *cobra.Command) *sandboxConsoleInput {
39+
in := &sandboxConsoleInput{}
40+
cmd.Flags().StringVar(&in.Shell, "shell", in.Shell, "Shell to use (default: sandbox default, usually /bin/bash)")
41+
cmd.Flags().BoolVar(&in.ForwardSSHAgent, "forward-ssh-agent", in.ForwardSSHAgent, "Forward the local SSH agent (SSH_AUTH_SOCK) into the sandbox")
42+
return in
43+
},
44+
CustomOutput: true,
45+
Action: func(ctx context.Context, cmd *cobra.Command, in *sandboxConsoleInput, args []string) (any, error) {
46+
return nil, runConsole(args[0], in.Shell, in.ForwardSSHAgent)
47+
},
48+
}
4249

43-
return cmd
50+
func newSandboxConsoleCmd() *cobra.Command {
51+
return sandboxConsoleCommand.Cobra()
4452
}
4553

4654
func runConsole(name, shell string, forwardSSHAgent bool) error {
Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56

7+
"github.com/langchain-ai/langsmith-cli/internal/structured"
68
"github.com/spf13/cobra"
79
)
810

11+
var sandboxConsoleCommand = structured.Command[struct{}]{
12+
Use: "console <name>",
13+
Short: "Open an interactive shell inside a sandbox (not supported on Windows)",
14+
Args: cobra.ExactArgs(1),
15+
CustomOutput: true,
16+
Action: func(ctx context.Context, cmd *cobra.Command, in struct{}, args []string) (any, error) {
17+
return nil, fmt.Errorf("sandbox console is not supported on Windows; use SSH instead: langsmith sandbox ssh-setup %s", args[0])
18+
},
19+
}
20+
921
func newSandboxConsoleCmd() *cobra.Command {
10-
return &cobra.Command{
11-
Use: "console <name>",
12-
Short: "Open an interactive shell inside a sandbox (not supported on Windows)",
13-
Args: cobra.ExactArgs(1),
14-
RunE: func(cmd *cobra.Command, args []string) error {
15-
return fmt.Errorf("sandbox console is not supported on Windows; use SSH instead: langsmith sandbox ssh-setup %s", args[0])
16-
},
17-
}
22+
return sandboxConsoleCommand.Cobra()
1823
}

internal/cmd/sandbox_ssh.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ import (
1010

1111
"github.com/langchain-ai/langsmith-cli/internal/client"
1212
"github.com/langchain-ai/langsmith-cli/internal/cmdutil"
13+
"github.com/langchain-ai/langsmith-cli/internal/structured"
1314
"github.com/langchain-ai/langsmith-go"
1415
"github.com/spf13/cobra"
1516
)
1617

17-
func newSandboxSSHSetupCmd() *cobra.Command {
18-
var identity string
18+
type sandboxSSHSetupInput struct {
19+
Identity string
20+
}
1921

20-
cmd := &cobra.Command{
21-
Use: "ssh-setup <name>",
22-
Short: "Upload your SSH public key and configure ~/.ssh/config for a sandbox",
23-
Long: `Upload your SSH public key to a running sandbox so you can connect
22+
var sandboxSSHSetupCommand = structured.Command[*sandboxSSHSetupInput]{
23+
Use: "ssh-setup <name>",
24+
Short: "Upload your SSH public key and configure ~/.ssh/config for a sandbox",
25+
Long: `Upload your SSH public key to a running sandbox so you can connect
2426
with standard SSH tools (ssh, scp, rsync, sftp).
2527
2628
This command uploads your key, fetches the host key, writes a
@@ -30,15 +32,20 @@ immediately connect with: ssh sandbox-<name>
3032
Examples:
3133
langsmith sandbox ssh-setup my-sandbox
3234
langsmith sandbox ssh-setup my-sandbox --identity ~/.ssh/id_ed25519.pub`,
33-
Args: cobra.ExactArgs(1),
34-
RunE: func(cmd *cobra.Command, args []string) error {
35-
return runSSHSetup(cmd, args[0], identity)
36-
},
37-
}
38-
39-
cmd.Flags().StringVar(&identity, "identity", "", "Path to SSH public key (default: auto-detect)")
35+
Args: cobra.ExactArgs(1),
36+
Input: func(cmd *cobra.Command) *sandboxSSHSetupInput {
37+
in := &sandboxSSHSetupInput{}
38+
cmd.Flags().StringVar(&in.Identity, "identity", in.Identity, "Path to SSH public key (default: auto-detect)")
39+
return in
40+
},
41+
CustomOutput: true,
42+
Action: func(ctx context.Context, cmd *cobra.Command, in *sandboxSSHSetupInput, args []string) (any, error) {
43+
return nil, runSSHSetup(cmd, args[0], in.Identity)
44+
},
45+
}
4046

41-
return cmd
47+
func newSandboxSSHSetupCmd() *cobra.Command {
48+
return sandboxSSHSetupCommand.Cobra()
4249
}
4350

4451
func runSSHSetup(cmd *cobra.Command, name, identity string) error {

internal/cmd/sandbox_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import (
66
"net/http"
77
"os"
88
"path/filepath"
9+
"runtime"
910
"testing"
1011

1112
langsmith "github.com/langchain-ai/langsmith-go"
13+
"github.com/spf13/cobra"
1214
"github.com/stretchr/testify/assert"
1315
"github.com/stretchr/testify/require"
1416
)
@@ -157,6 +159,71 @@ func TestSandboxCreateCmd_AllowsNoArgs(t *testing.T) {
157159
require.NotContains(t, body, "snapshot_id")
158160
}
159161

162+
func TestSandboxExecCmd_PositionalNameAndCommandSeparator(t *testing.T) {
163+
cmd := newSandboxExecCmd()
164+
require.NoError(t, cmd.Args(cmd, []string{"my-vm", "echo", "hi"}))
165+
require.Error(t, cmd.Args(cmd, []string{}))
166+
}
167+
168+
func TestSandboxExecCmd_WritesCommandOutputDirectly(t *testing.T) {
169+
var body map[string]any
170+
ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
171+
w.Header().Set("Content-Type", "application/json")
172+
switch {
173+
case r.Method == http.MethodGet && r.URL.Path == "/v2/sandboxes/boxes/my-vm":
174+
_, err := w.Write([]byte(`{"id":"box-id","name":"my-vm","status":"ready","dataplane_url":"` + tsURL(t, r) + `"}`))
175+
require.NoError(t, err)
176+
case r.Method == http.MethodPost && r.URL.Path == "/execute":
177+
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
178+
_, err := w.Write([]byte(`{"stdout":"hello\n","stderr":"","exit_code":0}`))
179+
require.NoError(t, err)
180+
default:
181+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
182+
}
183+
})
184+
185+
var out string
186+
var err error
187+
stdout := captureStdout(t, func() {
188+
out, err = executeCommand(t, "--api-key", "test-key", "--api-url", ts.URL, "sandbox", "exec", "my-vm", "--", "echo", "hello")
189+
})
190+
191+
require.NoError(t, err)
192+
assert.Empty(t, out)
193+
assert.Equal(t, "hello\n", stdout)
194+
assert.Equal(t, "'echo' 'hello'", body["command"])
195+
}
196+
197+
func tsURL(t *testing.T, r *http.Request) string {
198+
t.Helper()
199+
return "http://" + r.Host
200+
}
201+
202+
func TestSandboxCustomOutputCommands_Flags(t *testing.T) {
203+
tests := []struct {
204+
name string
205+
cmd *cobra.Command
206+
want []string
207+
}{
208+
{"tunnel", newSandboxTunnelCmd(), []string{"url", "name", "remote-port", "local-port", "stdio", "log-level"}},
209+
{"ssh-setup", newSandboxSSHSetupCmd(), []string{"identity"}},
210+
}
211+
if runtime.GOOS != "windows" {
212+
tests = append(tests, struct {
213+
name string
214+
cmd *cobra.Command
215+
want []string
216+
}{"console", newSandboxConsoleCmd(), []string{"shell", "forward-ssh-agent"}})
217+
}
218+
for _, tc := range tests {
219+
t.Run(tc.name, func(t *testing.T) {
220+
for _, name := range tc.want {
221+
require.NotNil(t, tc.cmd.Flags().Lookup(name), "flag --%s not found", name)
222+
}
223+
})
224+
}
225+
}
226+
160227
func TestSandboxTunnelCmd_PositionalNameOrURL(t *testing.T) {
161228
cmd := newSandboxTunnelCmd()
162229
// Should accept 0 or 1 args

0 commit comments

Comments
 (0)