# 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_value` used plain line-oriented `read -rp` in canonical mode. A multi-line Ctrl+V paste floods the tty queue; `read` consumes 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 update` ran). Single-line paste was unaffected. - **File changes:** `lib/menu-lib.sh` 169→362 (new `menu_read_value` raw-mode bracketed-paste reader + `menu_redraw`; `menu_ask_value` now delegates to it); `bin/pos-ai-alias` 712→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 gen` idempotent · `make check` OK · `make lint` 0 FAIL / 0 WARN. - **Commit:** `4306a53` — pushed to `main` (`300b742..4306a53`). [DONE] --- ## 1. Root Cause `lib/menu-lib.sh` `menu_ask_value` ended in: ```bash 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 0` raw mode (isig off so Ctrl-C/Z/\ arrive as bytes); `stty -g` snapshot 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|tr` reader (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 `CANCELLED` and 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_value` now calls `menu_read_value` via 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: bash `case` patterns do **not** match a single literal backslash (verified empirically). - `_alias_prompt_decode` — order-safe: `\n` first, then `\\`. - `_alias_load` now decodes the prompt field; `_alias_save` encodes 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 `\n` for 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_value` was 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-alias` is the tool whose "Insert Prompt" step accepted multi-line text. - `Ctrl-C` inside 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 `\r` only if the terminal emulator delivers them un-mapped; with `icrnl` on, 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.