8.0 KiB
Builder Report — Paste-Safe Multi-Line Input in pos ai alias Insert Prompt
Date: 2026-08-27 Status: COMPLETE — root cause established, fix implemented, verified, committed, pushed
TL;DR
- Root cause:
menu_ask_valueused plain line-orientedread -rpin canonical mode. A multi-line Ctrl+V paste floods the tty queue;readconsumes only the first line and the rest stay queued — later prompts eat them, and after the script exits the interactive shell executes whatever remains (user-verified:$(whoami),; ls,echo test,sudo apt updateran). Single-line paste was unaffected. - File changes:
lib/menu-lib.sh169→362 (newmenu_read_valueraw-mode bracketed-paste reader +menu_redraw;menu_ask_valuenow delegates to it);bin/pos-ai-alias712→760 (prompt encode/decode for the ENV file, newline-safe truncate, edit wizard keeps the full original prompt). - Verification: pty harnesses — multiline + single-line paste captured verbatim with nothing executed, clean exit; Ctrl-D and Ctrl-C cancel cleanly (terminal restored); full create→list→show→edit E2E; encode/decode round-trips byte-exact.
- Gates:
bash -n×2 ·make genidempotent ·make checkOK ·make lint0 FAIL / 0 WARN. - Commit:
4306a53— pushed tomain(300b742..4306a53).
[DONE]
1. Root Cause
lib/menu-lib.sh menu_ask_value ended in:
IFS= read -rp "$pr: " val
bash -p read is line-oriented and canonical: it reads until the first newline and returns. A bracketed multi-line paste delivers line one\nline two\n… into the tty input queue as a burst; the read takes line one, and every later line stays queued. Those leftovers are then consumed by the next prompt or — once the script exits and the interactive shell reads keyboard input again — executed as shell commands. That is the paste bug: user-pasted text (including $(whoami), ; ls, sudo apt update in the report which triggered real effects) was treated as live input.
Why not read -erp (readline)?
Tested on a real pty with bracketed paste enabled. Readline consumes the paste atomically (nothing executes, clean exit) — but on accept it returns only the first line; readline buffers a single line. That violates the core requirement that multi-line prompts paste correctly. A custom raw-mode reader is required.
Why not a plain read -N 1 byte loop?
bash read self-interrupts: with stty -isig verified on (diag: -isig -icanon -echo min 1 time 0), od -An -tu1 -N1 reads byte 3 (ETX) fine from the tty, and read -N 1 reads 0x03 fine from a pipe — but bash + tty + 0x03 dies by SIGINT (raw waitpid status 2) in both direct and command-substitution contexts, even with no trap and ISIG off. The reader therefore uses dd bs=4096 count=1 | od -An -tx1 | tr -d ' \n', which the pty tests show reads any byte (incl. 0x03/0x04) as plain data.
[DONE]
2. Fix
lib/menu-lib.sh — menu_read_value() (new)
stty -icanon -echo -isig min 1 time 0raw mode (isig off so Ctrl-C/Z/\ arrive as bytes);stty -gsnapshot restored on every exit path;trap 'restore; trap - INT TERM; return 1' INT TERM.- Enables bracketed paste (
\e[?2004h) on entry, disables (\e[?2004l) on exit. - Byte input: chunked
dd|od|trreader (one fork per input burst — pastes cost O(chunks), not O(per-byte forks)); bytes delivered as 2-hex-digit strings to a nameref; state (chunk/offset) persists because the reader runs in-place, never inside a$( )subshell (an early implementation that returned bytes via command substitution looped forever — each subshell's offset increment was lost). - Behavior:
\e[200~…\e[201~→ everything between inserted literally; newline/CR are data (echoed for display; CR renders matching CRLF pastes).- Enter outside a paste → submit. Backspace/DEL, Left/Right, Home/End, Delete, Ctrl-U. Up/Down ignored (no history).
- Ctrl-D on empty → cancel; Ctrl-C/Ctrl-Z/Ctrl-\ → cancel. Cancel returns rc 1 → callers print
CANCELLEDand continue. - Fast append path (no redraw) when the cursor is at end-of-value — pastes render with 0 redraws.
- Non-tty stdin or stty failure → falls back to plain
read(fail-closed, no paste protection possible).
menu_ask_valuenow callsmenu_read_valuevia command substitution (display → stderr, value → stdout, rc 1 = cancel/EOF).- Function index comment updated.
bin/pos-ai-alias — prompt persistence + display
_alias_prompt_encode—\→\\, newline →\n(escaped, so the ENV record stays single-line). Uses literal[ "$c" = '\' ]comparisons: bashcasepatterns do not match a single literal backslash (verified empirically)._alias_prompt_decode— order-safe:\nfirst, then\\._alias_loadnow decodes the prompt field;_alias_saveencodes it (previously the raw multi-line value was written straight into the|-delimited ENV record — corruption + line-splitting on round-trip)._alias_prompt_truncate— now replaces newlines with\nfor display and takes an optional max-length argument (create confirmation: 50; edit wizard: 80; edit summary: 40).- Edit wizard: the prompt default shown is the truncated render, but pressing Enter restores the full original prompt (
[ "$tmp" = "$default_display" ] && tmp="$default_prompt") — fixes a pre-existing bug where Edit+Enter silently truncated >80-char prompts; empty-original Enter keeps empty and continues instead of aborting the wizard.
[DONE]
3. Tests Performed
All harnesses under /tmp (not committed).
| Test | Result |
|---|---|
bash -n lib/menu-lib.sh bin/pos-ai-alias |
✅ |
Bracketed multiline paste through menu_ask_value (pty) — full capture |
✅ |
Nothing executed from the pasted content (incl. $(whoami), ; ls, sudo apt update) |
✅ |
Clean exit + terminal restored (\e[?2004l, sane termios) |
✅ |
| Single-line paste | ✅ |
| Ctrl-D on empty → CANCELLED → DONE, status 0 | ✅ |
| Ctrl-C → CANCELLED → DONE, status 0 (terminal not left in raw mode) | ✅ |
Encode/decode round-trips: backslash, literal \n text, mixed, empty |
✅ |
Full E2E: create (multiline paste with C:\temp\note + shell fragments) → ENV single-line record (\n/\\ escaped) → wrapper installed, executable, bash -n clean → wrapper passes whole prompt as ONE --system arg ($'…') → list table newline-safe → show decodes full prompt → edit Enter-keeps-full, "No changes — nothing to save." |
✅ |
Gates: make gen idempotent · make check OK · make lint 0 FAIL / 0 WARN |
✅ |
Harness files: pty_menulib_test.py, pty_cancel_test.py, pty_e2e_alias.py, roundtrip_test.sh, plus early experiments pty_paste_test.py/pty_paste_test2.py.
[DONE]
4. Notes / Remaining Risks
- The reader is byte-oriented; multi-byte UTF-8 is assembled byte-by-byte (correct round-trip; cursor arithmetic counts bytes, so editing inside multi-byte text can be coarse — same limitation as the previous readline-free implementation, acceptable for this tool).
- Only
menu_ask_valuewas made paste-safe. Other menu-lib primitives (menu_run,menu_pick,menu_guard) use canonical reads where a multi-line paste is not a meaningful input shape;pos-ai-aliasis the tool whose "Insert Prompt" step accepted multi-line text. Ctrl-Cinside the reader cancels the prompt and continues the script (CANCELLED). Previously any Ctrl-C killed the whole script; behavior is now more forgiving and the terminal is always restored first.- CRLF pastes render correctly (CR echoed) and are stored as raw
\ronly if the terminal emulator delivers them un-mapped; withicrnlon, they normalize to\n— either way the value stays literal text, never input.
[DONE]
Status: COMPLETE. Original objective (paste injection + broken multiline paste in pos ai alias Insert Prompt) is fixed at the shared input layer, verified with pty-level regression harnesses, committed (4306a53), and pushed.