DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: The Script Passed Here. The Other sh Was Not Bash.

These notes cover one lab cycle where a script passed in my terminal and failed as soon as another shell executed it. Have you ever accepted a fluent rewrite because your own laptop already printed a clean success line? I had accepted one, and the failure stayed quiet because the file existed and the shebang still read sh. The runner was not missing the fixture, and the real split was which program that shebang would actually start.

What I thought the rewrite fixed

I wanted a small preflight that checked one fixture path, failed closed on a bad write, and printed a single note line. The first rewrite used a Bash array, a double-bracket test, and set -o pipefail near the top of the file. Why did that feel safe when every line was short and the comments named the failure I already understood? I had not asked which shell would run the file after it left the laptop that trained my habits.

I used MonkeyCode's free model access for that rewrite, and the free server option for the second run. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two availability claims are the only product facts in this note, and I am not adding quotas, hardware, names, or duration. The steps should still help if you swap both tools for any remote shell and any assistant that can read a log.

What broke when the shell changed

On my laptop, sh behaved enough like Bash that the array, the brackets, and pipefail all succeeded. I then invoked the same rewrite under a stricter POSIX shell, and it exited while parsing, before the fixture path was ever opened. That is the break I would have blamed on the path, because the message never mentioned Bash at all. Have you seen a syntax error that looks like a missing command once the remote log wraps the line?

I pasted the remote-style error back to the free model access and asked for a shorter fix. The next draft kept the double-bracket test and only changed the fixture string, which repaired the wrong layer. I should have sent the shell identity first, and I should have refused any patch that still needed a Bash option. Would a greener comment have made that second draft safer for a runner I had not checked yet?

The probe I will actually rerun

I stopped editing the task and wrote a probe that prints feature flags before I trust a generated fix. This is a lab example you can run yourself, and I am not publishing timings, host names, or private runner metrics. It does not install packages, and it does not need a model name to tell you whether the current sh is a safe target. The feature tests sit inside eval so a strict sh can still parse the probe itself.

#!/bin/sh
# shell-probe.sh - lab probe, not a distro audit.
# The eval strings are fixed feature tests, not input from a log or a prompt.
echo "shell=$0"
if (eval 'x=0; echo $((x+1))') >/dev/null 2>&1; then
  echo "arith=yes"
else
  echo "arith=no"
fi
if (eval 'set -o pipefail') >/dev/null 2>&1; then
  echo "pipefail=yes"
else
  echo "pipefail=no"
fi
if (eval '[[ -n "$0" ]]') >/dev/null 2>&1; then
  echo "double_bracket=yes"
else
  echo "double_bracket=no"
fi
if (eval 'a=(one two); echo "${a[0]}"') >/dev/null 2>&1; then
  echo "arrays=yes"
else
  echo "arrays=no"
fi
Enter fullscreen mode Exit fullscreen mode

Commands I keep in the notes

I keep the invocations boring, because a clever wrapper can hide the very shell I am trying to observe. Run the probe with sh on the laptop, then run that same file on the free server with sh as well. If your local sh is already Bash, a local pass does not clear the runner, and you still need the remote lines.

sh ./shell-probe.sh
scp shell-probe.sh runner:~/lab/shell-probe.sh
ssh runner 'sh "$HOME/lab/shell-probe.sh"'
command -v bash && bash ./shell-probe.sh
Enter fullscreen mode Exit fullscreen mode

Replace runner with the free server host you were actually given, and do not publish that host in the notes. If pipefail, double_bracket, or arrays print no on one side and yes on the other, I do not paste the rewrite yet. I either rewrite the task in POSIX sh, or I change the job to call bash and write that choice beside the log. Which of those two is cheaper depends on who maintains the image, not on which snippet looks shorter in chat?

A decision table, not another guess

I kept a four-row table so the next hour would not restart the same argument about the fixture path. Read the probe first, then pick one row, and do not mix a Bash pin with a POSIX rewrite in the same commit. The table is a workflow aid rather than a measurement, and it does not claim that any free server matches my laptop. Would I still open the fixture code before I read the flags, if the log mentioned a missing file?

Probe result What it means What I do next
arrays=no on the runner The generated array fails while that shell parses Remove arrays, or call bash on purpose
pipefail=no on the runner A pipeline status can hide the failing stage Record each status with a temp file
double_bracket=no [[ ]] is not available to this sh Switch the tests to [ ] or test
arith=yes on both, other flags differ Arithmetic is not proof that this shell is Bash Trust the failing flags, not the shared one
Flags match on both sides Dialect is not the split I am chasing Inspect paths, users, and working directories

The POSIX version I would ship

When pipefail is absent, I do not pretend the option exists, and I do not hide the status inside a comment. I write the fixture line to a temp file, then I test that file with tools a POSIX shell already has. The result is longer than the Bash one-liner, and that extra length is what survived the stricter shell. Why keep the longer form when a chat window keeps offering a shorter one that only my laptop can run?

#!/bin/sh
out=$(mktemp)
trap 'rm -f "$out"' EXIT
if ! printf '%s\n' "fixture-ok" > "$out"; then
  echo "write failed" >&2
  exit 1
fi
if ! grep -q 'fixture-ok' "$out"; then
  echo "fixture missing" >&2
  exit 2
fi
echo "fixture readable"
Enter fullscreen mode Exit fullscreen mode

A harness that records both shells

A small Python harness records both shells when they exist, so I can diff the flags without retyping the loop. Treat it as an example to run on your machine, not as a benchmark and not as evidence about a particular image. If bash is missing, the harness skips that arm and still prints the sh result you actually have on disk. Did I merge a shorter generated form before both shells agreed on the flags I actually cared about?

import shutil
import subprocess

PROBE = "shell-probe.sh"

def run_probe(shell):
    if shutil.which(shell) is None:
        return shell + ":missing"
    done = subprocess.run([shell, PROBE], capture_output=True, text=True)
    lines = done.stdout.strip().splitlines()
    return shell + ":exit=" + str(done.returncode) + ":" + "|".join(lines)

if __name__ == "__main__":
    for name in ("sh", "bash"):
        print(run_probe(name))
Enter fullscreen mode Exit fullscreen mode

I did not merge it, because agreement on those flags is the gate, and the tone of the explanation is not. A fluent shorter form can still be the right patch later, once the weaker shell has already printed its flags. Until that print exists, I treat every generated shell convenience as a hypothesis rather than a fix. If mktemp is missing, I use a home-directory file and I still delete it from the trap.

Where the free options helped

The free server option gave me a second machine for the ssh line, so I was not inventing a remote shell from memory. The free model access was useful only after the probe output sat in the prompt, because earlier drafts assumed my laptop dialect. I am not claiming a faster edit, a larger box, a lasting free tier, or a better score than any other assistant. If either option is unavailable when you read this, the probe and the table still stand on their own.

  • Paste the probe flags, not only the red error line, or the draft will guess your laptop dialect again.
  • Keep the free server run on a throwaway directory, so a bad rewrite cannot touch a real fixture tree.
  • Record which binary you invoked, because a green bash run does not clear a job that calls sh.

Who should skip this

You should skip this workflow if you cannot print the remote sh and you cannot copy a probe file there. You should also skip it when the job truly needs Bash arrays or pipefail, unless the unit calls bash and you retest that binary. Do not drop this probe into a path that handles secrets, tokens, or private keys, because a lab file does not belong beside credentials. If your image build already pins bash and your review rejects other shebangs, you already have a stricter rule than this note.

What I would repeat next time

I would repeat the same order, even when the error text mentions a missing file and tempts me back toward the fixture. First I print the shell, then I run the probe on both sides, and only then I ask for a weaker-flag rewrite. I would keep the POSIX version when both sides share one script, and pin bash only if the team already depends on it. Would a later image change bring the mismatch back even if the script text stayed completely still on disk?

  1. Print the sh path and the bash path on both machines before you reread the fixture error.
  2. Run shell-probe.sh under sh on each side, and save the flag lines in the same note as the log.
  3. Ask for a rewrite only after those flags are attached, and reject any patch that needs a missing flag.
  4. Rerun the probe after any image change, even when the script text itself looks completely untouched.

It would come back, which is why the probe stays in the lab directory instead of living only inside chat history. I would rerun it whenever the image, the shebang, or the job command changes, even if the fixture text looks untouched. If a free server seat is already idle, point this probe at a throwaway directory before you merge a model-written shell change.

Top comments (0)