diff --git a/blackbox/tasks_test.go b/blackbox/tasks_test.go index c3f390e4b..3e54206b3 100644 --- a/blackbox/tasks_test.go +++ b/blackbox/tasks_test.go @@ -261,6 +261,29 @@ func TestRunPreservesArgumentBoundaries(t *testing.T) { if !strings.Contains(r.Stdout, "hello world") { t.Fatalf("argument boundaries were lost; want \"hello world\" in output, got:\n%s", r.Stdout) } + + r = m.MustRun("app", "run", "-a", name, "--", "printf '%s' 'hello world' | wc -c") + if strings.TrimSpace(r.Stdout) != "11" { + t.Fatalf("shell pipeline did not run; want 11, got:\n%s", r.Stdout) + } + + r = m.MustRun("app", "run", "-a", name, "--", "expr", "3", ">", "2") + if strings.TrimSpace(r.Stdout) != "1" { + t.Fatalf("quoted comparison was interpreted as redirection; want 1, got:\n%s", r.Stdout) + } + + r = m.MustRun("app", "run", "-a", name, "--", "echo $MIREN_APP") + if strings.TrimSpace(r.Stdout) != name { + t.Fatalf("shell variable did not expand; want %q, got:\n%s", name, r.Stdout) + } + + r = m.MustRun("app", "run", "-a", name, "--", "printf 'redirected' > /tmp/miren-run-redirect; cat /tmp/miren-run-redirect") + if strings.TrimSpace(r.Stdout) != "redirected" { + t.Fatalf("shell redirection did not write a readable file; got:\n%s", r.Stdout) + } + + r = m.Run("app", "run", "-a", name, "--", "exit 7") + r.RequireExitCode(t, 7) } // [tasks..env] has to reach the container. A task names no service, so diff --git a/cli/commands/app_run.go b/cli/commands/app_run.go index daed4fb7b..683790361 100644 --- a/cli/commands/app_run.go +++ b/cli/commands/app_run.go @@ -65,7 +65,7 @@ func AppRun(ctx *Context, opts struct { // separate for whatever is reading them. wantTTY := !opts.Detach && stdinIsTerminal() - created, err := runs.CreateRun(ctx, opts.App, opts.Task, opts.Args, wantTTY) + created, err := runs.CreateRun(ctx, opts.App, opts.Task, runCommand(opts.Args), wantTTY) if err != nil { return err } @@ -90,6 +90,17 @@ func AppRun(ctx *Context, opts struct { return reportRunExit(ctx, runs, runID) } +// runCommand treats a single command string as shell source. Multiple args +// remain argv: the caller's shell already removed quoting, so even a standalone +// operator token may be literal data deliberately quoted or escaped locally. +func runCommand(args []string) []string { + const shellSyntax = "$|&;<>()`\\*?[]{}~\n" + if len(args) == 1 && strings.ContainsAny(args[0], shellSyntax+" \t") { + return []string{"/bin/sh", "-c", args[0]} + } + return args +} + // serverPredatesRuns reports whether a runsClient error means the cluster is // too old to offer durable runs, as opposed to being unreachable, wedged, or // refusing the caller. diff --git a/cli/commands/app_run_doc.go b/cli/commands/app_run_doc.go index f81ea67e0..268a8c8f9 100644 --- a/cli/commands/app_run_doc.go +++ b/cli/commands/app_run_doc.go @@ -4,6 +4,10 @@ const appRunDescription = `This command runs a command in a fresh sandbox built With no arguments it opens an interactive shell. With arguments it runs that command. With ` + "`" + `--task` + "`" + ` it runs a task declared in ` + "`" + `app.toml` + "`" + `. +Pass a single command string to use shell syntax (such as ` + "`" + `$HOME` + "`" + `, ` + "`" + `|` + "`" + `, or ` + "`" + `>` + "`" + `). Multiple arguments retain their boundaries without shell interpretation, including quoted or escaped operator characters. + +Quote the expression for your local shell so it reaches Miren intact: ` + "`" + `miren app run -- 'echo $HOME | wc -c'` + "`" + `. Older clusters keep their existing command handling and may not interpret a single command string the same way. + This is useful for: - Debugging application issues in an isolated environment - Running one-off commands with your app's configuration diff --git a/cli/commands/app_run_test.go b/cli/commands/app_run_test.go index 3de6bd659..80d0e0f22 100644 --- a/cli/commands/app_run_test.go +++ b/cli/commands/app_run_test.go @@ -2,6 +2,9 @@ package commands import ( "errors" + "os/exec" + "reflect" + "strings" "testing" "time" @@ -11,6 +14,90 @@ import ( "miren.dev/runtime/pkg/ui" ) +func TestRunCommand(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + {"console", nil, nil}, + {"single executable", []string{"date"}, []string{"date"}}, + {"ordinary arguments", []string{"echo", "hello world", "O'Reilly"}, []string{"echo", "hello world", "O'Reilly"}}, + {"variable argument stays literal", []string{"echo", "$HOME"}, []string{"echo", "$HOME"}}, + {"operator argument stays literal", []string{"grep", "-F", "|", "log"}, []string{"grep", "-F", "|", "log"}}, + {"find exec terminator", []string{"find", ".", "-name", "x", "-exec", "rm", "{}", ";"}, []string{"find", ".", "-name", "x", "-exec", "rm", "{}", ";"}}, + {"expr comparison", []string{"expr", "3", ">", "2"}, []string{"expr", "3", ">", "2"}}, + {"single shell expression", []string{"echo $HOME | wc -c"}, []string{"/bin/sh", "-c", "echo $HOME | wc -c"}}, + {"single command string", []string{"exit 7"}, []string{"/bin/sh", "-c", "exit 7"}}, + {"redirect is data", []string{"echo", "hi", ">", "/tmp/result"}, []string{"echo", "hi", ">", "/tmp/result"}}, + {"python code is data", []string{"python", "-c", "print('hi')"}, []string{"python", "-c", "print('hi')"}}, + {"SQL is data", []string{"psql", "-c", "SELECT * FROM users;"}, []string{"psql", "-c", "SELECT * FROM users;"}}, + {"URL is data", []string{"curl", "https://x/?a=1&b=2"}, []string{"curl", "https://x/?a=1&b=2"}}, + {"grep pattern is data", []string{"grep", "-E", "foo|bar", "log"}, []string{"grep", "-E", "foo|bar", "log"}}, + {"substitution is data", []string{"echo", "it's $HOME"}, []string{"echo", "it's $HOME"}}, + {"explicit shell", []string{"/bin/sh", "-c", "echo $HOME | wc -c"}, []string{"/bin/sh", "-c", "echo $HOME | wc -c"}}, + {"combined shell flags", []string{"sh", "-ec", "exit 7; echo unexpected"}, []string{"sh", "-ec", "exit 7; echo unexpected"}}, + {"shell option value", []string{"sh", "-o", "pipefail", "-c", "echo 'x' | cat"}, []string{"sh", "-o", "pipefail", "-c", "echo 'x' | cat"}}, + {"env shell", []string{"/usr/bin/env", "bash", "-c", "echo $HOME"}, []string{"/usr/bin/env", "bash", "-c", "echo $HOME"}}, + {"login shell flags", []string{"bash", "-lc", "echo $1", "unused", "word"}, []string{"bash", "-lc", "echo $1", "unused", "word"}}, + {"separate shell flags", []string{"bash", "-e", "-c", "echo $1", "unused", "word"}, []string{"bash", "-e", "-c", "echo $1", "unused", "word"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := runCommand(tt.args); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("runCommand(%q) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} + +func TestRunCommandExecutesShellExpression(t *testing.T) { + args := runCommand([]string{`printf '%s' 'hello world' | wc -c`}) + out, err := exec.Command(args[0], args[1:]...).Output() + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(out)); got != "11" { + t.Fatalf("pipeline output = %q, want 11", got) + } +} + +func TestRunCommandPreservesLiteralArguments(t *testing.T) { + for _, literal := range []string{"O'Reilly book", "a # b", "a = b", ""} { + t.Run(literal, func(t *testing.T) { + args := runCommand([]string{"printf", "%s", literal}) + out, err := exec.Command(args[0], args[1:]...).Output() + if err != nil { + t.Fatal(err) + } + if string(out) != literal { + t.Fatalf("command output = %q, want %q", out, literal) + } + }) + } +} + +func TestRunCommandPreservesExplicitShellExit(t *testing.T) { + for _, args := range [][]string{ + {"sh", "-ec", "exit 7; echo unexpected"}, + {"bash", "-lc", "exit 7; echo unexpected"}, + {"bash", "-e", "-c", "exit 7; echo unexpected"}, + } { + got := runCommand(args) + out, err := exec.Command(got[0], got[1:]...).CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 7 || len(out) != 0 { + t.Fatalf("%q: exit = %v, output = %q; want code 7 and no output", args, err, out) + } + } + + args := runCommand([]string{"bash", "-lc", `printf '%s' "$1"`, "unused", "two words"}) + out, err := exec.Command(args[0], args[1:]...).Output() + if err != nil || string(out) != "two words" { + t.Fatalf("positional argument output = %q, error = %v; want two words", out, err) + } +} + // The compatibility fallback hinges on one distinction: a server that answered // and does not offer app-runs (fall back to legacy exec) versus a server that // is unreachable, wedged, or refusing us (surface the real error). Falling back diff --git a/docs/docs/command/app-run.md b/docs/docs/command/app-run.md index 1d915718d..c6e99ef8d 100644 --- a/docs/docs/command/app-run.md +++ b/docs/docs/command/app-run.md @@ -12,6 +12,10 @@ This command runs a command in a fresh sandbox built from your app's active vers With no arguments it opens an interactive shell. With arguments it runs that command. With `--task` it runs a task declared in `app.toml`. +Pass a single command string to use shell syntax (such as `$HOME`, `|`, or `>`). Multiple arguments retain their boundaries without shell interpretation, including quoted or escaped operator characters. + +Quote the expression for your local shell so it reaches Miren intact: `miren app run -- 'echo $HOME | wc -c'`. Older clusters keep their existing command handling and may not interpret a single command string the same way. + This is useful for: - Debugging application issues in an isolated environment - Running one-off commands with your app's configuration