smoke: five more scenarios + fix exit-code propagation bug the new ones caught

Five new smoke scenarios layered on top of the existing bare + workspace
vm-run tests:

  - exit-code propagation: `sh -c 'exit 42'` must rc=42
  - workspace dry-run: --dry-run lists tracked files without a VM
  - workspace --include-untracked: opt-in ships files outside the git
    index (regression guard on the security-default flip from review 4)
  - concurrent vm runs: two --rm invocations in parallel both succeed
    (stresses per-VM locks, createVMMu reservation window, tap pool)
  - invalid spec rejection: --vcpu 0 must fail with no VM row left
    behind (the "cleanup on partial failure" path the review flagged)

The exit-code scenario caught a real bug on first run:

  `banger vm run --rm -- sh -c 'exit 42'` returned rc=0, not 42.

Root cause in internal/cli/ssh.go's sshCommandArgs: extra args were
appended to the ssh argv verbatim, relying on ssh(1)'s implicit
space-join to deliver the remote command. That works for single
tokens (echo hello) but re-tokenises multi-word commands on the
remote side: `ssh host sh -c 'exit 42'` becomes remote
`sh -c exit 42`, where `42` is $0 for the already-completed `exit`,
and the exit code the user asked for is lost.

Fix: shell-quote every element of extra (`'sh'` `'-c'` `'exit 42'`)
and join them into a single trailing argv entry. ssh's space-join
then produces exactly the command the user typed on the remote
shell. TestSSHCommandArgs was updated to pin the quoting; the
existing TestRunVMRunCommandModePropagatesExitCode test needed a
one-word quote tweak (`false` → `'false'`).

Smoke run after fix passes all seven scenarios in ~2 min on warm
state. cmd/banger coverage jumped to 100% (the invalid-spec
scenario hits the error-reporting path that wasn't covered
before).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-22 19:37:07 -03:00
parent 5f81332b0a
commit 672d7151e9
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 94 additions and 4 deletions

View file

@ -1037,7 +1037,6 @@ func TestSSHCommandArgs(t *testing.T) {
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"root@172.16.0.2",
"--", "uname", "-a",
}
for _, s := range wantSubstrings {
found := false
@ -1052,6 +1051,15 @@ func TestSSHCommandArgs(t *testing.T) {
}
}
// The trailing argument is the user's command, shell-quoted and
// joined so ssh(1)'s space-concatenation produces the exact argv
// the user typed on the remote shell. Without this, multi-word
// args like `sh -c 'exit 42'` re-tokenise on the remote and lose
// exit codes.
if got, want := args[len(args)-1], `'--' 'uname' '-a'`; got != want {
t.Errorf("trailing arg = %q, want %q (ssh needs a single shell-quoted string)", got, want)
}
// Host-key verification posture: accept-new + a real path into
// banger state, not /dev/null.
joined := strings.Join(args, " ")
@ -1607,8 +1615,8 @@ func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) {
if !errors.As(err, &exitErr) || exitErr.Code != 7 {
t.Fatalf("d.runVMRun error = %v, want ExitCodeError{7}", err)
}
if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "false" {
t.Fatalf("sshArgsSeen = %v, want trailing command 'false'", sshArgsSeen)
if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "'false'" {
t.Fatalf("sshArgsSeen = %v, want trailing shell-quoted command 'false'", sshArgsSeen)
}
if !strings.Contains(stderr.String(), "[vm run] running command in guest") {
t.Fatalf("stderr = %q, want command progress", stderr.String())

View file

@ -91,7 +91,20 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s
)
}
args = append(args, "root@"+guestIP)
args = append(args, extra...)
// ssh(1) concatenates every argument after the host with spaces
// before sending to the remote shell. That means passing extra
// args raw — `ssh host sh -c 'exit 42'` — re-tokenises on the
// remote side to `sh -c exit 42`, where `42` is $0 for the
// already-completed `exit`, and the rc the user asked for is
// lost. Shell-quote each element and join them ourselves so the
// remote shell sees exactly the argv the user typed locally.
if len(extra) > 0 {
quoted := make([]string, len(extra))
for i, a := range extra {
quoted[i] = shellQuote(a)
}
args = append(args, strings.Join(quoted, " "))
}
return args, nil
}