Interpolate every value as one literal argument, like "$var" in sh - #201
Conversation
Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #41
Interpolation had two "already quoted" shortcuts: a value wrapped in matching quotes was spliced into the command as shell syntax instead of being quoted. So a path the caller had quoted lost its quotes, and a value like "it's" produced '"it's"' - an unterminated string the shell refuses to run. Worse, the shortcut also accepted unbalanced values, so "' ; touch /tmp/pwned ; '" was spliced in verbatim and the injected command executed. Values are now always quoted as literal text, so an interpolated path reaches the command as exactly one argument, spaces and quote characters included - the same guarantee as "$path" in sh, and the behavior of Bun's $, zx and execa (issue #41). The previous behavior stays available via shell.preQuotedPassthrough(), setPreQuotedPassthroughEnabled(true), or COMMAND_STREAM_PREQUOTED_PASSTHROUGH=1, and even then only balanced values are passed through, so the injection can no longer happen. Mirrored in the Rust crate (quote, is_pre_quoted_passthrough_enabled).
A pipeline stage can exit before the stage feeding it has finished writing (`source | grep -m1 ...`, or simply a race at the end of the pipe). Closing that stdin then raises EPIPE. The pump in pipeStreamToProcess guarded its writes but not the close in its `finally` block, and the pump promise itself was never awaited or caught, so the rejection escaped as an unhandled error and could fail an otherwise successful command - as it did on CI for a `printf ... | cat` parity case in tests/paths-with-spaces.test.mjs. Guard the close the same way the writes are guarded, and return the pump promise so the failure mode is testable. The Rust implementation already discards these errors (`let _ = stdin.write_all(...)` / `let _ = stdin.shutdown()`), so this brings JavaScript in line with it.
The changeset validator requires exactly one changeset per pull request.
Working session summaryDone. PR #201 is ready for review: #201 Final state of this session's work: Root cause of the remaining CI failure — it was not a quoting mismatch and not a flake in my test's assertions. Fix — the close is now guarded the same way the writes are, via Regression test — Also this session: folded the EPIPE note into the single changeset after Verification: This summary was automatically extracted from the AI working session output. |
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $12.319192📊 Context and tokens usage:Claude Opus 5: (3 sub-sessions)
Total: (8.8K new + 264.0K cache writes + 14.0M cache reads) input tokens, 105.7K output tokens, $12.319193 cost 🤖 Models used:
📎 Log file uploaded as Gist (5059KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
🎉 Auto-mergedThis pull request has been automatically merged by hive-mind.
Auto-merged by hive-mind with --auto-merge flag |
Summary
cat ${filePath}with a path that contains spaces already produced a singleargument, so the snippet in the issue works. Investigating the whole family of
cases around it (experiments in
experiments/issue-41-*.mjs) found the realdivergence one layer down, in
quote(): two "already quoted" shortcuts spliceda value into the command as shell syntax instead of quoting it.
That behavior is not what
sh— or any competitor — does, and it had two hardfailures:
'/My Documents/report.txt'(caller pre-quoted)"it's"'"it's"'→/bin/sh: Syntax error: Unterminated quoted string'"it'\''s"', runs' ; touch /tmp/pwned ; 'touchranFixes #41.
Behavior
An interpolated value is now always exactly one literal argument, spaces and
quote characters included — the same guarantee as
"$path"in a shell script:Measured against the competitors before choosing: Bun's
$, zx and execa alltreat an interpolated value literally, quote characters included. This matches
the maintainer's direction in the issue — sh-like by default, configurable.
Configurable opt-out (mirrors the existing
COMMAND_STREAM_QUOTE_CONTEXTswitch from #49):
shell.preQuotedPassthrough(true),setPreQuotedPassthroughEnabled(true), orCOMMAND_STREAM_PREQUOTED_PASSTHROUGH=1. Even then only balanced values arepassed through, so the injection above cannot be re-enabled.
How to reproduce
Tests
js/tests/paths-with-spaces.test.mjs(new, 185 tests):quote()semantics;an argv-fidelity table of 15 tricky path values × unquoted/double/single
contexts, asserted with a fixture that prints
ARG[...]perargventry; areal-binary case with virtual commands disabled; a
/bin/shdifferentialparity table (8 scripts × 15 values) where the reference always uses
"$V"; real file operations (cat, ls, cp/mv/rm, mkdir -p, redirection,pipeline,
cd && pwd,test -f,.sync()) inside a temp dir namedmy documents …; an injection regression using atouchmarker; and thelegacy passthrough switch.
rust/tests/paths_with_spaces.rs(new) mirrors it, including the/bin/shdifferential table and the injection regression. 3 of its 5 tests fail
against the pre-fix
quote(), confirming they reproduce the bug.js/tests/$.test.mjs,js/tests/path-interpolation.test.mjs,js/tests/readme-examples.test.mjs,rust/src/quote.rs,rust/tests/utils.rs, where they asserted the removedpassthrough.
bun test js/tests/— no new failures against the recordedbaseline (the remaining failures need
jq, which is not installed here);cargo testgreen;eslint,prettier,jscpd,cargo fmt,cargo clippy -D warningsall clean.The issue links
deep-assistant/hive-mind/command-stream-issues/issue-05-paths-with-spaces.mjs,which now 404s; the coverage above was reconstructed from the issue text plus
the competitor comparison.
Parity & release
Mirrored in the Rust crate (
quote,is_pre_quoted_passthrough_enabled,exported from the crate root). Docs updated in
js/README.md,js/BEST-PRACTICES.md,rust/BEST-PRACTICES.md. Release triggers included:js/.changeset/issue-41-paths-with-spaces.md(minor) andrust/changelog.d/20260906_120000_paths_with_spaces.md(minor).Follow-up fix: EPIPE in the streaming pipeline
The new
/bin/shparity table surfaced a pre-existing bug: onbun+ubuntu CI, one case (
printf '%s\n' <path> | cat) failed withEPIPE: broken pipe, writeatjs/src/$.process-runner-pipeline.mjs:351(
await writer.close()). When a pipeline stage exits before the stage feedingit has finished, closing its stdin raises EPIPE. The pump in
pipeStreamToProcessguarded its writes but not that close, and its promisewas never caught, so the rejection escaped as an unhandled error and failed an
otherwise successful command. The close is now guarded the same way the writes
are, and the pump promise is returned so the failure mode is testable
(
js/tests/pipeline-epipe.test.mjs, 2 of its 3 tests fail without the guards).The Rust implementation already discarded these errors
(
let _ = stdin.write_all(...)/let _ = stdin.shutdown()), so this bringsJavaScript in line with it.