A `.sh` step now reports what it prints the way a `.mjs` step does on
`main`: bash inherits the runner's stdout and stderr, which are the pipes
`ScriptLogCapture` reads, so the log, its limits, the drain and the secret
scrub apply with no bash-specific code.
The failure reason comes from stderr rather than from a file. bash has no
`throw`; what it has is an exit code and stderr, and every command a
script runs already writes its error there. So a non-zero exit reports the
runner's exit line followed by the last non-blank line the script wrote to
stderr - under `set -euo pipefail`, the error of the command that stopped
it, with no Argent-specific code in the script.
The runner never reads stderr: bash writes straight into the parent's
pipe. The executor's capture keeps the line (`LastLineTracker`), from the
raw text ahead of the scrub and the limits, so a script that floods stderr
and then says why still says it. Only the head of a line is kept - 1,000
characters and the usual omission marker - so a cut always lands at the
end of the message, where `redactTruncated` repairs one, and the line
joins the failure text before it is clamped and redacted. A line of
nothing but whitespace is blank at any length. stdout is never the
reason, and only the `exit` kind gets the line.
`$ARGENT_REASON` goes, and with it the runner's bounded read of the
reason file, its kept-count marker and `REASON_KEPT_RE`, its UTF-8
refusal, the CRLF hint for a reason that strayed, and the second exchange
file. `ARGENT_REASON` is an ordinary environment name again; the CRLF hint
for `$ARGENT_OUTPUT` stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
The case written for 103a25f94 - the whole-message clamp landing inside the
reason's own marker - could not fail on it, for two reasons.
`pinBash` wrote `<ws>/.argent/config.json`, and `scripts.bash` takes the global
scope alone, so `readScopeValue` returned before that file was read. The 934-
character symlink never reached a message, and the case ran under the host's
ordinary short bash path. It now pins the value in a home of its own, and
asserts the pinned path is IN the message so the setup cannot go inert again.
The arithmetic then pointed the wrong way: `- 50` put the message 50 characters
SHORT of the ceiling, where the premise of the fix is an exit line LONGER than
the room `MAX_REASON_CHARS` reserves for it. `+ 20` passes the ceiling and keeps
the path inside PATH_MAX.
With both, reverting the fix in the runner - the budget back to a fixed
`MAX_REASON_CHARS` and the `budget <= 0` guard dropped - fails the case:
"expected 'The script exited with code 5 (bash: …' to match
/this report keeps the first \d+ chara…/".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`readReasonFile` clamped `$ARGENT_REASON` at a fixed `MAX_REASON_CHARS`,
on the premise that the 1024 characters left over were always enough for
the exit line in front of it - and so that the whole-message `clampText`
never fired on that path. The interpreter path inside that line is the
one term nothing bounds. A bash some 900 characters down a directory
tree spends the reserve, the message passes the ceiling, and `clampText`
cuts the reason's own marker in half.
That marker is load-bearing. `redactTruncated` in the parent reads it to
find where the reason was cut, and drops the half of a secret straddling
that cut - a prefix no whole-value scrub can match. With the marker gone
the omission marker matches first, the head it hands `partialSecretTail`
ends in marker fragments rather than reason text, and the half survives
into the report.
The reason now takes the smaller of `MAX_REASON_CHARS` and what the exit
line leaves, and its marker counts against that budget the way
`clampText`'s does. A budget too narrow for the marker keeps no reason
text at all, so there is no half of a secret to leave behind either.
Reproduced and re-checked through `argent flow run` with `scripts.bash`
at a 934-character path: before, the report ended
`…CUTHERE99y… [$ARGENT_REASON ho… [61 more characters omitted]`; after,
`… [$ARGENT_REASON holds 14335 bytes; this report keeps the first 7137
characters]`.
A `script` step's work is as often a shell one-liner as a Node program:
`curl` an internal API, `psql -f` a seed file, `adb shell` a settings
change. This adds `.sh` as a second language on the existing step - one
new extension, no new key, no new tool, no new step kind and no new
report field.
The contract is one line each: input is the environment, the output
document is the file named by `$ARGENT_OUTPUT`, the verdict is the exit
status, and a failure reason is the file named by `$ARGENT_REASON`.
Bash runs inside the runner rather than instead of it. The executor forks
the same `flow-script-runner.mjs`, as the entry module rather than as an
`--import` preload, and the runner spawns bash as its own child. That
keeps one process group per step, so the parent's group stop and both
watchdogs' group kills reach a bash descendant unchanged - which matters
because a bash polling loop is exactly the orphan the watchdogs exist for,
and a shell has no thread to watch a descriptor from.