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
23 changes: 23 additions & 0 deletions blackbox/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MIREN_APP is the deprecated alias that api/app/runtimeenv.go keeps only for a deprecation window. The comment there says the aliases "will be removed in a future release". Once that happens, this assertion fails because $MIREN_APP expands to nothing, and that has nothing to do with shell handling. Please use echo $MIREN_RUNTIME_APP instead, since that's the canonical name appspec injects. The test checks the same thing and doesn't depend on the alias.

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.<name>.env] has to reach the container. A task names no service, so
Expand Down
13 changes: 12 additions & 1 deletion cli/commands/app_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions cli/commands/app_run_doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions cli/commands/app_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package commands

import (
"errors"
"os/exec"
"reflect"
"strings"
"testing"
"time"

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/command/app-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading