The slash-command completer (__larry_complete_slash) intentionally appends a trailing space after a unique match for arg-command ergonomics, but the main_loop dispatcher matched exact `case` globs — so a completed `/quit ` missed the `/quit)` arm and fell through to "unknown command". Latent since v0.6.6 (tab completion). Fixed by rtrimming the dispatch key once at the `case "$input"` boundary, which also transitively protects the sub-command dispatchers (/origin, /phi-auto, /phi-sidecar, /mouse) that consume the same $input via _slash_args. Interior `/load FILE` spacing is preserved. Added a shared rtrim() helper to lib/cygwin-safe.sh next to strip_cr. Co-Authored-By: Clover (Claude Opus 4.7) <noreply@anthropic.com>
133 lines
5.6 KiB
Bash
Executable File
133 lines
5.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# cygwin-safe.sh — three primitives that defend Larry-Anywhere against the
|
||
# Cygwin/MobaXterm CR-taint pattern that crashed OAuth in v0.7.3.
|
||
#
|
||
# Pattern (full diagnosis in
|
||
# Deliverables/2026-05-27-cloverleaf-larry-oauth-arithmetic-fix.md and the
|
||
# v0.7.5 CR-safety sweep deliverable):
|
||
#
|
||
# On MobaXterm / Cygwin / Git-Bash-for-Windows, any of the following can
|
||
# return a string ending in a literal carriage return (\r):
|
||
# - `$(date +%s)`, `$(date ...)`, `$(cmd)` against a Cygwin-built binary
|
||
# - `read` of user input (depending on tty mode)
|
||
# - `cat`/`head`/`tail` against a CRLF-line-ended file
|
||
# - `$(<file)` when the file is CRLF
|
||
# - `wc -l < file`, `wc -c < file` (the count is fine, but `wc.exe` may
|
||
# still emit `\r\n` on the captured stdout)
|
||
# - `jq -r '.field' file.json` when file was created with CRLF
|
||
# - Heredoc lines that came through a Windows clipboard
|
||
#
|
||
# The CR is invisible in normal output but lethal when the string lands in:
|
||
# - bash arithmetic ($(( )), (( )), let, [ -gt N ]) → "invalid arithmetic
|
||
# operator (error token is "")"
|
||
# - case dispatchers → pattern matches LITERAL `/cmd\r`, not `/cmd`
|
||
# - regex tests → `[[ $x =~ ^[Yy]$ ]]` silently fails on `Y\r`
|
||
# - path construction → mkdir/stat fail with ENOENT on `dir\r/file`
|
||
# - HTTP headers → server rejects the malformed Authorization line
|
||
# - file compares → `[[ $a == $b ]]` silent false-negative
|
||
#
|
||
# This file is SOURCEABLE — every caller does:
|
||
# . "$LARRY_LIB_DIR/cygwin-safe.sh"
|
||
#
|
||
# Idempotent: re-sourcing is harmless (functions just get redefined identically).
|
||
# It defines functions only, runs no code on source, sets no `set -u/-e/-o pipefail`
|
||
# globally (those are the caller's responsibility — we must not change them).
|
||
|
||
# coerce_int VAL [DEFAULT] — return a clean decimal integer that is SAFE to
|
||
# drop into any bash arithmetic / integer-test context.
|
||
#
|
||
# Algorithm: strip every byte that isn't 0-9, then fall back to DEFAULT (or 0)
|
||
# if the result is empty. No printf %d (whose behaviour on CR taint varies by
|
||
# libc), no shell expansion in arithmetic context — nothing that can crash
|
||
# the caller.
|
||
#
|
||
# Use whenever the value will appear in:
|
||
# $((expr)) (( expr )) [ X -gt Y ] [[ X -lt Y ]] let X=...
|
||
coerce_int() {
|
||
local raw="${1:-}" default="${2:-0}"
|
||
local cleaned; cleaned=$(printf '%s' "$raw" | tr -cd '0-9')
|
||
printf '%s' "${cleaned:-$default}"
|
||
}
|
||
|
||
# strip_cr VAL — return VAL with every embedded carriage return removed.
|
||
#
|
||
# Use when the value will appear in:
|
||
# case "$X" in ...) ...; esac # pattern dispatchers
|
||
# [[ "$X" =~ ^[Yy]$ ]] # regex tests
|
||
# [[ "$X" == "literal" ]] # string compares
|
||
# "$prefix/$X" # path construction
|
||
# "-H Authorization: Bearer $X" # HTTP headers
|
||
#
|
||
# Cheaper than coerce_int — no subshell, pure bash parameter expansion.
|
||
strip_cr() {
|
||
local v="${1:-}"
|
||
# Strip ALL \r occurrences, not just trailing — embedded CRs (from CRLF
|
||
# multi-line input) are just as toxic for the consumers above.
|
||
printf '%s' "${v//$'\r'/}"
|
||
}
|
||
|
||
# rtrim VAL — return VAL with all TRAILING whitespace removed (spaces, tabs,
|
||
# and CR — anything in [:space:]). Leading and interior whitespace untouched.
|
||
#
|
||
# Use immediately before a `case "$X" in ...) esac` pattern dispatcher whose
|
||
# arms are exact-string globs (e.g. /quit) /help)). Bash case patterns are
|
||
# literal globs, so a trailing space makes "/quit " miss "/quit)" and fall
|
||
# through to the catch-all. This bites tab completion: __larry_complete_slash
|
||
# intentionally appends a friendly trailing space after a unique match (so
|
||
# arg-taking commands feel snappy), which the exact-match dispatcher then
|
||
# rejects for no-arg commands. rtrim at the dispatch boundary tolerates the
|
||
# completer's space, a user-typed trailing space, and any CR remnant in one
|
||
# defensive line — without removing the completer's UX nicety.
|
||
#
|
||
# Trailing-only by design: interior spaces separate a command from its
|
||
# argument (/load FILE), so we must never collapse those. The expansion
|
||
# below strips the run of trailing whitespace chars only.
|
||
#
|
||
# Pure bash parameter expansion — no subshell, no external tools.
|
||
rtrim() {
|
||
local v="${1:-}"
|
||
printf '%s' "${v%"${v##*[![:space:]]}"}"
|
||
}
|
||
|
||
# read_clean VAR [PROMPT] — like `read -r VAR`, but every captured byte that
|
||
# is \r gets stripped before the assignment.
|
||
#
|
||
# Why a wrapper instead of post-processing the var: bash's `read` already
|
||
# strips a trailing newline, but on Cygwin/MobaXterm with a CRLF tty the
|
||
# \r BEFORE the \n stays in the variable. Doing `read X; X="${X//$'\r'/}"`
|
||
# at every call site is 2× the diff and easy to forget; this folds it.
|
||
#
|
||
# Reads from /dev/tty by default (same as the prevailing `read -r ans </dev/tty
|
||
# || ans=""` idiom across the codebase) so it works when stdin is piped.
|
||
# If /dev/tty is unavailable, falls back to plain stdin.
|
||
#
|
||
# Usage:
|
||
# read_clean answer "Proceed? [y/N]: "
|
||
# if [[ "$answer" =~ ^[Yy]$ ]]; then ... fi
|
||
#
|
||
# Returns the same exit code as the underlying `read` (1 on EOF).
|
||
read_clean() {
|
||
local _var="$1"; shift
|
||
local _prompt="${1:-}"
|
||
local _raw=""
|
||
if [ -r /dev/tty ]; then
|
||
if [ -n "$_prompt" ]; then
|
||
IFS= read -r -p "$_prompt" _raw </dev/tty
|
||
else
|
||
IFS= read -r _raw </dev/tty
|
||
fi
|
||
else
|
||
if [ -n "$_prompt" ]; then
|
||
IFS= read -r -p "$_prompt" _raw
|
||
else
|
||
IFS= read -r _raw
|
||
fi
|
||
fi
|
||
local _rc=$?
|
||
# Strip ALL CRs (paste of multi-line CRLF can introduce embedded ones).
|
||
_raw="${_raw//$'\r'/}"
|
||
# Assign through eval — printf-quote the value so it survives metacharacters.
|
||
printf -v "$_var" '%s' "$_raw"
|
||
return $_rc
|
||
}
|