diff --git a/scripts/paliadin-shim b/scripts/paliadin-shim new file mode 100755 index 0000000..5ab7667 --- /dev/null +++ b/scripts/paliadin-shim @@ -0,0 +1,185 @@ +#!/bin/bash +# paliadin-shim — server-side RPC for paliad's remote-tmux turns. +# +# Invoked via mRiver's ~/.ssh/authorized_keys command= restriction. The +# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this +# script parses it and dispatches to a fixed verb set. +# +# Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4. +# +# Verbs: +# health -> "ok" iff tmux + claude reachable +# bootstrap -> ensure pane + send system prompt +# run-turn -> send framed prompt, poll, return +# reset -> /clear the conversation +# +# All multi-character payloads (prompts, messages) are base64-encoded by +# the Go caller so we never have to quote them through ssh's argv. +# +# Errors go to stderr with a non-zero exit. The Go side maps the exit +# status into a friendly error code. +set -euo pipefail +umask 077 + +readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}" +readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}" +readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-60}" +readonly PANE_READY_S=60 # max wait for claude pane to settle +readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + +mkdir -p "$RESPONSE_DIR" +chmod 700 "$RESPONSE_DIR" + +# Parse $SSH_ORIGINAL_COMMAND into argv. Format: " …". +# We never `eval` this; `read -r -a` splits on $IFS without word-expansion. +read -r -a argv <<< "${SSH_ORIGINAL_COMMAND:-}" +verb="${argv[0]:-}" + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; } + +# ensure_pane creates the tmux session + claude window if missing, waits +# for the pane to become ready, and prints the target identifier +# ("session:window-idx") on stdout. +ensure_pane() { + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + tmux new-session -d -s "$TMUX_SESSION" + fi + + # Look for an existing window tagged with @paliadin-scope=chat. + local target="" + local idx scope + while read -r idx; do + [[ -z "$idx" ]] && continue + scope=$(tmux show-window-option -t "$TMUX_SESSION:$idx" -v @paliadin-scope 2>/dev/null || true) + if [[ "$scope" == "chat" ]]; then + target="$TMUX_SESSION:$idx" + break + fi + done < <(tmux list-windows -t "$TMUX_SESSION" -F '#{window_index}' 2>/dev/null || true) + + if [[ -z "$target" ]]; then + if ! command -v claude >/dev/null 2>&1; then + log_err "claude CLI not found in PATH" + exit 3 + fi + idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude) + target="$TMUX_SESSION:$idx" + + # Wait for claude to settle. Matches Go waitForPaneReady (paliadin.go:495). + local deadline=$(( $(date +%s) + PANE_READY_S )) + local pane="" + while [[ $(date +%s) -lt $deadline ]]; do + pane=$(tmux capture-pane -t "$target" -p 2>/dev/null || true) + if [[ "$pane" == *"❯"* || "$pane" == *"│"* ]]; then + break + fi + sleep 0.5 + done + + tmux set-window-option -t "$target" @paliadin-scope chat >/dev/null + tmux set-window-option -t "$target" @fix-name claude-paliadin >/dev/null + fi + + printf '%s' "$target" +} + +# send_to_pane writes a literal string then Enter. +send_to_pane() { + local target="$1" msg="$2" + tmux send-keys -t "$target" -l -- "$msg" + tmux send-keys -t "$target" Enter +} + +# --------------------------------------------------------------------------- +# verb dispatch +# --------------------------------------------------------------------------- + +case "$verb" in + + health) + # Used by the Go side's healthGate to short-circuit when mRiver is + # offline or tmux/claude is broken. Output is parsed verbatim. + if ! command -v tmux >/dev/null 2>&1; then + log_err "tmux not in PATH"; exit 1 + fi + if ! command -v claude >/dev/null 2>&1; then + log_err "claude not in PATH"; exit 1 + fi + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + tmux new-session -d -s "$TMUX_SESSION" + fi + echo ok + ;; + + bootstrap) + # Inject the system prompt into a fresh claude pane. Idempotent — + # the Go side may call this repeatedly; tmux send-keys is harmless + # against a settled pane. + if [[ -z "${argv[1]:-}" ]]; then + log_err "bootstrap: missing prompt"; exit 2 + fi + if ! prompt=$(printf '%s' "${argv[1]}" | base64 -d 2>/dev/null); then + log_err "bootstrap: invalid base64 prompt"; exit 2 + fi + target=$(ensure_pane) + send_to_pane "$target" "$prompt" + sleep 2 # let claude absorb before turns flow + echo ok + ;; + + run-turn) + # $1 = turn_id (UUID), $2 = base64-encoded user message. + turn_id="${argv[1]:-}" + if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then + log_err "run-turn: bad turn_id"; exit 2 + fi + if [[ -z "${argv[2]:-}" ]]; then + log_err "run-turn: missing message"; exit 2 + fi + if ! msg=$(printf '%s' "${argv[2]}" | base64 -d 2>/dev/null); then + log_err "run-turn: invalid base64 message"; exit 2 + fi + target=$(ensure_pane) + out="$RESPONSE_DIR/$turn_id.txt" + rm -f "$out" + + # Envelope matches paliadin_prompt.go's `[PALIADIN:turn_id] ` shape. + send_to_pane "$target" "[PALIADIN:$turn_id] $msg" + + # Poll for the response file. Same shape as Go pollForResponse + # (paliadin.go:530). Settle delay so we don't read mid-flush. + deadline=$(( $(date +%s) + TIMEOUT_S )) + while [[ $(date +%s) -lt $deadline ]]; do + if [[ -s "$out" ]]; then + sleep 0.05 + cat "$out" + rm -f "$out" + exit 0 + fi + sleep 0.2 + done + log_err "response timeout after ${TIMEOUT_S}s" + exit 124 + ;; + + reset) + # Send `/clear` so the next turn starts a fresh conversation. + target=$(ensure_pane) + send_to_pane "$target" "/clear" + echo ok + ;; + + '') + log_err "no verb (set SSH_ORIGINAL_COMMAND via authorized_keys command=)" + exit 2 + ;; + + *) + log_err "unknown verb '$verb'" + exit 2 + ;; +esac