Skip to content

feat(send): --stdin and --body-file so bodies bypass the sender shell and argv (#378) - #507

Open
Masashi-Ono0611 wants to merge 4 commits into
fujibee:mainfrom
Masashi-Ono0611:feat/send-stdin-file-input
Open

feat(send): --stdin and --body-file so bodies bypass the sender shell and argv (#378)#507
Masashi-Ono0611 wants to merge 4 commits into
fujibee:mainfrom
Masashi-Ono0611:feat/send-stdin-file-input

Conversation

@Masashi-Ono0611

@Masashi-Ono0611 Masashi-Ono0611 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Part of #378.

Adds --stdin and --body-file <path> to send.sh, so a message body can be read verbatim instead of arriving through the sender's shell, and makes that the canonical way to send in every first-party example.

Why this can't be fixed inside send.sh without a new input path

Both hazards in the issue happen before send.sh ever runs:

  • Shell evaluation. The damage is done at command-construction time — send ... "x $(cmd)" executes cmd in the caller's shell. send.sh receives the result, not the source, so it has nothing to intercept. (Note the precise mechanism: a positional argument is not re-evaluated; a body already containing literal backticks is safe. The problem is agents composing the command string.)
  • MSYS2 argv truncation. @masa6161's analysis pinned this to the build_argvglobifyglob.cc MAXPATHLEN 8192 path, silently cutting bodies at 8,186 characters with no error. Their repro harness and control experiments are what make this diagnosable rather than folklore — thank you for that work.

An input path that never touches argv sidesteps both.

Interface

send.sh <team> <from> <to> --stdin        # body read from stdin
send.sh <team> <from> <to> --body-file P  # body read from a file
send.sh <team> <from> <to> -- <body>      # explicit positional (see below)

Design decisions worth flagging:

  • The canonical form is flipped, not merely added to. --stdin / --body-file is now the spelling in README.md, README.ja.md, SKILL.md, all nine driver templates, the grok-build delivery rule file, the codex bridge's reply prompt, the session-start.sh monitor directives, and docs/building-on-agmsg.md. The positional form is marked deprecated in the same places, and each agent-facing surface states that a body an agent composes MUST NOT use it. This is stage (1) of the three-stage migration: (1) flip the docs and examples, (2) emit a deprecation warning on positional use, (3) remove it in the next breaking change. Only stage (1) is in this PR — there is no runtime warning here and nothing is removed.
  • There is a narrow backward-compatibility break, and -- is its migration syntax, not a reason it doesn't exist. Four literal bodies are affected. -- itself was a valid body before (only argument 5 was inspected) and is now consumed as the terminator; its migration form is -- --. --stdin and --body-file now have that argument read as a mode selector. --force is now rejected outright — previously it arrived as the body, because only argument 5 was checked for the flag (send.sh t a b --force set BODY=--force, FORCE=0; verified against the pre-PR script). All four must be rewritten as send.sh <team> <from> <to> -- <body> to get the old behaviour. That is a real break for those callers — narrow, but real. Every other positional body is unaffected and keeps working unchanged.
  • Ambiguity is rejected, never resolved. A positional body plus --stdin, --stdin plus --body-file, --body-file with no path or with a flag-shaped path, empty stdin, an empty or unreadable file — each errors with a message naming the problem, rather than sending a silently-empty message.
  • The body is preserved byte-for-byte, trailing newlines included, via IFS= read -r -d '' plus a sentinel around the SQL-escape command substitution (command substitution otherwise eats trailing newlines). This is deliberate: a heredoc contributes the newline immediately before its closing delimiter.
  • A NUL byte is refused, not truncated. read -d '' exits 0 only when it found its delimiter — here NUL — and non-zero at EOF, which is the normal complete read. That status is now checked instead of discarded, so NUL-bearing input is rejected with a clear error and nothing is stored. Without it the shortened body was saved and delivered with a success exit, which meant "verbatim" only held for NUL-free input. The check runs before the empty-input check, because a leading NUL yields an empty value and a zero exit and would otherwise be misreported as empty stdin.

Scope limits I'd rather state than have you find

  • Text only, not arbitrary binary. Input containing a NUL byte is rejected with a clear error and a non-zero exit (see above). A positional <message> cannot reach this case at all, since argv strings cannot carry NUL either.
  • The MSYS2 fix is untested on MSYS2. I verified the >8192-char path works here on macOS, and the body no longer crosses send.sh's argv — and the SQL itself already went to sqlite3 over stdin, so it doesn't cross a second argv boundary either. But I have no Windows machine, so I'm not claiming the field incident is fixed, only that the mechanism it was attributed to is bypassed.
  • This narrows exposure; it does not remove the hazard. The positional form remains available and still carries both hazards for anyone who keeps using it. Removing it is stage (3), not this PR — which is also why send.sh: add a stdin/file input mode so message bodies survive shell quoting #378 stays open after this lands.
  • Deliberately not implementing Optional length guard / warning in send.sh #230 (length guard) or send.sh accepts nonexistent from/to agents without validation #355 (recipient validation), though this plumbing would make both straightforward.
  • Two first-party callers still pass a variable body positionallyscripts/windows/dispatch.sh:176 and app/src-tauri/src/agmsg.rs:613. Both hand the body to a process spawn rather than a shell, so the evaluation hazard does not apply to them; the argv one still does, and dispatch.sh is the Windows path send.sh: add a stdin/file input mode so message bodies survive shell quoting #378 describes. (scripts/despawn.sh:101 also sends positionally but with the fixed literal ctrl:despawn, so it is not exposed.) They are code, not examples, so they are outside stage (1) as scoped — but after this PR the docs tell agents an agent-composed body must not use the positional form while agmsg's own Windows dispatcher and desktop app still do. Happy to migrate them here if you'd rather not carry that gap, or to file it as a follow-up.

Verification

  • bats tests/ → all green. tests/test_messaging.bats is 50 ok, 0 not ok, exit 0 (was 44): 21 tests for this feature, including byte-identical bodies containing backticks and $(...) via both input modes, a >8192-char body, explicit trailing-newline preservation, --force composition, the -- compat cases, three NUL-rejection paths, and the remaining rejection paths.
  • Mutation-tested rather than trusting green. Restoring the old IFS= read … || true makes exactly the three NUL tests fail (exit 1). Moving the NUL check after the empty check makes only the leading-NUL test fail — which is what pins the ordering. Replacing the verbatim read with a line-oriented read makes the trailing-newline test fail; neutralising the -- case arm makes the compat tests fail. All restored and re-confirmed green.
  • The -- + --force test now exercises its own name. It sends to an unregistered team/recipient, which the roster check refuses unless force mode is on, so the send succeeding is what proves the trailing --force was still parsed after -- consumed the body. A control test asserts the same call without the trailing --force is refused — otherwise the success could just mean the roster check never ran.
  • Reviewed with codex exec (gpt-5.6-sol, high). It flagged the -- compat hole (fixed) and the NUL caveat (now a rejection). It also flagged a possible second argv boundary at the sqlite3 call and a seq portability problem — I checked both: the SQL is already piped via stdin, and seq is present at /usr/bin/seq on macOS 26.5. Neither needed a change.

… and argv (fujibee#378)

Message bodies passed positionally go through the caller's shell first, so an
agent composing the command string can execute $(...) or backticks by
accident. Separately, MSYS2 silently truncates a native caller's argv at 8186
chars. Neither is interceptable inside send.sh, because both happen before it
runs — but an input path that never touches argv sidesteps both.

--stdin / --body-file read the body verbatim. The positional form is unchanged,
and -- was added so a body that IS the literal string --stdin/--body-file/--force
keeps working. Ambiguous invocations are rejected rather than silently resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Masashi-Ono0611
Masashi-Ono0611 marked this pull request as ready for review July 27, 2026 23:24
@masa6161

Copy link
Copy Markdown
Contributor

Windows / MSYS2 verification — all clear, merge recommended

TL;DR — Verified --stdin and --body-file on a real MSYS2 / Windows environment. Both modes bypass the argv truncation completely. A field-incident replay with the original 8,324-char body produced a byte-exact match. The full bats suite (44/44) passes on Windows. This PR is ready to merge.

Environment

Item Value
OS Windows 11 (10.0.26200)
Shell MSYS2 Git Bash, msys2-runtime 3.4.7-25de8b84
agmsg upstream main @ 970ad9f (2026-07-27) + this PR's diff only
Callers Node.js v24.14.1, PowerShell 7.6.3 (pwsh), bash
sqlite3 3.53.1

This is the same machine and msys2-runtime the #378 truncation evidence was gathered on; the base tree is v1.1.11 minus its last six commits, none of which touch the send/storage path. The positive control below confirms the truncation still reproduces on this exact tree before the new flags are exercised.

One version-specificity note: the 8,186-char threshold itself is a property of this msys2-runtime build's glob.cc buffer — other runtime versions may truncate at a different point. The fix's mechanism is version-independent: bodies passed via stdin or a file never enter the runtime's argv conversion at all.

bats regression

tests/test_messaging.bats: 44 ok / 0 fail / 0 skip on Windows/Git Bash — all 16 new tests included. As far as we know, the upstream CI Windows leg does not run the messaging suite, so this is the first Windows-native result for these tests.

Positive control

Sending an 8,187-char body via the positional argument from a native caller (Node.js execFileSync, PowerShell) reproduced the truncation to 8,186 chars, confirming the bug environment is identical to the one documented in #378.

Matrix results (summary)

Six body variants (at/above the 8,186-char boundary, multibyte, trailing newline, shell metacharacters) tested across Node.js, PowerShell, and Bash callers:

Mode Result
--stdin (Node.js) All bodies stored byte-exact
--body-file (Node.js) All bodies stored byte-exact
--stdin (PowerShell naive pipe) All bodies stored correctly; PowerShell pipeline appends a trailing LF (+1 byte) which send.sh faithfully stores — this is a caller-side behavior, not a send.sh issue. Confirmed on both pwsh 7.6.3 and Windows PowerShell 5.1
--stdin (PowerShell raw stdin, .NET Process API) All bodies stored byte-exact, on both pwsh 7.6.3 and Windows PowerShell 5.1
--body-file (PowerShell) All bodies stored byte-exact
--stdin / --body-file (Bash MSYS->MSYS) All bodies stored byte-exact
Positional (Node.js / PowerShell, native caller) Truncated at 8,186 as expected — the bug this PR fixes
Positional (Bash MSYS->MSYS) No truncation (cygheap path bypasses the glob buffer)

Two Windows caveats we hit while testing, both caller-side and worth a line in the docs:

  1. A bare bash FileName in the .NET Process API resolves to WSL bash, not MSYS bash. CreateProcess searches System32 before PATH, so C:\Windows\System32\bash.exe (WSL) shadows Git's bash — the body then never reaches send.sh (our first test run reported raw stdin as "broken" until we root-caused this). Native callers spawning bash directly should use the full path to MSYS bash. PowerShell's own &/pipeline invocation resolves via PATH and is not affected.
  2. ProcessStartInfo.StandardInputEncoding = Encoding.UTF8 injects a UTF-8 BOM into the stream even when writing through BaseStream. Leave it unset (or use a BOM-less UTF8Encoding) for byte-exact bodies.

With those two caller-side details handled, --stdin is byte-exact from every native caller we tried; --body-file needs no care at all and is the simplest recommendation for PowerShell users.

(If you inspect our raw logs: a few FAIL markers on the metacharacter body are an off-by-one in our harness's expected length — the generated body is 8,186 chars, not 8,187 — and one on the trailing-newline body is our bash caller's $(<file) stripping the final newline before send.sh ever ran. The stdin-raw EMPTY rows in the first run are the WSL-bash resolution bug from caveat 1, corrected in the Phase 5 re-run. In every case the DB content matched the bytes send.sh actually received.)

Field-incident replay

We replayed the exact body from the original truncation incident (8,324 chars / 12,234 bytes):

Mode Result
--stdin byte-exact match (cmp confirms identical)
--body-file byte-exact match
Positional (control) Truncated to 8,186 chars, matching the DB record from the incident

The real body content is non-public; only lengths, byte counts, and cmp results are reported here, consistent with the evidence in #378.

Conclusion

--stdin and --body-file completely eliminate the MSYS2 argv truncation on Windows. The field incident is directly resolved. We recommend merging this PR.

Test harness and raw logs are available on request.

@Masashi-Ono0611

Copy link
Copy Markdown
Contributor Author

This is fantastic — thank you so much! A byte-exact replay of the original 8,324-char incident body plus the full bats suite on real MSYS2 is exactly the verification this PR needed, and it's coverage I couldn't have produced myself without a Windows environment. Really grateful you took the time to run it end to end.

@fujibee

fujibee commented Jul 29, 2026

Copy link
Copy Markdown
Owner

@Masashi-Ono0611

Thanks for this. The direction is right: reading the body from a file descriptor instead of argv is the correct first step, and we intend to take it as an additive mitigation. The test pass is unusually thorough — checking that the trailing-newline test actually fails when the verbatim read is replaced with a line-oriented one, rather than trusting the green, is the kind of evidence that makes a review short.

One scoping note before the requested changes. This PR will not close #378. #378 is about the positional body being a hazard at all, and that is only resolved when the positional form is retired; adding a safe alternative alongside it narrows exposure without removing it. We will keep #378 open and link this PR as the first stage.

Three changes we would like.

B1 — flip the canonical form in the docs, not just add to it. Right now --stdin and --body-file are introduced as an option for tricky bodies while the positional form stays the headline shape in README.md, SKILL.md, and the first-party examples. That leaves the hazardous form as the one people copy. Please make --stdin / --body-file the canonical spelling everywhere first-party examples appear, mark the positional form as deprecated in the same places, and state that an agent-composed body MUST NOT use the positional form. To be explicit about scope: we see this as a three-stage migration — (1) flip the canonical form in documentation and examples, (2) emit a deprecation warning on positional use, (3) remove it in the next breaking change — and only stage (1) belongs in this PR.

B2 — a NUL byte is silently truncating, so "verbatim" does not hold yet. IFS= read -r -d '' BODY stops at the first NUL, so a body carrying one is cut there and the shortened value is stored and delivered with a success exit. Your inline comment already notes that a NUL terminates both read -d '' and a bash string, which is exactly the problem: the caller is told nothing. Please make this fail closed — distinguish the success and EOF cases from read so a NUL-bearing input is rejected with a clear error rather than truncated — and add a rejection test for it. Once input containing a NUL is refused rather than quietly shortened, the "verbatim" claim is true as written; today it holds only for NUL-free input.

B3 — the backward-compatibility claim needs correcting. The PR body says the positional form is unchanged and that no caller has to migrate. That is not quite the case: a caller that was passing a body which is literally --stdin or --body-file now has that argument reinterpreted as a mode selector, and must rewrite the call as -- --stdin to get the old behaviour. That is a genuine breaking ambiguity for those callers, narrow but real, and -- is the mitigation rather than a reason the break does not exist. Please state it that way, naming -- <body> as the migration syntax. Catching the hole and adding the terminator was the right call; we just want the description to match what happens.

Two non-blocking notes.

(1) The test named "-- keeps a body that is literally --force working, and --force after it still applies" does not exercise the second half of its own name. It runs send.sh testteam alice bob -- --force and asserts the stored body, but never passes a trailing --force and never asserts any force-mode effect. Either add the trailing flag and assert the behaviour it enables (for example, a send to an unregistered recipient that would otherwise be refused), or narrow the name to what it checks.

(2) A few places describe the change in terms that blend "the new path avoids the hazard" with "the hazard is gone". Since the positional form remains available and remains the shape most existing callers use, please keep those statements to what the safe path does, and leave the removal claim for the stage that actually removes it.

On the shape of the interface itself: two separate flags is fine and we are not asking you to unify them into a --body-file - convention. The current split reads clearly at a call site, which matters more here than matching another tool's spelling.

…ating (fujibee#378)

Addresses the review on fujibee#507.

B1 — make --stdin/--body-file the canonical spelling rather than an extra
option alongside the positional form. Every first-party example now uses it
and marks the positional form deprecated in the same place: README.md,
README.ja.md (which the first commit left out of sync), SKILL.md, all nine
driver templates, the grok-build delivery rule, the codex bridge's reply
prompt, both session-start.sh monitor directives, and docs/building-on-agmsg.md.
Each agent-facing surface states that a body an agent composes MUST NOT use
the positional form. This is stage (1) only: no runtime deprecation warning,
nothing removed.

B2 — a NUL byte was silently truncating the body and still exiting 0, so
"verbatim" only held for NUL-free input. `read -d ''` exits 0 only when it
found its delimiter and non-zero at EOF, so that status now distinguishes a
NUL-bearing input from a complete read, and such input is refused with
nothing stored. The check runs before the empty-input check because a
leading NUL yields an empty value AND a zero exit.

Also: the "-- keeps a body that is literally --force working, and --force
after it still applies" test never passed a trailing --force. It is split
into a narrowed test plus a pair that sends to an unregistered recipient
(refused unless force mode is on) with a control asserting the same call
without the trailing flag fails on the roster check specifically.

Examples use the delimiter AGMSG_BODY, not EOF: a body containing a bare
EOF line would end the heredoc early and run the rest as shell commands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Masashi-Ono0611

Copy link
Copy Markdown
Contributor Author

Thanks — the scoping note is the useful part, and I've taken it as written: this PR is stage (1) only, Part of #378 stands, and nothing here emits a warning or removes anything.

B1 — canonical form flipped, not appended to. --stdin / --body-file is now the spelling everywhere a first-party example appears, with the positional form marked deprecated in the same place and an explicit MUST NOT for an agent-composed body on every agent-facing surface: README.md, README.ja.md, SKILL.md, all nine scripts/drivers/types/*/template.md, the grok-build delivery rule file, codex-bridge.js's reply prompt, both session-start.sh monitor directives, and docs/building-on-agmsg.md.

Two judgment calls in there worth your eye:

B2 — NUL now fails closed. read -d '' exits 0 only when it found its delimiter and non-zero at EOF, so a zero exit means exactly "this input carries a NUL and the value is already cut short at it". That status is checked instead of discarded, and such input is refused with a clear error; nothing is stored. The check runs before the empty-input check, because a leading NUL produces an empty value and a zero exit and would otherwise be misreported as empty stdin. Three rejection tests cover stdin, --body-file, and that ordering. Mutation-checked both ways: restoring || true fails exactly the three NUL tests; moving the check after the empty check fails only the leading-NUL one. So the "verbatim" claim now holds as written rather than only for NUL-free input.

B3 — compatibility claim corrected. You're right, and the PR body no longer says otherwise. It now states that a caller whose body is literally --stdin or --body-file has that argument reinterpreted as a mode selector and must be rewritten as send.sh <team> <from> <to> -- <body>, that this is a genuine if narrow breaking ambiguity, and that -- is the migration syntax for it rather than evidence the break does not exist.

(1) The test now exercises the half of its name it was skipping: it sends -- --force --force to an unregistered team/recipient, which the roster check refuses unless force mode is on, so the send succeeding is what proves the trailing --force still parsed after -- consumed the body. I added a control asserting the same call without the trailing flag is refused — without it, the success could equally mean the roster check never ran for an unknown team. The original test kept its assertion and lost the half of the name it did not check.

One thing my own review pass caught while making the heredoc form canonical, which I fixed rather than shipped: the examples were delimited by EOF, so a body containing a line that is exactly EOF would end the heredoc early and the rest of the body would run as shell commands — the same class of hazard this PR exists to close, reintroduced by the form we are promoting. Every example now uses AGMSG_BODY, and each surface says once to pick a delimiter the body cannot contain on a line by itself, or to use --body-file when that cannot be ruled out.

(2) Reworded. The script header, the README/JA paragraphs, the test-file comment and the PR body now say what the safe path does — a body sent that way meets neither hazard — and state plainly that the hazard is not gone, because the positional form still exists and still carries both. The removal claim is left for stage (3).

One thing I found while sweeping and deliberately did not change, since it is code rather than an example and so outside stage (1) as you scoped it: two first-party callers still pass a body positionally — scripts/windows/dispatch.sh (which on Windows is the exact MSYS argv path #378 describes) and the non-inline prompt path's sibling in codex-bridge.js. After this PR the docs tell agents an agent-composed body must not use the positional form while agmsg's own Windows dispatcher still does. Happy to migrate them in this PR if you'd rather not carry that gap into stage (2), or to file it separately — your call.

On the interface shape: understood, keeping the two flags as they are.

…our bodies

Second review pass (agmsg peer + self-audit) found three description/reality
gaps of the same kind B3 was about.

- The MUST NOT was missing from the codex bridge's runtime prompt — the one
  text an agent reads before composing a reply — where it existed only as a
  source comment. Emit it there. Also add the "deprecated" marking to
  docs/building-on-agmsg.md and both session-start.sh directives, which had
  the canonical form but not the wording B1 asks for in the same places.

- The compat break is four literal bodies, not three: `--` itself was an
  ordinary positional body before (only argument 5 was inspected) and is now
  consumed as the terminator. Migration form is `-- --`. Pinned with a test.

- send.sh's header claimed the positional form "keeps working for now so no
  existing caller breaks", contradicting the break documented alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Masashi-Ono0611

Copy link
Copy Markdown
Contributor Author

Corrections to my previous comment — a second review pass found three things I got wrong, all of the same "description does not match what happens" kind B3 was about.

The break is four literal bodies, not three. I missed -- itself. Before the flags it was an ordinary positional body, because only argument 5 was inspected; it is now consumed as the terminator. So the affected set is --, --stdin, --body-file, --force, and the migration form for the first one is send.sh <team> <from> <to> -- --. Verified against the pre-PR script (BODY=[--] FORCE=0) rather than inferred, and pinned with a test. PR body updated.

I named the wrong callers. codex-bridge.js does not invoke send.sh at all — it only builds the prompt string, and both its branches were already flipped in this PR. The two real first-party callers passing a variable body positionally are scripts/windows/dispatch.sh:176 and app/src-tauri/src/agmsg.rs:613 (the desktop app), which I had missed entirely. scripts/despawn.sh:101 also sends positionally but with the fixed literal ctrl:despawn, so it is not exposed. Both real callers hand the body to a process spawn rather than a shell, so the evaluation hazard does not reach them — the argv one still does. The stage-(1) line still holds, but that is the accurate list.

B1 was not actually complete where it matters most. I had claimed the MUST NOT was on every agent-facing surface. It was not on the codex bridge's runtime prompt — the one text an agent actually reads before composing a reply — where it existed only as a source comment. It is now emitted in the prompt. I also added the deprecation marking to docs/building-on-agmsg.md and to both session-start.sh directives, which had the canonical form but not the "deprecated" wording your B1 asks for in the same places.

I also dropped a line in send.sh's header that said the positional form "keeps working for now so no existing caller breaks" — which contradicted the break I had just documented.

Nothing changed in B2, the --force test pair, or the hazard wording. Full suite still green; tests/test_messaging.bats is now 50 ok, 0 not ok.

…tions

Follow-through on the fourth affected literal body. The compat escape hatch is
described in the README (both languages), SKILL.md and all nine driver
templates, and each still listed only `--stdin` / `--body-file` / `--force` —
the three cases known before `--` itself turned out to be affected. Those are
exactly the places a caller looks when their body is one of these, so they now
list all four and name `-- --` as the form for a body of `--`.

Docs only; no behaviour change. The `--` case itself is already implemented
and pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants