v0.6.6: strip CR from jq output + 0600 oauth file + TAB slash completion

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 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-27 15:18:51 -07:00
parent dd44d361c3
commit 9f97d15f9a
3 changed files with 246 additions and 7 deletions

View File

@ -1 +1 @@
0.6.5
0.6.6

169
larry.sh
View File

@ -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<TAB> → /help
/ss<TAB> → lists every /ssh-* command
/ssh-h<TAB> → /ssh-hosts
/q<TAB> → /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

View File

@ -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 <file> <jq-args...>
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))