104 lines
8.0 KiB
Markdown
104 lines
8.0 KiB
Markdown
# 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. |