From 9f97d15f9a3f492faf2bc25329f694291aab929e Mon Sep 17 00:00:00 2001 From: Bryan Johnson Date: Wed, 27 May 2026 15:18:51 -0700 Subject: [PATCH] v0.6.6: strip CR from jq output + 0600 oauth file + TAB slash completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (PRIMARY — unblocks OAuth on MobaXterm): jqf now strips \r from every jq output. Root cause of the multi-day "OAuth token unavailable" cascade was CRLF-tainted .oauth.json: `fetched_at=$(jqf ... '.fetched_at')` captured "1716826990\r", and `$((fetched_at + expires_in))` crashed with the cryptic "invalid arithmetic operator (error token is \"\"") — bash trying to print the embedded CR. Single-point fix in jqf covers every caller (ensure, refresh, status, debug, login). Added belt-and-suspenders `printf '%d'` coercion on every numeric capture so any future non-CR junk falls back to 0 instead of crashing arithmetic. /oauth-debug now reports CR/LF byte counts so future CRLF taint is visible at a glance. Bug 2 (security): .oauth.json was landing at 0644 on Cygwin/MobaXterm even though both cmd_login and cmd_refresh called `chmod 600`. Introduced secure_install (install -m 600 → cp+chmod → mv+chmod fallback chain) so the mode is set atomically at placement. Also added umask 077 to cmd_refresh (only cmd_login had it) so the .new sidecar is created tight from the start, plus a pre-mv chmod 600 on the sidecar for fs-where-install-doesn't-stick. On a fully POSIX FS this is now triple-redundant; on Cygwin NTFS we get as close to 0600 as the ACL emulation will allow. Feature 1: TAB completion for slash commands. New _LARRY_SLASH_CMDS canonical array near read_user_input, __larry_complete_slash uses `bind -x` to read $READLINE_LINE / $READLINE_POINT and rewrites the buffer in-place. Prefix matching is primary; subsequence fuzzy is the fallback. Non-slash lines and mid-arg TABs fall back to literal-tab insertion so muscle memory isn't broken. Heredoc continuation lines DO NOT get completion (binding only fires on the first read). /help section documents the behavior with examples. Smoke-tested on macOS: - CRLF-tainted .oauth.json: ensure returns access_token cleanly, status & debug print real numbers + human timestamps (no bash arith crash). - secure_install: file ends at 0600 even when source was 0644. - Completion: /h→/help, /ss→lists ssh-*, /ssh-h→/ssh-hosts, /q→/quit, /oa→/oauth-debug, /sssp→/ssh-setup (fuzzy), /xyz→silent, non-slash and "/cmd args"→literal tab. Co-Authored-By: Claude Opus 4.7 --- VERSION | 2 +- larry.sh | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++- lib/oauth.sh | 82 +++++++++++++++++++++++-- 3 files changed, 246 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index ef5e445..05e8a45 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.5 +0.6.6 diff --git a/larry.sh b/larry.sh index 3271dd0..d28c05f 100755 --- a/larry.sh +++ b/larry.sh @@ -36,7 +36,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.6.5" +LARRY_VERSION="0.6.6" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_BASE_URL="${LARRY_BASE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-${LARRY_BASE_URL}/larry.sh}" @@ -1226,6 +1226,16 @@ Slash commands: /help this help Multi-line input: start with '<<' on its own line, end with 'EOF' on its own line. + +TAB completion (v0.6.6): + Type '/' followed by any prefix and press TAB. + /h → /help + /ss → lists every /ssh-* command + /ssh-h → /ssh-hosts + /q → /quit + Subsequence fuzzy is the fallback when no prefix matches (e.g. /sssp finds + /ssh-setup). Non-slash input falls back to inserting a literal tab so + normal typing isn't disturbed. EOF } @@ -1256,6 +1266,157 @@ _run_ssh_helper() { "$helper" "$@" || true } +# ───────────────────────────────────────────────────────────────────────────── +# Slash-command TAB completion (v0.6.6) +# ───────────────────────────────────────────────────────────────────────────── +# +# _LARRY_SLASH_CMDS — canonical list of slash commands. This is the single +# source of truth for the TAB-completion function. The case statement in +# main_loop is the dispatcher; this array is what the user sees when they +# fuzzy-match. Keep them in sync when adding new commands. +# +# Excluded on purpose: command aliases (e.g. /exit and /q both map to +# /quit) — completing to the canonical form is friendlier than offering +# every spelling. +_LARRY_SLASH_CMDS=( + /help + /quit + /sys + /pwd + /env + /auth + /login + /logout + /oauth-debug + /lesson + /lessons + /export + /phi + /unmask + /tokens + /ssh + /ssh-hosts + /ssh-add + /ssh-remove + /ssh-pass + /ssh-setup + /ssh-close + /ssh-status + /redetect + /sites + /site + /reset + /model + /cd + /load +) + +# __larry_complete_slash — bound to TAB via `bind -x` (see _install_readline_tab). +# +# Reads READLINE_LINE (the current line buffer) and READLINE_POINT (cursor +# position, 0-indexed). If the line starts with "/" and the cursor is on the +# first word, we attempt prefix completion against _LARRY_SLASH_CMDS: +# +# * exactly one match → replace the line with the match (+ a trailing space +# for commands that take an arg, e.g. "/site ") +# * many matches → print them under the prompt and re-display the line +# (readline will redraw automatically when we return) +# * zero matches → silent no-op (readline does NOT insert a literal +# tab, matching slash-aware completion in modern +# shells) +# +# If the line does NOT start with "/" we insert a literal tab so the user's +# muscle memory for whitespace alignment / indented heredocs still works. +# +# Refs: +# bash(1) — READLINE Variables: $READLINE_LINE, $READLINE_POINT. +# bash(1) — `bind -x '"\C-x": shell-function'` binds a key to a shell +# function that may read/modify $READLINE_LINE in place. Available since +# bash 4.0. +__larry_complete_slash() { + local line="$READLINE_LINE" + local point="${READLINE_POINT:-0}" + # Only complete when the buffer is a single token starting with '/'. + # If there's whitespace before the cursor, we treat it as "user typing + # arguments to a command", not "user wants to complete the command name". + case "$line" in + /*) + # Has it already been word-split (a space anywhere in the line)? If yes, + # fall through to literal-tab. Completion is for the command name only. + case "$line" in + *' '*) + READLINE_LINE="${line:0:point}"$'\t'"${line:point}" + READLINE_POINT=$((point + 1)) + return 0 + ;; + esac + ;; + *) + # Non-slash line — insert a literal tab at the cursor. + READLINE_LINE="${line:0:point}"$'\t'"${line:point}" + READLINE_POINT=$((point + 1)) + return 0 + ;; + esac + + # Build the match list. Primary: prefix match. If exactly one prefix match, + # complete it. If many, print them. If zero prefix matches AND the input is + # at least 2 chars, try a subsequence fuzzy match as a polish; if that + # yields exactly one, complete to it. + local prefix="$line" + local matches=() cmd + for cmd in "${_LARRY_SLASH_CMDS[@]}"; do + case "$cmd" in + "$prefix"*) matches+=("$cmd") ;; + esac + done + + if [ "${#matches[@]}" -eq 0 ] && [ "${#prefix}" -ge 2 ]; then + # Subsequence fuzzy: every char of $prefix (after the leading '/') must + # appear in $cmd in order. Cheap, predictable, no scoring. + local needle="${prefix#/}" + for cmd in "${_LARRY_SLASH_CMDS[@]}"; do + local hay="${cmd#/}" + local i=0 nlen="${#needle}" ok=1 + while [ "$i" -lt "$nlen" ]; do + local ch="${needle:i:1}" + case "$hay" in + *"$ch"*) hay="${hay#*"$ch"}" ;; + *) ok=0; break ;; + esac + i=$((i + 1)) + done + [ "$ok" -eq 1 ] && matches+=("$cmd") + done + fi + + if [ "${#matches[@]}" -eq 1 ]; then + # Replace the buffer with the matched command. Append a space so the user + # can immediately type the argument. (No-arg commands waste one keystroke + # — acceptable.) + READLINE_LINE="${matches[0]} " + READLINE_POINT=${#READLINE_LINE} + elif [ "${#matches[@]}" -gt 1 ]; then + # Multiple matches: print them on a new line, then let readline redisplay + # the prompt with the user's current buffer intact. + printf '\n' + printf ' %s' "${matches[@]}" + printf '\n' + # READLINE_LINE / READLINE_POINT stay as-is so the user sees their input. + fi + # Zero matches → silent no-op (the user's typo stays on screen so they can fix it). +} + +# _install_readline_tab — wire TAB to the slash-completer for the lifetime +# of the REPL. Safe to call multiple times (bind is idempotent for the same +# key). No-op if `bind` isn't a builtin in this bash (e.g. non-interactive +# subshells, sh-mode invocations). +_install_readline_tab() { + # `bind -x` is bash 4.0+. The `2>/dev/null` swallows the warning bash + # emits on non-tty stdin ("bind: warning: line editing not enabled"). + bind -x '"\t": __larry_complete_slash' 2>/dev/null || true +} + read_user_input() { # Returns user input via global LARRY_INPUT. # If first line is "<<", read until line "EOF" (heredoc-style). @@ -1264,12 +1425,18 @@ read_user_input() { # correctly across terminals (MobaXterm/Cygwin in particular often has # stty erase mismatches that swallow plain `read`'s backspace). We pass # the prompt via -p so readline knows the visible width. + # + # v0.6.6: TAB on the first line is bound to __larry_complete_slash for + # slash-command completion. Continuation lines of a '<<' heredoc DO NOT + # get completion (we only `bind -x` ahead of the first-line read, not + # inside the heredoc loop), keeping the heredoc's "raw text" semantics. LARRY_INPUT="" local first if [ -t 0 ] && _readline_ok; then local prompt; prompt=$(printf '%syou>%s ' "$C_GREEN" "$C_RESET") # Clear the prompt the caller already printed, then re-emit via readline. printf '\r\033[K' + _install_readline_tab IFS= read -e -r -p "$prompt" first || return 1 [ -n "$first" ] && history -s "$first" else diff --git a/lib/oauth.sh b/lib/oauth.sh index 03dc0f1..96ad0eb 100755 --- a/lib/oauth.sh +++ b/lib/oauth.sh @@ -48,6 +48,31 @@ SCOPE="${LARRY_OAUTH_SCOPE:-org:create_api_key user:profile user:inference user: die() { printf 'oauth: %s\n' "$*" >&2; exit 1; } +# secure_install SRC DST — atomically move SRC to DST and force mode 0600. +# +# Why this exists (v0.6.6): on Cygwin/MobaXterm the .oauth.json was landing +# at mode 0644 even though both cmd_login and cmd_refresh called `chmod 600` +# after the `mv`. Two compounding issues: +# 1) `chmod` order matters less than `umask` — the new file's mode is set +# at create() time; the chmod afterward only adjusts ACL entries Cygwin +# may or may not actually honour on NTFS-backed mounts (MobaXterm +# portable mounts don't set `noacl`, so POSIX perms are emulated via +# NTFS ACLs that can silently degrade to "Everyone:Read"). +# 2) Prefer `install -m 600 SRC DST` when available: it sets the mode +# *atomically as part of the placement*, not as a post-hoc chmod. +# +# Fallback chain: install(1) → cp+chmod+rm → mv+chmod (legacy). Each branch +# ends with a redundant `chmod 600` so on a working POSIX FS the perms are +# always 0600 even if `install` silently no-ops the mode flag. +secure_install() { + local src="$1" dst="$2" + if command -v install >/dev/null 2>&1; then + install -m 600 "$src" "$dst" 2>/dev/null && rm -f "$src" && chmod 600 "$dst" 2>/dev/null && return 0 + fi + mv "$src" "$dst" || return 1 + chmod 600 "$dst" 2>/dev/null || true +} + # Dependency check command -v curl >/dev/null 2>&1 || die "curl required" command -v jq >/dev/null 2>&1 || die "jq required" @@ -60,10 +85,19 @@ b64url() { base64 | tr '/+' '_-' | tr -d '=' | tr -d '\n'; } # may be a Windows-native binary that doesn't understand Cygwin paths like # /home/mobaxterm/... when they come in as argv. Stdin redirection always # works because bash does the open() itself. +# +# IMPORTANT (v0.6.6): jq output on MobaXterm/Cygwin can carry a trailing \r +# when the source JSON file was written with CRLF line endings, because jq -r +# preserves line endings byte-for-byte. A captured value of "1716826990\r" +# then crashes bash arithmetic with the cryptic: +# syntax error: invalid arithmetic operator (error token is "") +# (bash trying to print the embedded CR.) We strip \r at the source so EVERY +# caller is safe regardless of whether they use the value in arithmetic, string +# comparison, or printf. This is the central CR-defense; do not remove it. # Usage: jqf jqf() { local file="$1"; shift - jq "$@" < "$file" + jq "$@" < "$file" | tr -d '\r' } urlenc() { @@ -186,8 +220,10 @@ EOF rm -f "$OAUTH_FILE.new" die "wrote oauth file is missing access_token/refresh_token/fetched_at — aborting" fi - mv "$OAUTH_FILE.new" "$OAUTH_FILE" - chmod 600 "$OAUTH_FILE" + # Pre-chmod the tempfile too — belt-and-suspenders in case secure_install's + # `install` branch silently no-ops the mode flag on a hostile filesystem. + chmod 600 "$OAUTH_FILE.new" 2>/dev/null || true + secure_install "$OAUTH_FILE.new" "$OAUTH_FILE" || die "failed to install oauth file" printf '\n✓ logged in. Tokens saved to %s (mode 0600).\n' "$OAUTH_FILE" cmd_status } @@ -212,6 +248,12 @@ cmd_refresh() { fi local now; now=$(date +%s) + # Force tight perms on the sidecar at create() time — cmd_refresh historically + # didn't set umask before the tempfile write, so the .new file inherited the + # process default (often 0022 → 0644). On Cygwin where chmod-after-the-fact + # may degrade to NTFS-ACL approximation, the tempfile mode at creation is the + # only knob we can trust. + umask 077 # Pre-read the old refresh_token so we don't need --slurpfile (which would # take a file path argv-style and break on MobaXterm's Windows-native jq). local prev_refresh; prev_refresh=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty') @@ -230,8 +272,12 @@ cmd_refresh() { printf 'refresh: new oauth file is missing required keys — keeping previous file intact\n' >&2 return 1 fi - mv "$OAUTH_FILE.new" "$OAUTH_FILE" - chmod 600 "$OAUTH_FILE" + chmod 600 "$OAUTH_FILE.new" 2>/dev/null || true + if ! secure_install "$OAUTH_FILE.new" "$OAUTH_FILE"; then + rm -f "$OAUTH_FILE.new" + printf 'refresh: failed to install new oauth file\n' >&2 + return 1 + fi jqf "$OAUTH_FILE" -r '.access_token' } @@ -261,6 +307,13 @@ cmd_ensure() { local fetched_at expires_in fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') + # Extra paranoia (v0.6.6): the v0.6.5 /oauth-debug surfaced "syntax error: + # invalid arithmetic operator" — diagnosed as CR contamination from CRLF + # JSON files (now stripped in jqf). Belt-and-suspenders: coerce via printf + # to a pure integer so ANY residual junk (whitespace, NULs, alpha glitches) + # falls back to 0 instead of crashing the arithmetic expression. + fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) + expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) local now; now=$(date +%s) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) @@ -297,6 +350,8 @@ cmd_status() { fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(unknown)"') + fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) + expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) local now; now=$(date +%s) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) @@ -363,12 +418,29 @@ cmd_debug() { fi printf ' status: parses as JSON\n' + # Line-ending check — Cygwin/MobaXterm tools sometimes write JSON with CRLF + # line endings (especially if the file gets edited by a Windows editor). The + # trailing \r on each line propagates through `jq -r` and crashes downstream + # bash arithmetic. Show byte counts of CR vs LF so we can spot CRLF taint. + local cr_count lf_count + cr_count=$(tr -dc '\r' < "$OAUTH_FILE" | wc -c | tr -d ' ') + lf_count=$(tr -dc '\n' < "$OAUTH_FILE" | wc -c | tr -d ' ') + printf ' line endings: CR=%s LF=%s' "$cr_count" "$lf_count" + if [ "$cr_count" -gt 0 ]; then + printf ' (CRLF detected — v0.6.6 jqf strips these defensively)\n' + else + printf ' (clean LF)\n' + fi + # Token math printf '\n[token math]\n' local fetched_at expires_in scope fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"') + # Belt-and-suspenders integer coercion (see cmd_ensure for the why). + fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) + expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) local now; now=$(date +%s) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now))