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:
parent
dd44d361c3
commit
9f97d15f9a
169
larry.sh
169
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<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
|
||||
|
||||
82
lib/oauth.sh
82
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 <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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user