v0.6.0: secure SSH ControlMaster — password hidden from Larry-the-LLM

NEW lib/ssh-helper.sh implements the full SSH command surface:
  hosts/list                              show configured remote hosts
  add <alias> <user@host[:port]>          register a new host
  remove <alias>                          remove + clean cred + socket
  pass <alias>                            set/update password (hidden interactive)
  setup <alias>                           open long-lived ControlMaster
  close <alias>                           close ControlMaster
  status [alias]                          show open masters + cred presence
  exec <alias> <command...>               run command via master

Architecture:
  • $LARRY_HOME/.ssh-hosts.tsv      — alias \t user@host \t port (3-col)
  • $LARRY_HOME/.ssh-creds/<alias>  — raw password, mode 0600
  • $LARRY_HOME/.ssh-sockets/<alias>.sock — ControlMaster socket

The password is read from disk by sshpass via -f (file argument), so it
never lands in argv or environment. It is used ONCE to open the master;
all subsequent execs multiplex through the socket with no auth. Daily-
rotating passwords: just overwrite the cred file and re-run setup.

SLASH COMMANDS wired in larry.sh REPL: /ssh-hosts /ssh-add /ssh-remove
/ssh-pass /ssh-setup /ssh-close /ssh-status /ssh <alias> <cmd>.

LARRY TOOLS exposed to the LLM:
  ssh_status      — list aliases + open-master state
  ssh_exec        — run command on remote via the master socket
Both tool descriptions explicitly tell Larry the password is unreachable
and to ask Bryan to run /ssh-setup if a master is closed. Tool inputs
and outputs never contain the password. Output capped at max_lines
(default 500) with a "[ssh_exec: exit rc=N]" footer.

Bundle updated: MANIFEST + install-larry.sh both now include
lib/ssh-helper.sh. Auto-update will pull it on next launch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-27 10:28:37 -07:00
parent c2bba7be90
commit f58bcf711f
5 changed files with 327 additions and 2 deletions

View File

@ -26,6 +26,9 @@ agents/regress.md
# Auth implementation
lib/oauth.sh
# Secure SSH with ControlMaster (password hidden from Larry-the-LLM)
lib/ssh-helper.sh
# Logging / capture
lib/lessons.sh
lib/journal.sh

View File

@ -1 +1 @@
0.5.5
0.6.0

View File

@ -91,6 +91,7 @@ fetch agents/regress.md "$LARRY_HOME/agents/regress.md"
fetch larry-rollback.sh "$LARRY_HOME/larry-rollback.sh"
fetch larry-auth.sh "$LARRY_HOME/larry-auth.sh"
fetch lib/oauth.sh "$LARRY_HOME/lib/oauth.sh"
fetch lib/ssh-helper.sh "$LARRY_HOME/lib/ssh-helper.sh"
fetch lib/lessons.sh "$LARRY_HOME/lib/lessons.sh"
fetch lib/hl7-sanitize.sh "$LARRY_HOME/lib/hl7-sanitize.sh"
fetch lib/hl7-desanitize.sh "$LARRY_HOME/lib/hl7-desanitize.sh"

View File

@ -36,7 +36,7 @@ set -o pipefail
# ─────────────────────────────────────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.5.5"
LARRY_VERSION="0.6.0"
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}"
@ -807,6 +807,33 @@ tool_hl7_sanitize() {
"$LARRY_LIB_DIR/hl7-sanitize.sh" "${args[@]}" 2>&1
}
# Secure SSH tools — password is read from $LARRY_HOME/.ssh-creds/<alias> by
# ssh-helper.sh and never exposed in argv, env, or tool output. The Larry-LLM
# only sees: alias name, command, command output.
tool_ssh_exec() {
local alias="$1" command="$2" max_lines="${3:-500}"
local helper="$LARRY_LIB_DIR/ssh-helper.sh"
[ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; }
[ -n "$alias" ] && [ -n "$command" ] || { echo "ERROR: ssh_exec needs alias and command"; return 1; }
local out
out=$("$helper" exec "$alias" "$command" 2>&1)
local rc=$?
local total_lines
total_lines=$(printf '%s' "$out" | wc -l | tr -d ' ')
if [ "$total_lines" -gt "$max_lines" ]; then
printf '%s\n[ssh_exec: output truncated — showed %s of %s lines. Exit rc=%d]\n' \
"$(printf '%s' "$out" | head -n "$max_lines")" "$max_lines" "$total_lines" "$rc"
else
printf '%s\n[ssh_exec: exit rc=%d]\n' "$out" "$rc"
fi
}
tool_ssh_status() {
local helper="$LARRY_LIB_DIR/ssh-helper.sh"
[ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; }
"$helper" status 2>&1
}
tool_lesson_record() {
local text="$1" topic="${2:-}" site="${3:-${HCISITE:-}}" severity="${4:-info}"
_lib_err_if_missing || return
@ -906,6 +933,8 @@ execute_tool() {
"$(J '.phase // "all"')" "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" ;;
lesson_record) tool_lesson_record "$(J '.text')" "$(J '.topic // ""')" "$(J '.site // ""')" "$(J '.severity // "info"')" ;;
hl7_sanitize) tool_hl7_sanitize "$(J '.input_path')" "$(J '.strict // 0' | sed "s/false/0/;s/true/1/")" ;;
ssh_exec) tool_ssh_exec "$(J '.alias')" "$(J '.command')" "$(J '.max_lines // 500')" ;;
ssh_status) tool_ssh_status ;;
larry_rollback_list) tool_larry_rollback_list "$(J '.session // ""')" ;;
*) echo "ERROR: unknown tool: $name" ;;
esac
@ -948,6 +977,10 @@ TOOLS_JSON='[
{"name":"hl7_sanitize","description":"Tokenize PHI fields in an HL7 message file. Replaces values in patient identifiers, names, DOB, addresses, phones, SSN, account numbers, providers, visit numbers, NK1/GT1/IN1 fields, etc. with deterministic local tokens like [[MRN_0001]]. Same value gets same token across the entire local lookup table, so correlation analysis still works. The token-to-original mapping NEVER leaves the client (stored at $LARRY_HOME/sanitize/lookup.tsv, mode 0600). Use this when Bryan needs you to analyze a file that has real PHI. Returns the sanitized HL7 content with tokens substituted. Bryan can desanitize the final output locally with hl7-desanitize.sh.","input_schema":{"type":"object","properties":{"input_path":{"type":"string","description":"Path to the HL7 message file to sanitize."},"strict":{"type":"integer","description":"1=also tokenize any unknown Z* segments wholesale. Default 0 (safer for legibility but might miss custom PHI in Z segments)."}},"required":["input_path"]}},
{"name":"ssh_exec","description":"Run a shell command on a remote test/dev host via an authenticated SSH ControlMaster session. Bryan must have already configured the alias (via /ssh-add) and opened the master (via /ssh-setup). The password is stored locally and you CANNOT see it — do not ask Bryan for it; if the master is closed, tell him to run the /ssh-setup ALIAS slash command. Use ssh_status first to confirm which aliases are open. Output capped at max_lines (default 500). Tool result includes the remote exit code as a [ssh_exec: exit rc=N] footer.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"Host alias Bryan configured. Run ssh_status to see the list."},"command":{"type":"string","description":"Shell command to execute on the remote. Quote as needed; will be passed through ssh as a single string."},"max_lines":{"type":"integer","description":"Cap output lines (default 500). Increase for known-large output, but prefer targeted commands."}},"required":["alias","command"]}},
{"name":"ssh_status","description":"List the SSH hosts Bryan has configured and which ones have an open ControlMaster session. Call this BEFORE ssh_exec to confirm an alias exists and the master is open. Each line shows: alias, user@host, port, cred (present/absent), master (open or dash). If the master is not open for an alias you need, ask Bryan to run the /ssh-setup ALIAS slash command. Do NOT attempt to authenticate yourself — you have no access to the password.","input_schema":{"type":"object","properties":{},"required":[]}},
{"name":"hl7_diff","description":"HL7-aware diff between two message files (or multi-message dumps). Compares segment-by-segment, field-by-field, with component and subcomponent precision. Ignores configured fields (default MSH.7 timestamp) so timestamp-only diffs do not show up as noise. Use for regression testing between environments (e.g. test vs prod route-test outputs).","input_schema":{"type":"object","properties":{"left":{"type":"string","description":"Path to left HL7 file."},"right":{"type":"string","description":"Path to right HL7 file."},"ignore":{"type":"string","description":"Comma-separated list of fields to ignore (e.g. MSH.7,MSH.10,EVN.6). Default MSH.7."},"include":{"type":"string","description":"If set, ONLY these fields are compared (overrides ignore for that set)."},"format":{"type":"string","enum":["text","tsv","count"],"description":"text=human-readable diff, tsv=machine-parseable, count=just the difference count."}},"required":["left","right"]}},
{"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env)."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env)."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"Output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."}},"required":["scope","env_a","env_b","out"]}}
@ -1096,6 +1129,16 @@ Slash commands:
/unmask <token> show the original PHI for a token (local only; never sent)
/tokens show the full local PHI ↔ token lookup table
Secure SSH (password stays local; never visible to Larry-the-LLM):
/ssh-hosts list configured remote hosts
/ssh-add <alias> <user@host[:port]> register a new host
/ssh-pass <alias> set/update password (hidden input; daily rotation OK)
/ssh-setup <alias> open a long-lived ControlMaster connection
/ssh-close <alias> close the ControlMaster
/ssh-status [alias] show open masters + cred presence
/ssh <alias> <command> run command on the remote (you-driven, ad-hoc)
Larry can also run things there via the ssh_exec tool.
PHI inline syntax in any prompt:
@@VALUE EASY: wrap PHI in @@. Spaceless = no end delim.
e.g. @@12345 @@SMITH^JOHN @@V789
@ -1197,6 +1240,31 @@ main_loop() {
/tokens) [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ] && "$LARRY_LIB_DIR/hl7-sanitize.sh" show-table \
|| err "hl7-sanitize.sh not installed"
continue ;;
# ── SSH ControlMaster commands (password never visible to Larry-the-LLM) ──
/ssh-hosts|/ssh-list) [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ] && "$LARRY_LIB_DIR/ssh-helper.sh" hosts \
|| err "ssh-helper.sh not installed"
continue ;;
/ssh-add\ *) local rest="${input#/ssh-add }"; local args=($rest)
if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" add "${args[@]}"; else err "ssh-helper.sh not installed"; fi
continue ;;
/ssh-remove\ *|/ssh-rm\ *) local a="${input#/ssh-* }"
if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" remove "$a"; else err "ssh-helper.sh not installed"; fi
continue ;;
/ssh-pass\ *) local a="${input#/ssh-pass }"
if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" pass "$a"; else err "ssh-helper.sh not installed"; fi
continue ;;
/ssh-setup\ *) local a="${input#/ssh-setup }"
if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" setup "$a"; else err "ssh-helper.sh not installed"; fi
continue ;;
/ssh-close\ *) local a="${input#/ssh-close }"
if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" close "$a"; else err "ssh-helper.sh not installed"; fi
continue ;;
/ssh-status) [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ] && "$LARRY_LIB_DIR/ssh-helper.sh" status \
|| err "ssh-helper.sh not installed"
continue ;;
/ssh\ *) local rest="${input#/ssh }"; local alias="${rest%% *}"; local rcmd="${rest#"$alias" }"
if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" exec "$alias" "$rcmd"; else err "ssh-helper.sh not installed"; fi
continue ;;
/redetect) detect_cloverleaf_env
system_prompt=$(build_system_prompt)
larry_say "re-detected. /env to view."

253
lib/ssh-helper.sh Executable file
View File

@ -0,0 +1,253 @@
#!/usr/bin/env bash
# ssh-helper.sh — secure SSH command execution via ControlMaster.
#
# Architecture:
# • Hosts configured in $LARRY_HOME/.ssh-hosts.tsv as
# alias \t user@host \t port
# • Passwords stored at $LARRY_HOME/.ssh-creds/<alias>, mode 0600.
# The password file is the single point of truth — to rotate (daily-changing
# passwords) just overwrite the file with the new one and re-run 'setup'.
# • sshpass reads the password via -f (file), so it never lands in argv or
# environment where Larry the LLM (or other processes via /proc) could see it.
# • The first 'setup' call opens a long-lived SSH ControlMaster connection
# (default ControlPersist=8h). Subsequent 'exec' calls multiplex through
# the master socket and need no password.
# • Larry's tool layer only sees: alias, command, command_output.
# Never the password. Never the user@host (unless added to the alias list).
#
# Subcommands:
# hosts list configured hosts
# add <alias> <user@host[:port]> add a host to the alias list
# remove <alias> remove an alias (also clears cred + socket)
# pass <alias> set/update the password (hidden interactive)
# setup <alias> open ControlMaster (uses stored password ONCE)
# close <alias> close ControlMaster
# status [alias] show open masters / cred presence
# exec <alias> <command...> run command via master (returns output)
# help print this help
set -u
set -o pipefail
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
SSH_HOSTS_FILE="$LARRY_HOME/.ssh-hosts.tsv"
SSH_CREDS_DIR="$LARRY_HOME/.ssh-creds"
SSH_SOCKETS_DIR="$LARRY_HOME/.ssh-sockets"
SSH_CONTROL_PERSIST="${LARRY_SSH_CONTROL_PERSIST:-8h}"
die() { printf 'ssh-helper: %s\n' "$*" >&2; exit 1; }
warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; }
ok() { printf 'ssh-helper: %s\n' "$*"; }
ensure_layout() {
mkdir -p "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null
chmod 700 "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null || true
if [ ! -f "$SSH_HOSTS_FILE" ]; then
umask 077
printf 'alias\taddr\tport\n' > "$SSH_HOSTS_FILE"
chmod 600 "$SSH_HOSTS_FILE"
fi
}
# read_host_addr ALIAS → echoes "ADDR\tPORT" or empty
read_host_addr() {
local alias="$1"
[ -f "$SSH_HOSTS_FILE" ] || { printf ''; return 1; }
awk -F'\t' -v a="$alias" 'NR>1 && $1==a { print $2 "\t" $3; exit }' < "$SSH_HOSTS_FILE"
}
require_sshpass() {
command -v sshpass >/dev/null 2>&1 \
|| die "sshpass not on PATH — install it (apt install sshpass / brew install sshpass) and retry"
}
cmd_help() {
sed -n '4,30p' "$0"
}
cmd_hosts() {
ensure_layout
if [ "$(wc -l < "$SSH_HOSTS_FILE")" -le 1 ]; then
echo "no hosts configured. Add with: ssh-helper.sh add <alias> <user@host[:port]>"
return 0
fi
printf 'alias user@host port cred master\n'
printf '%s\n' '───── ───────── ──── ──── ──────'
awk -F'\t' 'NR>1' "$SSH_HOSTS_FILE" | while IFS=$'\t' read -r alias addr port; do
local cred_state=""
[ -f "$SSH_CREDS_DIR/$alias" ] && cred_state="✓"
local master_state=""
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
master_state="open"
fi
printf '%-20s%-52s%-6s%-6s%s\n' "$alias" "$addr" "${port:-22}" "$cred_state" "$master_state"
done
}
cmd_add() {
local alias="${1:-}" target="${2:-}"
[ -n "$alias" ] && [ -n "$target" ] || die "usage: add <alias> <user@host[:port]>"
[[ "$target" =~ ^[^@[:space:]]+@[^:[:space:]]+(:[0-9]+)?$ ]] \
|| die "target must look like user@host or user@host:port"
local addr port
if [[ "$target" == *:* ]]; then
addr="${target%:*}"
port="${target##*:}"
else
addr="$target"
port="22"
fi
ensure_layout
# Reject duplicates (use 'remove' first)
if awk -F'\t' -v a="$alias" 'NR>1 && $1==a { found=1; exit } END { exit !found }' "$SSH_HOSTS_FILE"; then
die "alias '$alias' already exists. Use 'remove $alias' first."
fi
umask 077
printf '%s\t%s\t%s\n' "$alias" "$addr" "$port" >> "$SSH_HOSTS_FILE"
chmod 600 "$SSH_HOSTS_FILE"
ok "added $alias$addr (port $port). Next: ssh-helper.sh pass $alias"
}
cmd_remove() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: remove <alias>"
ensure_layout
local tmp; tmp=$(mktemp)
awk -F'\t' -v a="$alias" 'NR==1 || $1!=a' "$SSH_HOSTS_FILE" > "$tmp" && mv "$tmp" "$SSH_HOSTS_FILE"
chmod 600 "$SSH_HOSTS_FILE"
# Close + clean master socket and cred
cmd_close "$alias" 2>/dev/null || true
rm -f "$SSH_CREDS_DIR/$alias" "$SSH_SOCKETS_DIR/$alias.sock" 2>/dev/null
ok "removed $alias (cred + socket cleared)"
}
cmd_pass() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: pass <alias>"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias (run 'add' first)"
ensure_layout
printf 'Password for %s (input is hidden; press Enter when done): ' "$alias" >&2
local pw=""
stty -echo 2>/dev/null
IFS= read -r pw </dev/tty || true
stty echo 2>/dev/null
echo "" >&2
[ -n "$pw" ] || die "no password entered"
umask 077
# NO trailing newline — sshpass -f expects raw password as full file content
printf '%s' "$pw" > "$SSH_CREDS_DIR/$alias"
chmod 600 "$SSH_CREDS_DIR/$alias"
ok "password saved to $SSH_CREDS_DIR/$alias (mode 0600). Next: ssh-helper.sh setup $alias"
}
cmd_setup() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: setup <alias>"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias"
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
ensure_layout
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
ok "master already open for $alias ($addr:$port)"
return 0
fi
local credfile="$SSH_CREDS_DIR/$alias"
[ -f "$credfile" ] || die "no password set for $alias — run 'pass $alias' first"
require_sshpass
ok "opening ssh master for $alias ($addr:$port) — ControlPersist=$SSH_CONTROL_PERSIST..."
if sshpass -f "$credfile" ssh \
-o "ControlMaster=yes" \
-o "ControlPath=$sock" \
-o "ControlPersist=$SSH_CONTROL_PERSIST" \
-o "StrictHostKeyChecking=accept-new" \
-o "ConnectTimeout=10" \
-p "$port" \
-N -f \
"$addr" 2>/tmp/larry-ssh-setup.err; then
if ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
ok "✓ master open: $alias$addr:$port (socket: $sock)"
rm -f /tmp/larry-ssh-setup.err
return 0
fi
fi
printf 'ssh-helper: setup failed. sshpass/ssh stderr:\n' >&2
cat /tmp/larry-ssh-setup.err >&2 2>/dev/null
rm -f /tmp/larry-ssh-setup.err
return 1
}
cmd_close() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: close <alias>"
local addr_port; addr_port=$(read_host_addr "$alias") || addr_port=""
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ -S "$sock" ] && [ -n "$addr_port" ]; then
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
ssh -S "$sock" -O exit -p "$port" "$addr" 2>/dev/null || true
fi
rm -f "$sock"
ok "closed master for $alias"
}
cmd_status() {
ensure_layout
if [ -n "${1:-}" ]; then
local alias="$1"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias"
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
local sock="$SSH_SOCKETS_DIR/$alias.sock"
printf 'alias: %s\naddr: %s\nport: %s\ncred: %s\nsocket: %s\nstatus: ' \
"$alias" "$addr" "$port" \
"$([ -f "$SSH_CREDS_DIR/$alias" ] && echo present || echo missing)" \
"$sock"
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
echo "master OPEN"
else
echo "no master (run setup)"
fi
return 0
fi
cmd_hosts
}
cmd_exec() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: exec <alias> <command...>"
shift
local cmd="$*"
[ -n "$cmd" ] || die "no command given"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias"
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ ! -S "$sock" ] || ! ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
die "no open master for $alias — run 'setup $alias' first"
fi
# Multiplexed; no password needed.
ssh -S "$sock" -p "$port" -o BatchMode=yes "$addr" "$cmd"
}
case "${1:-help}" in
hosts|list) shift; cmd_hosts ;;
add) shift; cmd_add "$@" ;;
remove|rm) shift; cmd_remove "$@" ;;
pass|passwd) shift; cmd_pass "$@" ;;
setup|open) shift; cmd_setup "$@" ;;
close|exit) shift; cmd_close "$@" ;;
status) shift; cmd_status "$@" ;;
exec|run) shift; cmd_exec "$@" ;;
-h|--help|help) cmd_help ;;
*) die "unknown subcommand: ${1:-} (run with --help)" ;;
esac