opencode-thrum-pro 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/commands/thrum-compact-extended.md +5 -0
- package/assets/commands/thrum-compact.md +5 -0
- package/assets/skills/coordinator-assessing-agent-completion/SKILL.md +17 -20
- package/assets/skills/coordinator-deploying-a-node/SKILL.md +2 -2
- package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +11 -2
- package/assets/skills/coordinator-merging-code/SKILL.md +79 -5
- package/assets/skills/coordinator-philosophy-merge-gate/SKILL.md +2 -2
- package/assets/skills/coordinator-rolling-out-to-nodes/SKILL.md +2 -2
- package/assets/skills/diagnosing-fleet-and-daemon/SKILL.md +1 -1
- package/assets/skills/implementer-receiving-dispatch/SKILL.md +1 -1
- package/assets/skills/orchestrate/SKILL.md +1 -1
- package/assets/skills/persistent-watcher-archetype/SKILL.md +24 -8
- package/assets/skills/persistent-watcher-archetype/resources/roster-watch.sh +3 -3
- package/assets/skills/project-setup/SKILL.md +7 -12
- package/assets/skills/project-setup/resources/implementation-agent.md +2 -2
- package/assets/skills/researcher-maintaining-memory/SKILL.md +1 -1
- package/assets/skills/thrum/resources/CLI_REFERENCE.md +1 -1
- package/assets/skills/thrum/resources/LISTENER_PATTERN.md +2 -2
- package/assets/skills/using-monitors/SKILL.md +156 -18
- package/assets/skills/using-tmux-ssh/SKILL.md +121 -0
- package/assets/skills/using-tmux-ssh/resources/tmux-ssh +1158 -0
- package/assets/skills/verify-against-plan/SKILL.md +1 -1
- package/dist/installer.js +3 -5
- package/package.json +1 -1
- package/assets/skills/coordinator-context-monitoring/SKILL.md +0 -685
- package/assets/skills/coordinator-post-restart-sweep/SKILL.md +0 -135
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# tmux-ssh - run commands on a remote host through one persistent ssh login session.
|
|
4
|
+
# Usage: tmux-ssh --help.
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
print_help() {
|
|
8
|
+
cat <<'TSSH_HELP_EOF'
|
|
9
|
+
tmux-ssh - run commands on a remote host through one persistent ssh login session.
|
|
10
|
+
|
|
11
|
+
QUICK START (HOST is an ssh alias or [user@]host)
|
|
12
|
+
tmux-ssh HOST "make test" run a command that ENDS: prints its output, exits with its code
|
|
13
|
+
tmux-ssh HOST "tmux new -d -s NAME 'cmd'" start a long job in a remote tmux session, detached
|
|
14
|
+
tmux-ssh HOST --send "vim f" drive a program that stays open (vim, top, REPL, prompt):
|
|
15
|
+
tmux-ssh HOST --read --settle type, look at the screen,
|
|
16
|
+
tmux-ssh HOST --send --key Escape send a named key
|
|
17
|
+
tmux-ssh HOST --timeout 900 "cmd" long command; on exit 124 keep waiting: tmux-ssh HOST --resume
|
|
18
|
+
tmux-ssh HOST --expect 'Password:=secret' "cmd" answer a prompt automatically
|
|
19
|
+
tmux-ssh HOST --abort stuck host: Ctrl-C the command and clear the watch
|
|
20
|
+
tmux-ssh HOST --kill still stuck: drop the ssh session
|
|
21
|
+
|
|
22
|
+
TWO MODES
|
|
23
|
+
run mode tmux-ssh HOST "cmd" waits for cmd to FINISH; for commands that end.
|
|
24
|
+
interactive mode --send / --read for programs that stay open (vim, tmux attached, top, REPLs, prompts).
|
|
25
|
+
|
|
26
|
+
COMMANDS
|
|
27
|
+
tmux-ssh HOST connect if needed; print status
|
|
28
|
+
tmux-ssh HOST "cmd" run mode (see above)
|
|
29
|
+
tmux-ssh HOST --status show the recorded session (prints "not connected" if there is none)
|
|
30
|
+
tmux-ssh HOST --resume keep waiting for a timed-out command (never re-sends it)
|
|
31
|
+
tmux-ssh HOST --abort send Ctrl-C to the watched command, wait briefly, clear the watch
|
|
32
|
+
exit 0: host ready for the next command; 1: the shell did not answer (--read, then --send --key or --kill)
|
|
33
|
+
tmux-ssh HOST --kill close the ssh session and clear all saved state
|
|
34
|
+
tmux-ssh HOST --attach attach your terminal to the session (detach with C-b d)
|
|
35
|
+
tmux-ssh HOST --send "text" type text + Enter into the live session
|
|
36
|
+
tmux-ssh HOST --send --no-enter "text" type without Enter
|
|
37
|
+
tmux-ssh HOST --send --key KEY send a named tmux key (C-c, Escape, Up, Enter, ...)
|
|
38
|
+
tmux-ssh HOST --read [--settle] print the current screen (--settle waits until it stops changing); also works while another tmux-ssh command is still running, as does --status
|
|
39
|
+
|
|
40
|
+
FLAGS
|
|
41
|
+
--timeout SEC max wait for a command, 1-2700 (default 120). --resume reuses the original command's value.
|
|
42
|
+
--lines N return the last N lines of output/screen (default 50)
|
|
43
|
+
--connect-timeout SEC max wait for ssh + remote shell on (re)connect (default 25)
|
|
44
|
+
--no-reconnect refuse instead of reconnecting a dead session
|
|
45
|
+
--interrupt-on-timeout on timeout send Ctrl-C (default: leave the command running)
|
|
46
|
+
--force-run run mode: skip the refusal of interactive commands (vim, top, tmux attach, ...)
|
|
47
|
+
--expect 'PAT=RESP' when PAT appears on screen after the command starts, type RESP + Enter (once per rule).
|
|
48
|
+
PAT is a literal SINGLE-LINE substring; a PAT containing a newline or CR is refused (exit 2).
|
|
49
|
+
--expect-secret 'PAT=@FILE' | 'PAT=env:VAR' like --expect, RESP read from a file or env var (not shown in argv).
|
|
50
|
+
The reply is typed into the pane: a prompt that echoes it will show it in --read output.
|
|
51
|
+
--expect-host NAME OPTIONAL. Refuse unless the host reports hostname NAME (short or FQDN, any case). Use it for
|
|
52
|
+
an alias you could mistype or that may point at the wrong box. Find NAME with: ssh HOST hostname.
|
|
53
|
+
Also settable via TMUX_SSH_EXPECT_HOST.
|
|
54
|
+
-h, --help this text
|
|
55
|
+
|
|
56
|
+
GOTCHAS
|
|
57
|
+
- Run mode is only for commands that FINISH. A command that stays open (vim, top, less, "tmux new" without -d,
|
|
58
|
+
"tmux attach", a prompt with no --expect) never returns: use interactive mode, or "tmux new -d".
|
|
59
|
+
Obvious interactive commands are refused (exit 2) before anything is sent; --force-run overrides.
|
|
60
|
+
- A command or --send text over about 16,000 bytes is refused (exit 2) before anything is sent. Copy the data over with scp and run a short command (scp patch.diff HOST:/tmp/ ; tmux-ssh HOST 'git apply /tmp/patch.diff').
|
|
61
|
+
- The command runs in the remote user's login shell (may be zsh, not bash). Bash-only syntax such as
|
|
62
|
+
"read -p" differs: wrap it as bash -c '...' .
|
|
63
|
+
- Quote the whole remote command once, locally. "$VAR" inside double quotes expands on YOUR machine; use single quotes.
|
|
64
|
+
- After exit 124 the host is "watched": every other command returns 125 until you use --resume, --abort or --kill.
|
|
65
|
+
- --kill closes the ssh session only. Remote processes started in it (e.g. a tmux session) keep running.
|
|
66
|
+
- A dropped ssh loses any command running in it. Run long work under "tmux new -d" or nohup and re-run after a drop.
|
|
67
|
+
- A host that reports THIS machine's own hostname is refused (125); clear it with --kill.
|
|
68
|
+
- A live session keeps the TMUX_SSH_OPTS it started with; to change them run --kill first.
|
|
69
|
+
- --expect that never matched is reported as a warning after the command finishes.
|
|
70
|
+
|
|
71
|
+
EXIT CODES
|
|
72
|
+
0-123, 126+ the remote command's own exit code.
|
|
73
|
+
124 timed out; the command may still be running or waiting for input.
|
|
74
|
+
Next: --read (see the screen) | --resume [--timeout N] (keep waiting) | --send --key C-c then --resume | --abort
|
|
75
|
+
125 not done; the MESSAGE says why. Common: another command is still watched (--resume | --abort | --kill),
|
|
76
|
+
not connected, hostname mismatch, unresolved input (--kill). Nothing new was typed unless the message says
|
|
77
|
+
"completion unknown" (ssh dropped mid-command: do NOT blindly retry).
|
|
78
|
+
2 usage error (including a refused interactive command).
|
|
79
|
+
3 input state UNKNOWN (a keystroke failed or ssh dropped during --send). The host is guarded: every
|
|
80
|
+
typing call is refused until --kill; --read, --status and --resume still work.
|
|
81
|
+
|
|
82
|
+
ENVIRONMENT
|
|
83
|
+
TMUX_SSH_DIR where session state lives (default ~/.thrum/tmux-ssh)
|
|
84
|
+
TMUX_SSH_OPTS extra ssh options (e.g. '-i ~/.ssh/key -o IdentitiesOnly=yes'); chars [A-Za-z0-9 _./=:,@~+%-] only
|
|
85
|
+
TMUX_SSH_EXPECT_HOST default for --expect-host
|
|
86
|
+
TSSH_HELP_EOF
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# tssh_has_done <nonce> <pane-text>: 0 iff a line of the pane text is the
|
|
90
|
+
# completion marker for <nonce>. Pure bash (no pipe, no temp file, no fork):
|
|
91
|
+
# `printf | grep -q` exits early and printf died on SIGPIPE under pipefail on a
|
|
92
|
+
# large pane capture. The leading newline makes the line anchor.
|
|
93
|
+
tssh_has_done() {
|
|
94
|
+
local re=$'\n'"TSSHDONE_${1}_ rc=[0-9]+"
|
|
95
|
+
[[ $'\n'"$2" =~ $re ]]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
SCRIPT_NAME="tmux-ssh"
|
|
99
|
+
SOCKET="tmux-ssh" # single tmux server for all tmux-ssh sessions
|
|
100
|
+
REF_DIR="${TMUX_SSH_DIR:-$HOME/.thrum/tmux-ssh}"
|
|
101
|
+
|
|
102
|
+
# Defaults (overridable by flags)
|
|
103
|
+
CMD_TIMEOUT=120
|
|
104
|
+
CONNECT_TIMEOUT=25
|
|
105
|
+
LINES=50
|
|
106
|
+
ALLOW_RECONNECT=true
|
|
107
|
+
INTERRUPT_ON_TIMEOUT=false
|
|
108
|
+
CAPTURE_LINES=5000 # scrollback window captured for output slicing
|
|
109
|
+
LOCK_WAIT=${TMUX_SSH_LOCK_WAIT:-30} # seconds to wait for the per-host lock (env override is a TEST seam)
|
|
110
|
+
SEND_ENTER=true
|
|
111
|
+
SEND_KEY=""
|
|
112
|
+
SETTLE=false
|
|
113
|
+
FORCE_RUN=false
|
|
114
|
+
TIMEOUT_GIVEN=false
|
|
115
|
+
WATCH_TIMEOUT=""
|
|
116
|
+
ABORT_WAIT=5 # seconds --abort waits for the DONE marker after Ctrl-C
|
|
117
|
+
EXPECT_HOST="${TMUX_SSH_EXPECT_HOST:-}" # a later --expect-host flag wins
|
|
118
|
+
EXPECT_PATS=()
|
|
119
|
+
EXPECT_RESPS=()
|
|
120
|
+
EXPECT_SECRET=()
|
|
121
|
+
EXPECT_FIRED=()
|
|
122
|
+
|
|
123
|
+
log() { printf '[%s] %s\n' "$SCRIPT_NAME" "$*" >&2; }
|
|
124
|
+
die() { local code="${2:-2}"; printf '[%s] error: %s\n' "$SCRIPT_NAME" "$1" >&2; exit "$code"; }
|
|
125
|
+
usage(){ print_help >&2; }
|
|
126
|
+
|
|
127
|
+
# ---- naming derived deterministically from the host ------------------------
|
|
128
|
+
sanitize() { printf '%s' "$1" | tr -c 'A-Za-z0-9_-' '_'; }
|
|
129
|
+
|
|
130
|
+
# ---- per-host advisory lock (portable: mkdir, no flock dependency) ---------
|
|
131
|
+
LOCK_HELD=""
|
|
132
|
+
acquire_lock() {
|
|
133
|
+
local dir="$1" waited=0 max="$LOCK_WAIT" merr
|
|
134
|
+
while ! merr=$(mkdir "$dir" 2>&1); do
|
|
135
|
+
# mkdir fails when the lock exists (another tmux-ssh holds the host: wait) or when it cannot be created at all
|
|
136
|
+
# (permissions, read-only filesystem, missing parent: waiting never helps). If the directory is not there after a
|
|
137
|
+
# failed mkdir, try once more: the holder may have released it between the two calls.
|
|
138
|
+
if [ ! -d "$dir" ]; then
|
|
139
|
+
merr=$(mkdir "$dir" 2>&1) && break
|
|
140
|
+
[ -d "$dir" ] || die "cannot create the host lock directory $dir: ${merr:-unknown error} (this is not lock contention; check the permissions of $(dirname "$dir"))" 125
|
|
141
|
+
fi
|
|
142
|
+
if [ -f "$dir/pid" ]; then
|
|
143
|
+
local p; p=$(cat "$dir/pid" 2>/dev/null || true)
|
|
144
|
+
if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null; then
|
|
145
|
+
rm -f "$dir/pid" 2>/dev/null; rmdir "$dir" 2>/dev/null; continue # steal stale
|
|
146
|
+
fi
|
|
147
|
+
fi
|
|
148
|
+
waited=$((waited + 1))
|
|
149
|
+
if [ "$waited" -gt "$((max * 5))" ]; then
|
|
150
|
+
die "could not acquire lock for this host (held by pid $(cat "$dir/pid" 2>/dev/null || echo '?'))" 125
|
|
151
|
+
fi
|
|
152
|
+
sleep 0.2
|
|
153
|
+
done
|
|
154
|
+
printf '%s\n' "$$" > "$dir/pid"
|
|
155
|
+
LOCK_HELD="$dir"
|
|
156
|
+
}
|
|
157
|
+
release_lock() {
|
|
158
|
+
[ -n "$LOCK_HELD" ] || return 0
|
|
159
|
+
rm -f "$LOCK_HELD/pid" 2>/dev/null; rmdir "$LOCK_HELD" 2>/dev/null
|
|
160
|
+
LOCK_HELD=""
|
|
161
|
+
}
|
|
162
|
+
trap release_lock EXIT
|
|
163
|
+
|
|
164
|
+
# ---- reference file --------------------------------------------------------
|
|
165
|
+
read_ref() {
|
|
166
|
+
EXPECTED_HOSTNAME=""
|
|
167
|
+
[ -f "$REF_FILE" ] || return 0
|
|
168
|
+
EXPECTED_HOSTNAME=$(grep '^expected_hostname=' "$REF_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# ---- identity guard ---------------------------------------------------------
|
|
172
|
+
# A stale recorded identity (or a probe that leaked onto a local shell in the pane) can
|
|
173
|
+
# make this script treat the box it was launched FROM as the remote target. These checks are independent of,
|
|
174
|
+
# and layered ON TOP OF, the existing prior-vs-new drift check: that check only catches DRIFT from a previously
|
|
175
|
+
# recorded value; it says nothing about whether that value (or a brand-new first-connect value) was ever
|
|
176
|
+
# trustworthy.
|
|
177
|
+
# local_hostname tries three ways to learn this machine's own hostname,
|
|
178
|
+
# since any single one can be absent (a minimal container may lack
|
|
179
|
+
# `hostname`; `uname -n` and `HostName`/`LocalHostName` can disagree on
|
|
180
|
+
# macOS. Prints nothing and fails if all three do.
|
|
181
|
+
local_hostname() {
|
|
182
|
+
local h
|
|
183
|
+
h=$(hostname 2>/dev/null); [ -n "$h" ] && { printf '%s' "$h"; return 0; }
|
|
184
|
+
h=$(uname -n 2>/dev/null); [ -n "$h" ] && { printf '%s' "$h"; return 0; }
|
|
185
|
+
if command -v scutil >/dev/null 2>&1; then
|
|
186
|
+
h=$(scutil --get LocalHostName 2>/dev/null)
|
|
187
|
+
[ -n "$h" ] && { printf '%s' "$h"; return 0; }
|
|
188
|
+
fi
|
|
189
|
+
return 1
|
|
190
|
+
}
|
|
191
|
+
# resolve_local_hostname memoizes local_hostname for the lifetime of this
|
|
192
|
+
# invocation, into the globals below -- it's called from every identity-guard
|
|
193
|
+
# check, and each underlying lookup is its own subprocess.
|
|
194
|
+
#
|
|
195
|
+
# [CORRECTNESS] MUST be called directly (never as `x=$(resolve_local_hostname)`
|
|
196
|
+
# or inside any other command substitution) -- a command substitution forks a
|
|
197
|
+
# SUBSHELL, and an assignment made inside a subshell is discarded the instant
|
|
198
|
+
# it exits. Calling it that way silently defeats the memoization (every call
|
|
199
|
+
# would re-run local_hostname from scratch, each in its own throwaway
|
|
200
|
+
# subshell) without producing any error. Callers read the result back from
|
|
201
|
+
# $LOCAL_HOSTNAME_CACHE afterward, in the SAME (non-subshell) context.
|
|
202
|
+
LOCAL_HOSTNAME_CACHE=""
|
|
203
|
+
LOCAL_HOSTNAME_CACHE_SET=false
|
|
204
|
+
resolve_local_hostname() {
|
|
205
|
+
[ "$LOCAL_HOSTNAME_CACHE_SET" = true ] && return 0
|
|
206
|
+
LOCAL_HOSTNAME_CACHE=$(local_hostname) || true
|
|
207
|
+
LOCAL_HOSTNAME_CACHE_SET=true
|
|
208
|
+
}
|
|
209
|
+
first_label() { printf '%s' "$1" | cut -d. -f1; }
|
|
210
|
+
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
|
|
211
|
+
# hostnames_match compares two hostnames tolerant of short-vs-FQDN form
|
|
212
|
+
# (either side's first dot-label may match the other's full value),
|
|
213
|
+
# case-insensitively.
|
|
214
|
+
hostnames_match() {
|
|
215
|
+
local a b al bl
|
|
216
|
+
a="$1"; b="$2"
|
|
217
|
+
[ -n "$a" ] && [ -n "$b" ] || return 1
|
|
218
|
+
al=$(lower "$a"); bl=$(lower "$b")
|
|
219
|
+
[ "$al" = "$bl" ] && return 0
|
|
220
|
+
[ "$(lower "$(first_label "$a")")" = "$bl" ] && return 0
|
|
221
|
+
[ "$al" = "$(lower "$(first_label "$b")")" ] && return 0
|
|
222
|
+
return 1
|
|
223
|
+
}
|
|
224
|
+
# alias_is_explicitly_local reports whether $HOST (optional user@ stripped)
|
|
225
|
+
# is a literal loopback alias -- the one case where "reported hostname ==
|
|
226
|
+
# local hostname" is the CORRECT, intended outcome, not a hazard.
|
|
227
|
+
alias_is_explicitly_local() {
|
|
228
|
+
# ::1 deliberately excluded: HOST's
|
|
229
|
+
# own charset validation rejects ':' with "invalid host" before ANY alias
|
|
230
|
+
# ever reaches this function, and IPv6 aliases are not a fleet use case --
|
|
231
|
+
# so it would be dead code, unreachable via the real CLI.
|
|
232
|
+
case "${1#*@}" in localhost|127.0.0.1) return 0 ;; esac
|
|
233
|
+
return 1
|
|
234
|
+
}
|
|
235
|
+
# An unknown local hostname is refused, not silently passed: if this
|
|
236
|
+
# machine's own identity can't be determined, NEITHER of the checks below
|
|
237
|
+
# can rule out that a reported/recorded value IS this machine, so trusting
|
|
238
|
+
# either would be fail-open on exactly the case they exist to catch.
|
|
239
|
+
die_unknown_local_identity() {
|
|
240
|
+
die "IDENTITY GUARD: cannot determine this machine's own hostname (hostname / uname -n / scutil all failed or returned empty) -- refusing $1 rather than risk it silently being this machine" 125
|
|
241
|
+
}
|
|
242
|
+
# reject_if_local_identity_ref refuses a RECORDED (from an earlier session)
|
|
243
|
+
# hostname that equals this machine's own hostname -- a ref file poisoned by
|
|
244
|
+
# a past instance of this exact bug. Fires before any (re)connect attempt.
|
|
245
|
+
reject_if_local_identity_ref() {
|
|
246
|
+
local recorded="$1" lh
|
|
247
|
+
[ -n "$recorded" ] || return 0
|
|
248
|
+
alias_is_explicitly_local "$HOST" && return 0
|
|
249
|
+
resolve_local_hostname; lh="$LOCAL_HOSTNAME_CACHE"
|
|
250
|
+
[ -n "$lh" ] || die_unknown_local_identity "the ref file for $HOST"
|
|
251
|
+
if hostnames_match "$recorded" "$lh"; then
|
|
252
|
+
die "IDENTITY GUARD: the ref file for $HOST records '$recorded', which matches THIS machine's own hostname ('$lh') -- treating it as POISONED. Clear it: $SCRIPT_NAME $HOST --kill" 125
|
|
253
|
+
fi
|
|
254
|
+
}
|
|
255
|
+
# reject_if_local_identity refuses a FRESHLY-PROBED hostname that equals this
|
|
256
|
+
# machine's own hostname. Runs on every (re)connect, including the very
|
|
257
|
+
# first one -- a first connect is never trusted blindly.
|
|
258
|
+
reject_if_local_identity() {
|
|
259
|
+
local h="$1" lh
|
|
260
|
+
[ -n "$h" ] || return 0
|
|
261
|
+
alias_is_explicitly_local "$HOST" && return 0
|
|
262
|
+
resolve_local_hostname; lh="$LOCAL_HOSTNAME_CACHE"
|
|
263
|
+
[ -n "$lh" ] || die_unknown_local_identity "$HOST's reported hostname '$h'"
|
|
264
|
+
if hostnames_match "$h" "$lh"; then
|
|
265
|
+
die "IDENTITY GUARD: $HOST's reported hostname '$h' matches THIS machine's own hostname ('$lh') -- refusing to treat the local box as the remote target" 125
|
|
266
|
+
fi
|
|
267
|
+
}
|
|
268
|
+
# reject_if_expect_host_mismatch enforces an operator-supplied expectation
|
|
269
|
+
# (--expect-host / TMUX_SSH_EXPECT_HOST), when one was given. Deliberately
|
|
270
|
+
# NOT derived automatically from the alias name: an alias's OS hostname can
|
|
271
|
+
# legitimately differ from its ssh-config name (see the flag's help text for
|
|
272
|
+
# how to look it up and set this explicitly).
|
|
273
|
+
reject_if_expect_host_mismatch() {
|
|
274
|
+
local h="$1"
|
|
275
|
+
[ -n "$EXPECT_HOST" ] || return 0
|
|
276
|
+
hostnames_match "$h" "$EXPECT_HOST" && return 0
|
|
277
|
+
die "IDENTITY GUARD: $HOST's reported hostname '$h' does not match --expect-host/TMUX_SSH_EXPECT_HOST '$EXPECT_HOST' -- refusing" 125
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# A timeout may leave the remote command running. Persist its exact marker so
|
|
281
|
+
# another invocation can observe that command without reconnecting or typing.
|
|
282
|
+
WATCH_NONCE=""
|
|
283
|
+
read_watch() {
|
|
284
|
+
WATCH_NONCE=""; WATCH_TIMEOUT=""
|
|
285
|
+
[ -f "$WATCH_FILE" ] || return 0
|
|
286
|
+
WATCH_NONCE=$(grep '^nonce=' "$WATCH_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
|
|
287
|
+
WATCH_TIMEOUT=$(grep '^timeout=' "$WATCH_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
|
|
288
|
+
case "$WATCH_TIMEOUT" in *[!0-9]*) WATCH_TIMEOUT="" ;; esac
|
|
289
|
+
case "$WATCH_NONCE" in ''|*[!A-Za-z0-9_]*) WATCH_NONCE=""; return 1 ;; esac
|
|
290
|
+
}
|
|
291
|
+
write_watch() { # returns nonzero (and sets no WATCH_NONCE) unless the watch file was written AND renamed into place
|
|
292
|
+
local n="$1" tmp="${WATCH_FILE}.tmp.$$"
|
|
293
|
+
printf 'nonce=%s\nstarted_at=%s\ntimeout=%s\n' "$n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$CMD_TIMEOUT" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
294
|
+
mv -f "$tmp" "$WATCH_FILE" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
295
|
+
[ -f "$WATCH_FILE" ] || return 1
|
|
296
|
+
WATCH_NONCE="$n"
|
|
297
|
+
}
|
|
298
|
+
# ---- input guard ------------------------------------------------------------
|
|
299
|
+
# A payload-free file armed under the host lock BEFORE any keystroke that changes the remote input, and cleared only
|
|
300
|
+
# after every primitive of that input succeeded (and, for a command, after its watch was published). If any
|
|
301
|
+
# primitive fails, the guard STAYS with the stage and tmux's native rc: what reached the remote input is UNKNOWN, so
|
|
302
|
+
# every later mutating call (run, ensure, probe, setup, --send, --key) is refused before it types anything, and
|
|
303
|
+
# nothing is cleared, killed or reconnected automatically. Observers (--read, --status, --resume) keep working. Only
|
|
304
|
+
# an explicit --kill that kills the exact session (or proves it absent) clears it.
|
|
305
|
+
guard_arm() { # $1=stage : 0 only if the guard file is in place
|
|
306
|
+
local tmp="${GUARD_FILE}.tmp.$$"
|
|
307
|
+
printf 'state=armed\nstage=%s\narmed_at=%s\n' "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
308
|
+
mv -f "$tmp" "$GUARD_FILE" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
309
|
+
[ -f "$GUARD_FILE" ]
|
|
310
|
+
}
|
|
311
|
+
guard_mark() { # $1=stage $2=step $3=native rc : record UNKNOWN (best effort; the armed guard stays either way)
|
|
312
|
+
{ printf 'state=UNKNOWN\nstage=%s\nfailed_step=%s\nnative_rc=%s\nmarked_at=%s\n' "$1" "$2" "$3" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
313
|
+
> "${GUARD_FILE}.tmp.$$" && mv -f "${GUARD_FILE}.tmp.$$" "$GUARD_FILE"; } 2>/dev/null || rm -f "${GUARD_FILE}.tmp.$$"
|
|
314
|
+
}
|
|
315
|
+
guard_clear() { rm -f "$GUARD_FILE" 2>/dev/null; }
|
|
316
|
+
guard_present() { [ -e "$GUARD_FILE" ]; }
|
|
317
|
+
guard_summary() { tr '\n' ' ' < "$GUARD_FILE" 2>/dev/null; }
|
|
318
|
+
refuse_if_guarded() { # pre-input admission for every mutating path
|
|
319
|
+
guard_present || return 0
|
|
320
|
+
die "UNRESOLVED input on $HOST ($(guard_summary| sed 's/ $//')): a previous keystroke's effect is unknown, so NOTHING new is typed. Inspect with $SCRIPT_NAME $HOST --read / --status; when resolved, clear it with $SCRIPT_NAME $HOST --kill" 125
|
|
321
|
+
}
|
|
322
|
+
clear_watch() { rm -f "$WATCH_FILE"; WATCH_NONCE=""; }
|
|
323
|
+
write_ref() {
|
|
324
|
+
mkdir -p "$REF_DIR"
|
|
325
|
+
# Atomic: write to a temp file then rename, so a concurrent reader never sees
|
|
326
|
+
# a half-written ref (and a crash mid-write can't truncate the live file).
|
|
327
|
+
local tmp="${REF_FILE}.tmp.$$"
|
|
328
|
+
printf 'host=%s\nssh_target=%s\nsocket=%s\nsession=%s\nexpected_hostname=%s\nestablished_at=%s\n' \
|
|
329
|
+
"$HOST" "$HOST" "$SOCKET" "$SESSION" "$EXPECTED_HOSTNAME" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
330
|
+
> "$tmp"
|
|
331
|
+
mv -f "$tmp" "$REF_FILE"
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
# ---- tmux primitives -------------------------------------------------------
|
|
335
|
+
has_session() { tmux -L "$SOCKET" has-session -t "$STGT" 2>/dev/null; }
|
|
336
|
+
pane_cmd() { tmux -L "$SOCKET" list-panes -t "$PTGT" -F '#{pane_current_command}' 2>/dev/null | head -1; }
|
|
337
|
+
capture() { tmux -L "$SOCKET" capture-pane -p -t "$PTGT" -S "-${CAPTURE_LINES}" -J 2>/dev/null; }
|
|
338
|
+
# send_line: type the text, then Enter, checking each primitive. Never clears, kills or reconnects on failure. Returns
|
|
339
|
+
# 1 if the literal failed, 2 if the literal was accepted and Enter failed; SEND_STEP, SEND_RC (tmux's native rc) and
|
|
340
|
+
# SEND_ERR (its stderr) describe the failure. Callers hold an armed input guard around it.
|
|
341
|
+
SEND_ERR=""; SEND_RC=0; SEND_STEP=""
|
|
342
|
+
send_line() {
|
|
343
|
+
SEND_ERR=$(tmux -L "$SOCKET" send-keys -t "$PTGT" -l "$1" 2>&1 >/dev/null); SEND_RC=$?
|
|
344
|
+
if [ "$SEND_RC" -ne 0 ]; then SEND_STEP=literal; return 1; fi
|
|
345
|
+
SEND_ERR=$(tmux -L "$SOCKET" send-keys -t "$PTGT" Enter 2>&1 >/dev/null); SEND_RC=$?
|
|
346
|
+
if [ "$SEND_RC" -ne 0 ]; then SEND_STEP=enter; return 2; fi
|
|
347
|
+
SEND_STEP=""; SEND_ERR=""
|
|
348
|
+
}
|
|
349
|
+
# guarded_line <stage> <text> : arm the guard, send, clear only on full success. rc 0 ok; rc 125 = guard could not be
|
|
350
|
+
# armed (NOTHING was sent); rc 3 = a primitive failed (guard kept, input UNKNOWN).
|
|
351
|
+
guarded_line() {
|
|
352
|
+
guard_arm "$1" || { log "🔴 REFUSED ($1): could not arm the input guard in $REF_DIR; nothing was sent"; return 125; }
|
|
353
|
+
if ! send_line "$2"; then
|
|
354
|
+
guard_mark "$1" "$SEND_STEP" "$SEND_RC"
|
|
355
|
+
log "🔴 INPUT UNKNOWN ($1): tmux $SEND_STEP failed (native rc $SEND_RC: ${SEND_ERR:-no message}). What reached $HOST's input is unknown; the host is now guarded: nothing more is typed until $SCRIPT_NAME $HOST --kill. Do NOT blindly retry."
|
|
356
|
+
return 3
|
|
357
|
+
fi
|
|
358
|
+
[ "${GUARD_KEEP:-0}" = 1 ] || guard_clear
|
|
359
|
+
return 0
|
|
360
|
+
}
|
|
361
|
+
pane_dead() { tmux -L "$SOCKET" list-panes -t "$PTGT" -F '#{pane_dead}' 2>/dev/null | head -1; }
|
|
362
|
+
# the pane is a LIVE ssh client: alive (a remain-on-exit pane keeps reporting its last command when dead) and
|
|
363
|
+
# its foreground process is ssh
|
|
364
|
+
pane_is_ssh() { [ "$(pane_dead)" = 0 ] && [ "$(pane_cmd)" = "ssh" ]; }
|
|
365
|
+
# connected == the pane's foreground process is a live ssh (gate 1)
|
|
366
|
+
is_connected(){ has_session && pane_is_ssh; }
|
|
367
|
+
|
|
368
|
+
# Pattern tests on a captured buffer. NOT `printf | grep -q`: under pipefail, grep -q exits at the first match,
|
|
369
|
+
# and if the buffer is larger than a pipe (a 5000-line capture can be ~1 MB) printf then fails on the closed pipe,
|
|
370
|
+
# the pipeline is nonzero, and a PRESENT marker reads as absent (missed DONE -> false timeout; missed BEGIN ->
|
|
371
|
+
# leaked history; missed --expect prompt). grep without -q reads all of its input, so printf always completes.
|
|
372
|
+
buf_has_re() { printf '%s\n' "$1" | grep -E -- "$2" >/dev/null; }
|
|
373
|
+
buf_has_fixed() { printf '%s\n' "$1" | grep -F -- "$2" >/dev/null; }
|
|
374
|
+
|
|
375
|
+
# Per-command marker token. pid + epoch-seconds + two $RANDOM draws so the
|
|
376
|
+
# anchored DONE regex cannot collide with a concurrent or prior call's sentinel.
|
|
377
|
+
nonce() { printf '%s_%s_%s%s' "$$" "$(date +%s)" "${RANDOM}" "${RANDOM}"; }
|
|
378
|
+
|
|
379
|
+
# ---- probe the remote hostname (gate 2 / establish-time learning) ----------
|
|
380
|
+
# Echoes the discovered hostname on success (rc 0); rc 1 on timeout.
|
|
381
|
+
# The recorded-hostname line is matched ANCHORED (^TSSHHOST_<n>=), which the
|
|
382
|
+
# echoed input line (starts with ':') can never match — so only a live shell's
|
|
383
|
+
# resolved output is ever read.
|
|
384
|
+
probe_hostname() {
|
|
385
|
+
local timeout="${1:-8}" n
|
|
386
|
+
# [SECURITY] gate on a live ssh pane BEFORE sending, mirroring cmd_send/handle_expects. A dead pane
|
|
387
|
+
# never answers, so the probe times out instead of reading anything.
|
|
388
|
+
if ! pane_is_ssh; then
|
|
389
|
+
log "probe: pane is not ssh (connection not confirmed) -- refusing to probe"
|
|
390
|
+
return 1
|
|
391
|
+
fi
|
|
392
|
+
n=$(nonce)
|
|
393
|
+
local grc=0; guarded_line probe "$(printf ': TSSHBEGIN_%s; echo ""; echo "TSSHHOST_%s=$(hostname)"; echo "TSSHDONE_%s_ rc=$?"' "$n" "$n" "$n")" || grc=$?
|
|
394
|
+
[ "$grc" = 0 ] || return "$grc" # 3 = input UNKNOWN (fatal), 125 = nothing sent
|
|
395
|
+
local deadline=$(( $(date +%s) + timeout )) buf
|
|
396
|
+
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
397
|
+
buf=$(capture)
|
|
398
|
+
if tssh_has_done "$n" "$buf"; then
|
|
399
|
+
local h
|
|
400
|
+
h=$(printf '%s\n' "$buf" | grep -E "^TSSHHOST_${n}=" | tail -1 | sed "s/^TSSHHOST_${n}=//")
|
|
401
|
+
# [SECURITY] This value is interpolated into gate 3's guard string
|
|
402
|
+
# (build_wrapper: [ "$(hostname)" = "<h>" ]). A hostile/compromised remote
|
|
403
|
+
# returning a hostname containing '"' or ';' could break out and, on the
|
|
404
|
+
# local-shell fallback path, execute locally. A real hostname is
|
|
405
|
+
# [A-Za-z0-9._-]+; reject anything else and fail the probe (fail-safe).
|
|
406
|
+
case "$h" in
|
|
407
|
+
''|*[!A-Za-z0-9._-]*) log "refusing probed hostname with unexpected characters: '$h'"; return 1 ;;
|
|
408
|
+
esac
|
|
409
|
+
printf '%s\n' "$h"
|
|
410
|
+
return 0
|
|
411
|
+
fi
|
|
412
|
+
sleep 0.2
|
|
413
|
+
done
|
|
414
|
+
return 1
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# ---- build the sentinel-wrapped payload (gate 3 inline host-guard) ---------
|
|
418
|
+
# Resolved DONE line carries the exit code; the echoed input line contains the
|
|
419
|
+
# literal format so it can never false-match the anchored resolved DONE regex.
|
|
420
|
+
# A command that runs `exit` (or `logout`) would close the remote login shell before the DONE line is printed: the pane
|
|
421
|
+
# dies, the run is reported as a dropped connection (rc 125, completion unknown) although it finished. Such a command is
|
|
422
|
+
# typed inside a subshell, where `exit N` just becomes the command's exit code. Other commands run in the login shell
|
|
423
|
+
# itself, so a `cd` or `export` in one still applies to the next. Over-matching only costs that persistence.
|
|
424
|
+
cmd_exits_shell() { [[ "$1" =~ (^|[^A-Za-z0-9_./-])(exit|logout)($|[^A-Za-z0-9_./-]) ]]; }
|
|
425
|
+
# The command is TYPED into the remote login shell (send-keys -l), so that shell's line editor sees it key by key. zsh with
|
|
426
|
+
# oh-my-zsh's url-quote-magic then backslash-quotes ; & | { ... in any word containing "://": `curl http://h/a; echo B` becomes
|
|
427
|
+
# one command, and a command that ends in a URL swallows the appended `; __tsrc=$?` (no DONE line, a timeout). Such a command
|
|
428
|
+
# is sent base64-encoded and decoded on the remote, so no widget can rewrite it. A failed decode is an error, never a no-op.
|
|
429
|
+
cmd_needs_encoding() { case "$1" in *://*) return 0 ;; esac; return 1; }
|
|
430
|
+
encode_cmd() { # prints the typed form of a command that contains a URL
|
|
431
|
+
local b64; b64=$(printf '%s' "$1" | base64 | tr -d '\n')
|
|
432
|
+
printf '%s' '__tsd=$(printf %s '"$b64"' | { base64 -d 2>/dev/null || base64 -D; }) && eval "$__tsd"'
|
|
433
|
+
}
|
|
434
|
+
build_wrapper() {
|
|
435
|
+
local n="$1" cmd="$2"
|
|
436
|
+
if cmd_exits_shell "$cmd"; then cmd="( $cmd"$'\n'")"; fi
|
|
437
|
+
if cmd_needs_encoding "$cmd"; then cmd=$(encode_cmd "$cmd"); fi
|
|
438
|
+
# A final heredoc delimiter must occupy its own line, even when the caller
|
|
439
|
+
# omitted a trailing newline. Keep the wrapper suffix on a separate line.
|
|
440
|
+
printf ': TSSHBEGIN_%s; if [ "$(hostname)" = "%s" ]; then %s\n__tsrc=$?; else __tsrc=125; fi; echo ""; echo "TSSHDONE_%s_ rc=$__tsrc"' \
|
|
441
|
+
"$n" "$EXPECTED_HOSTNAME" "$cmd" "$n"
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# ---- slice this command's output out of the pane buffer --------------------
|
|
445
|
+
emit_output() {
|
|
446
|
+
local buf="$1" n="$2" sliced
|
|
447
|
+
sliced=$(printf '%s\n' "$buf" | awk -v n="$n" '
|
|
448
|
+
index($0, "TSSHBEGIN_" n) { seen=1; next }
|
|
449
|
+
seen && $0 ~ ("^TSSHDONE_" n "_ rc=[0-9]") { exit }
|
|
450
|
+
seen { print }
|
|
451
|
+
')
|
|
452
|
+
# Fallback ONLY if the BEGIN anchor scrolled out of the capture window. A command that printed nothing has an
|
|
453
|
+
# empty slice WITH its BEGIN present: falling back then leaked old pane history as its output.
|
|
454
|
+
if [ -z "$sliced" ] && ! buf_has_fixed "$buf" "TSSHBEGIN_${n}"; then
|
|
455
|
+
sliced=$(printf '%s\n' "$buf" | awk -v n="$n" '$0 ~ ("^TSSHDONE_" n "_ rc=[0-9]") { exit } { print }')
|
|
456
|
+
fi
|
|
457
|
+
# Strip trailing blank lines (the wrapper's separator echo), then tail.
|
|
458
|
+
printf '%s\n' "$sliced" | awk '{ L[NR]=$0 } END { last=NR; while (last>0 && L[last] ~ /^[[:space:]]*$/) last--; for (i=1;i<=last;i++) print L[i] }' \
|
|
459
|
+
| tail -n "$LINES"
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# ---- expect: auto-answer known prompts while a command runs (Tier B) -------
|
|
463
|
+
# Scans only the region AFTER this command's BEGIN marker so a stale prompt in
|
|
464
|
+
# scrollback cannot fire. Each rule is one-shot (tracked in EXPECT_FIRED).
|
|
465
|
+
handle_expects() {
|
|
466
|
+
local buf="$1" n="$2" i pat resp region
|
|
467
|
+
[ "${#EXPECT_PATS[@]}" -eq 0 ] && return 0
|
|
468
|
+
region=$(printf '%s\n' "$buf" | awk -v n="$n" 'index($0,"TSSHBEGIN_" n){seen=1;next} seen{print}')
|
|
469
|
+
for i in "${!EXPECT_PATS[@]}"; do
|
|
470
|
+
[ "${EXPECT_FIRED[$i]:-0}" -eq 1 ] && continue
|
|
471
|
+
pat="${EXPECT_PATS[$i]}"
|
|
472
|
+
# SINGLE-LINE CONTRACT: $pat is one literal substring (from --expect); a value with a newline is not supported.
|
|
473
|
+
if [[ $region == *"$pat"* ]]; then
|
|
474
|
+
# [SECURITY] Gate 1 the response too: if ssh dropped mid-command the pane is dead, and a response
|
|
475
|
+
# (esp. an --expect-secret) must not be typed anywhere. The identity probe can't run mid-command.
|
|
476
|
+
if ! pane_is_ssh; then
|
|
477
|
+
log "expect: pane is no longer ssh (connection dropped) — REFUSING to send response for '$pat' (the connection is gone)"
|
|
478
|
+
continue
|
|
479
|
+
fi
|
|
480
|
+
resp="${EXPECT_RESPS[$i]}"
|
|
481
|
+
local erc=0; guarded_line expect "$resp" || erc=$?
|
|
482
|
+
if [ "$erc" != 0 ]; then EXPECT_SEND_FAILED=$erc; return 1; fi # stop: no further responses or actions
|
|
483
|
+
EXPECT_FIRED[$i]=1
|
|
484
|
+
if [ "${EXPECT_SECRET[$i]:-0}" -eq 1 ]; then log "expect: matched '$pat' → sent secret response"
|
|
485
|
+
else log "expect: matched '$pat' → sent '$resp'"; fi
|
|
486
|
+
fi
|
|
487
|
+
done
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
# ssh command line for the pane; caller TMUX_SSH_OPTS go first (ssh: first -o wins).
|
|
491
|
+
ssh_cmd_line() {
|
|
492
|
+
local o="${TMUX_SSH_OPTS:-}"
|
|
493
|
+
echo "ssh ${o:+$o }-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o LogLevel=ERROR -- $HOST"
|
|
494
|
+
}
|
|
495
|
+
# the same command as an argv array (SSH_ARGV), so tmux execs ssh DIRECTLY: no $SHELL -c in between. Splitting on
|
|
496
|
+
# spaces is exact: TMUX_SSH_OPTS and HOST are validated to a charset with no quotes or glob characters.
|
|
497
|
+
ssh_argv() { read -r -a SSH_ARGV <<< "$(ssh_cmd_line)"; }
|
|
498
|
+
|
|
499
|
+
# ---- establish or reconnect the ssh session --------------------------------
|
|
500
|
+
establish() {
|
|
501
|
+
read_ref
|
|
502
|
+
local prior="$EXPECTED_HOSTNAME"
|
|
503
|
+
reject_if_local_identity_ref "$prior"
|
|
504
|
+
# [SECURITY] ssh is the pane's OWN process (exec'd by tmux directly, argv form, no shell), never a command typed into a local
|
|
505
|
+
# shell. Keys sent while ssh is still authenticating used to sit in the tty and be read by the LOCAL shell when
|
|
506
|
+
# auth failed (publickey denied -> the probe ran on the fleet box). With no shell in the pane, a failed or
|
|
507
|
+
# dropped ssh leaves a DEAD pane (remain-on-exit) that executes nothing; its last screen stays readable.
|
|
508
|
+
ssh_argv
|
|
509
|
+
if ! has_session; then
|
|
510
|
+
tmux -L "$SOCKET" new-session -d -s "$SESSION" -x 220 -y 50 "${SSH_ARGV[@]}" \; \
|
|
511
|
+
set-option -t "$PTGT" remain-on-exit on >/dev/null 2>&1 || die "tmux new-session failed" 1
|
|
512
|
+
elif ! pane_is_ssh; then
|
|
513
|
+
# dead ssh pane, or a legacy session whose pane is a local shell: replace the pane's process outright (-k)
|
|
514
|
+
tmux -L "$SOCKET" set-option -t "$PTGT" remain-on-exit on >/dev/null 2>&1
|
|
515
|
+
tmux -L "$SOCKET" respawn-pane -k -t "$PTGT" "${SSH_ARGV[@]}" >/dev/null 2>&1 || die "tmux respawn-pane failed" 1
|
|
516
|
+
fi
|
|
517
|
+
has_session || die "ssh to $HOST exited immediately (auth/host-key issue? see: ssh $HOST)" 125
|
|
518
|
+
# Wait for the ssh client to become the pane's foreground process.
|
|
519
|
+
local deadline=$(( $(date +%s) + CONNECT_TIMEOUT ))
|
|
520
|
+
while [ "$(date +%s)" -lt "$deadline" ]; do pane_is_ssh && break; sleep 0.2; done
|
|
521
|
+
if ! pane_is_ssh; then
|
|
522
|
+
capture | awk 'NF' | tail -5 | sed 's/^/ ssh: /' >&2
|
|
523
|
+
die "ssh did not start within ${CONNECT_TIMEOUT}s or has exited (auth/host-key issue? see the ssh lines above; nothing was sent)" 125
|
|
524
|
+
fi
|
|
525
|
+
# Wait for a live remote shell (only a returned sentinel counts; keys only go
|
|
526
|
+
# in AFTER pane==ssh, so this can never learn the LOCAL hostname).
|
|
527
|
+
sleep 1
|
|
528
|
+
local h="" pdeadline=$(( $(date +%s) + CONNECT_TIMEOUT ))
|
|
529
|
+
while [ "$(date +%s)" -lt "$pdeadline" ]; do
|
|
530
|
+
local prc=0; h=$(probe_hostname 8) || prc=$?
|
|
531
|
+
case "$prc" in 3) exit 3 ;; 125) exit 125 ;; esac # fatal send failure: no further probe, setup or user send
|
|
532
|
+
[ "$prc" = 0 ] && [ -n "$h" ] && break
|
|
533
|
+
h=""; sleep 0.5
|
|
534
|
+
done
|
|
535
|
+
if [ -z "$h" ]; then
|
|
536
|
+
capture | awk 'NF' | grep -v 'TSSH' | tail -5 | sed 's/^/ ssh: /' >&2
|
|
537
|
+
if pane_is_ssh; then
|
|
538
|
+
die "connected but no remote shell confirmed within ${CONNECT_TIMEOUT}s (auth prompt? try: $SCRIPT_NAME $HOST --attach)" 125
|
|
539
|
+
fi
|
|
540
|
+
die "ssh to $HOST exited before a remote shell was confirmed (see the ssh lines above; nothing ran on any shell)" 125
|
|
541
|
+
fi
|
|
542
|
+
# never trust a probed hostname -- first connect or not --
|
|
543
|
+
# before it clears the identity guard, and before it can be written as
|
|
544
|
+
# ground truth below.
|
|
545
|
+
reject_if_local_identity "$h"
|
|
546
|
+
reject_if_expect_host_mismatch "$h"
|
|
547
|
+
if [ -n "$prior" ] && [ "$h" != "$prior" ]; then
|
|
548
|
+
die "IDENTITY MISMATCH on (re)connect: recorded '$prior' but got '$h' — refusing to use this session" 125
|
|
549
|
+
fi
|
|
550
|
+
EXPECTED_HOSTNAME="$h"
|
|
551
|
+
# [SECURITY] re-check the live ssh pane one last time, AFTER identity is confirmed but
|
|
552
|
+
# BEFORE this send and before write_ref: a ref must never record a validated identity for a pane that has died.
|
|
553
|
+
if ! pane_is_ssh; then
|
|
554
|
+
die "IDENTITY GUARD: connection to $HOST dropped between identity confirmation and session setup -- refusing before recording anything" 125
|
|
555
|
+
fi
|
|
556
|
+
# Make output clean and deterministic for a driven session.
|
|
557
|
+
local src=0; guarded_line setup 'export PAGER=cat GIT_PAGER=cat 2>/dev/null; true' || src=$?
|
|
558
|
+
[ "$src" = 0 ] || exit "$src" # no ref, no "connected", no user payload after an uncertain setup line
|
|
559
|
+
write_ref
|
|
560
|
+
if [ -n "$prior" ]; then
|
|
561
|
+
log "RECONNECTED to $HOST ($h) — remote shell state (cwd, env, running jobs) was RESET"
|
|
562
|
+
else
|
|
563
|
+
log "connected to $HOST ($h)"
|
|
564
|
+
fi
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
# ---- ensure a usable, identity-confirmed connection ------------------------
|
|
568
|
+
ensure_connected() {
|
|
569
|
+
read_ref
|
|
570
|
+
reject_if_local_identity_ref "$EXPECTED_HOSTNAME"
|
|
571
|
+
if is_connected; then
|
|
572
|
+
local h prc=0; h=$(probe_hostname 12) || prc=$?
|
|
573
|
+
case "$prc" in 3) exit 3 ;; 125) exit 125 ;; esac # fatal send failure: not "busy", not a reconnect
|
|
574
|
+
if [ -n "$h" ]; then
|
|
575
|
+
reject_if_local_identity "$h"
|
|
576
|
+
reject_if_expect_host_mismatch "$h"
|
|
577
|
+
if [ -n "$EXPECTED_HOSTNAME" ] && [ "$h" != "$EXPECTED_HOSTNAME" ]; then
|
|
578
|
+
die "IDENTITY MISMATCH: recorded '$EXPECTED_HOSTNAME' but live shell is '$h' — refusing" 125
|
|
579
|
+
fi
|
|
580
|
+
if [ -z "$EXPECTED_HOSTNAME" ]; then EXPECTED_HOSTNAME="$h"; write_ref; fi
|
|
581
|
+
return 0
|
|
582
|
+
fi
|
|
583
|
+
# pane==ssh but no sentinel: a command is in flight, or the shell is wedged.
|
|
584
|
+
if [ -n "$EXPECTED_HOSTNAME" ]; then
|
|
585
|
+
die "session for $HOST is busy or unresponsive (a command may be running) — retry, or: $SCRIPT_NAME $HOST --attach" 125
|
|
586
|
+
fi
|
|
587
|
+
fi
|
|
588
|
+
# Not connected.
|
|
589
|
+
if [ "$ALLOW_RECONNECT" != "true" ]; then
|
|
590
|
+
die "not connected to $HOST and --no-reconnect is set (the command did NOT run)" 125
|
|
591
|
+
fi
|
|
592
|
+
establish
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
# ---- observe one exact command marker without ever sending input ------------
|
|
596
|
+
observe_watch() {
|
|
597
|
+
local n="$1" permit_actions="${2:-false}"
|
|
598
|
+
local deadline=$(( $(date +%s) + CMD_TIMEOUT )) buf found=0
|
|
599
|
+
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
600
|
+
buf=$(capture)
|
|
601
|
+
if tssh_has_done "$n" "$buf"; then found=1; break; fi
|
|
602
|
+
# If ssh exits mid-command the pane's foreground process stops being `ssh`
|
|
603
|
+
# (a remote program never changes it — only the ssh client dying does).
|
|
604
|
+
# Bail fast instead of waiting out the whole timeout; completion is unknown.
|
|
605
|
+
if ! pane_is_ssh; then
|
|
606
|
+
# The remote wrapper may have printed DONE immediately before an `exit`
|
|
607
|
+
# closed ssh. Re-capture once and prefer that authoritative exact marker
|
|
608
|
+
# over a false completion-unknown classification.
|
|
609
|
+
buf=$(capture)
|
|
610
|
+
if tssh_has_done "$n" "$buf"; then found=1; break; fi
|
|
611
|
+
emit_output "$buf" "$n"
|
|
612
|
+
clear_watch
|
|
613
|
+
log "connection to $HOST dropped mid-command (ssh exited) — completion unknown; NOT retried"
|
|
614
|
+
return 125
|
|
615
|
+
fi
|
|
616
|
+
if [ "$permit_actions" = "true" ]; then
|
|
617
|
+
EXPECT_SEND_FAILED=0; handle_expects "$buf" "$n"
|
|
618
|
+
if [ "$EXPECT_SEND_FAILED" != 0 ]; then emit_output "$(capture)" "$n"; log "--expect response failed; watch kept, observation stopped"; return 3; fi
|
|
619
|
+
fi
|
|
620
|
+
sleep 0.2
|
|
621
|
+
done
|
|
622
|
+
if [ "$found" -ne 1 ]; then
|
|
623
|
+
buf=$(capture)
|
|
624
|
+
# A completion at the deadline is still authoritative; do not turn it
|
|
625
|
+
# into a timeout merely because it arrived between loop iterations.
|
|
626
|
+
if tssh_has_done "$n" "$buf"; then found=1
|
|
627
|
+
else
|
|
628
|
+
emit_output "$buf" "$n"
|
|
629
|
+
if [ "$permit_actions" = "true" ] && [ "$INTERRUPT_ON_TIMEOUT" = "true" ]; then
|
|
630
|
+
tmux -L "$SOCKET" send-keys -t "$PTGT" C-c
|
|
631
|
+
log "command timed out after ${CMD_TIMEOUT}s — sent Ctrl-C; collect the result with: $SCRIPT_NAME $HOST --resume"
|
|
632
|
+
else
|
|
633
|
+
log "command timed out after ${CMD_TIMEOUT}s — still running remotely, or waiting for input. Next steps:"
|
|
634
|
+
log " $SCRIPT_NAME $HOST --read see the screen"
|
|
635
|
+
log " $SCRIPT_NAME $HOST --resume [--timeout N] keep waiting"
|
|
636
|
+
log " $SCRIPT_NAME $HOST --send --key C-c interrupt it, then --resume to collect"
|
|
637
|
+
log " $SCRIPT_NAME $HOST --abort Ctrl-C and clear the watch"
|
|
638
|
+
fi
|
|
639
|
+
return 124
|
|
640
|
+
fi
|
|
641
|
+
fi
|
|
642
|
+
emit_output "$buf" "$n"
|
|
643
|
+
local rc; rc=$(printf '%s\n' "$buf" | grep -E "^TSSHDONE_${n}_ rc=[0-9]+" | tail -1 | sed 's/.*rc=//')
|
|
644
|
+
if [ "$permit_actions" = "true" ] && [ "${#EXPECT_PATS[@]}" -gt 0 ]; then
|
|
645
|
+
local xi
|
|
646
|
+
for xi in "${!EXPECT_PATS[@]}"; do
|
|
647
|
+
[ "${EXPECT_FIRED[$xi]:-0}" -eq 1 ] || log "warning: --expect pattern '${EXPECT_PATS[$xi]}' never matched; its response was NOT sent"
|
|
648
|
+
done
|
|
649
|
+
fi
|
|
650
|
+
clear_watch
|
|
651
|
+
[ -n "$rc" ] || rc=125
|
|
652
|
+
return "$rc"
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# ---- run one command, retaining its nonce if observation times out ---------
|
|
656
|
+
run_command() {
|
|
657
|
+
local cmd="$1" n
|
|
658
|
+
# [SECURITY] explicit pane gate, not just reliance
|
|
659
|
+
# on build_wrapper's inline `hostname == EXPECTED_HOSTNAME` guard -- cheap,
|
|
660
|
+
# and consistent with every other send site in this file.
|
|
661
|
+
if ! pane_is_ssh; then
|
|
662
|
+
log "run_command: pane is not ssh (connection not confirmed) -- refusing to send"
|
|
663
|
+
return 125
|
|
664
|
+
fi
|
|
665
|
+
n=$(nonce)
|
|
666
|
+
# The guard is armed before the first keystroke and kept until the watch is published, so there is never a moment
|
|
667
|
+
# where the command may be on the remote input with neither a guard nor a watch recording it.
|
|
668
|
+
local grc=0; GUARD_KEEP=1 guarded_line run "$(build_wrapper "$n" "$cmd")" || grc=$?
|
|
669
|
+
[ "$grc" = 0 ] || return "$grc"
|
|
670
|
+
if ! write_watch "$n"; then
|
|
671
|
+
guard_mark run watch-publish 0
|
|
672
|
+
log "🔴 SUBMITTED, BUT THE WATCH COULD NOT BE WRITTEN ($WATCH_FILE): the command is running on $HOST unobserved; the host is guarded. Read it with --read; clear with --kill. Do NOT blindly retry."
|
|
673
|
+
return 3
|
|
674
|
+
fi
|
|
675
|
+
guard_clear
|
|
676
|
+
observe_watch "$n" true
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
cmd_resume() {
|
|
680
|
+
read_watch || die "saved command watch is invalid; use --kill before starting another command" 125
|
|
681
|
+
[ -n "$WATCH_NONCE" ] || die "no timed-out command watch for $HOST (nothing was sent)" 125
|
|
682
|
+
if [ "$TIMEOUT_GIVEN" != true ] && [ -n "$WATCH_TIMEOUT" ] && [ "$WATCH_TIMEOUT" -ge 1 ] && [ "$WATCH_TIMEOUT" -le 2700 ]; then
|
|
683
|
+
CMD_TIMEOUT="$WATCH_TIMEOUT"; log "resume: waiting up to ${CMD_TIMEOUT}s (the original command's --timeout; override with --timeout N)"
|
|
684
|
+
fi
|
|
685
|
+
# Never ensure/reconnect here: a remote program may own its TTY. This is a
|
|
686
|
+
# capture-pane-only observer and reports a dead pane as unknown.
|
|
687
|
+
observe_watch "$WATCH_NONCE" false
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
# ---- interactive primitives (Tier A) ---------------------------------------
|
|
691
|
+
# send raw keystrokes into the live session. Gate-1 only (a program may own
|
|
692
|
+
# stdin, so the identity probe can't run); refuses on a dead session because a
|
|
693
|
+
# reconnect would silently discard interactive state.
|
|
694
|
+
#
|
|
695
|
+
# [SECURITY] Handshake-gated on EVERY send, not just once at function entry: is_connected() (live pane, foreground
|
|
696
|
+
# ssh) is re-run immediately before the send-keys call below and again immediately after. ANY mismatch REFUSES.
|
|
697
|
+
# The session is NOT killed: a dead pane executes nothing (ssh is the pane's process), and keeping it lets --read
|
|
698
|
+
# show the last screen (the denial, or the output before the drop). --kill or the next connect clears it.
|
|
699
|
+
refuse_send() { # pre-send only: nothing was transmitted
|
|
700
|
+
log "🔴 REFUSING send (pre-send): the ssh connection to $HOST is not live (pane dead or not ssh). Nothing was sent. Read its last screen with: $SCRIPT_NAME $HOST --read ; clear it with --kill"
|
|
701
|
+
}
|
|
702
|
+
cmd_send() {
|
|
703
|
+
# Pre-send gate: checked immediately before transmitting, not carried over
|
|
704
|
+
# from an earlier check — this is what "--send does not auto-reconnect"
|
|
705
|
+
# protects: a dead/dropped session is refused, never reconnected under a running interactive program.
|
|
706
|
+
if ! is_connected; then
|
|
707
|
+
refuse_send
|
|
708
|
+
return 125
|
|
709
|
+
fi
|
|
710
|
+
guard_arm send || { log "🔴 REFUSED (pre-send): could not arm the input guard in $REF_DIR. Nothing was sent."; return 125; }
|
|
711
|
+
local step="" nrc=0 nerr=""
|
|
712
|
+
if [ -n "$SEND_KEY" ]; then
|
|
713
|
+
nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" "$SEND_KEY" 2>&1 >/dev/null); nrc=$?; step=key # named key (NOT literal)
|
|
714
|
+
else
|
|
715
|
+
nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" -l "$COMMAND" 2>&1 >/dev/null); nrc=$?; step=literal
|
|
716
|
+
if [ "$nrc" = 0 ] && [ "$SEND_ENTER" = "true" ]; then nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" Enter 2>&1 >/dev/null); nrc=$?; step=enter; fi
|
|
717
|
+
fi
|
|
718
|
+
if [ "$nrc" != 0 ]; then
|
|
719
|
+
guard_mark send "$step" "$nrc"
|
|
720
|
+
log "🔴 INPUT UNKNOWN (send): tmux $step failed (native rc $nrc: ${nerr:-no message}). What reached $HOST's input is unknown; the host is guarded until --kill. Do NOT blindly retry."
|
|
721
|
+
return 3
|
|
722
|
+
fi
|
|
723
|
+
if [ -n "$SEND_KEY" ]; then log "sent key: $SEND_KEY"; else log "sent: ${COMMAND}$([ "$SEND_ENTER" = "true" ] && echo ' <Enter>')"; fi
|
|
724
|
+
# The keys WERE transmitted; if the connection is gone right after, they may or may not have reached the remote
|
|
725
|
+
# (nothing local can read them: ssh is the pane's process). Distinct rc 3 and wording, because unlike the
|
|
726
|
+
# pre-send 125 a blind retry could repeat the action.
|
|
727
|
+
if ! is_connected; then
|
|
728
|
+
guard_mark send post-send-drop 0
|
|
729
|
+
log "🔴 SENT, THEN THE CONNECTION DROPPED (post-send): the keys were sent to $HOST, then the ssh connection was lost. Remote effect UNKNOWN: check with $SCRIPT_NAME $HOST --read (last screen) and do NOT blindly retry; the host is guarded until --kill."
|
|
730
|
+
return 3
|
|
731
|
+
fi
|
|
732
|
+
guard_clear
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
# read the current screen (read-only; safe even on a dead session, warns if so).
|
|
736
|
+
cmd_read() {
|
|
737
|
+
if [ "$SETTLE" = "true" ]; then
|
|
738
|
+
local prev="" cur stable=0 k
|
|
739
|
+
for k in $(seq 1 150); do
|
|
740
|
+
cur=$(capture | cksum)
|
|
741
|
+
if [ "$cur" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi
|
|
742
|
+
[ "$stable" -ge 3 ] && break
|
|
743
|
+
prev="$cur"; sleep 0.2
|
|
744
|
+
done
|
|
745
|
+
fi
|
|
746
|
+
read_ref
|
|
747
|
+
is_connected || log "note: the ssh connection is not live (dead pane or no session) — this is the last screen"
|
|
748
|
+
# hide tmux-ssh's own wrapper lines (sentinels and the echoed wrapper); keep everything the remote printed
|
|
749
|
+
capture | { grep -v -e 'TSSHBEGIN_' -e '^TSSHDONE_' -e '^TSSHHOST_' || true; } | awk '{ L[NR]=$0 } END { last=NR; while (last>0 && L[last] ~ /^[[:space:]]*$/) last--; for (i=1;i<=last;i++) print L[i] }' | tail -n "$LINES"
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
# ---- subcommands -----------------------------------------------------------
|
|
753
|
+
cmd_status() {
|
|
754
|
+
read_ref
|
|
755
|
+
local buf=""
|
|
756
|
+
read_watch || printf 'active watch: INVALID (use --kill before starting another command)\n'
|
|
757
|
+
has_session && buf=$(capture)
|
|
758
|
+
printf 'host: %s\n' "$HOST"
|
|
759
|
+
printf 'socket/session: %s / %s\n' "$SOCKET" "$SESSION"
|
|
760
|
+
printf 'ref file: %s%s\n' "$REF_FILE" "$([ -f "$REF_FILE" ] && echo '' || echo ' (absent)')"
|
|
761
|
+
printf 'recorded hostname: %s\n' "${EXPECTED_HOSTNAME:-<none>}"
|
|
762
|
+
printf 'tmux session: %s\n' "$(has_session && echo present || echo ABSENT)"
|
|
763
|
+
printf 'pane foreground: %s\n' "$(pane_cmd || echo '<none>')"
|
|
764
|
+
printf 'input guard: %s\n' "$(guard_present && guard_summary || echo none)"
|
|
765
|
+
# Status is an observer: it must be safe to run while the remote TTY is
|
|
766
|
+
# owned by a command, so it deliberately never calls a probe or send-keys.
|
|
767
|
+
# pane==ssh and the recorded hostname are local observations, not a fresh
|
|
768
|
+
# identity check. Command execution still uses ensure_connected's probes.
|
|
769
|
+
# A DONE may have appeared immediately before the remote command closed ssh;
|
|
770
|
+
# the saved exact nonce remains authoritative even though pane==ssh is false.
|
|
771
|
+
if [ -n "$WATCH_NONCE" ] && tssh_has_done "$WATCH_NONCE" "$buf"; then
|
|
772
|
+
printf 'identity probe: NOT RUN (observer mode)\n'
|
|
773
|
+
printf 'active watch: complete (run --resume to collect rc/output)\n'
|
|
774
|
+
printf 'remote cwd: not queried (observer mode)\n'
|
|
775
|
+
printf 'verdict: COMPLETED — exact saved marker observed\n'
|
|
776
|
+
return 0
|
|
777
|
+
fi
|
|
778
|
+
if is_connected; then
|
|
779
|
+
if [ -n "$EXPECTED_HOSTNAME" ]; then
|
|
780
|
+
printf 'identity probe: NOT RUN (observer mode; recorded=%s)\n' "$EXPECTED_HOSTNAME"
|
|
781
|
+
else
|
|
782
|
+
printf 'identity probe: NOT RUN (observer mode; no recorded hostname)\n'
|
|
783
|
+
fi
|
|
784
|
+
if [ -n "$WATCH_NONCE" ] && tssh_has_done "$WATCH_NONCE" "$buf"; then
|
|
785
|
+
printf 'active watch: complete (run --resume to collect rc/output)\n'
|
|
786
|
+
elif [ -n "$WATCH_NONCE" ]; then
|
|
787
|
+
printf 'active watch: running or no exact completion marker observed\n'
|
|
788
|
+
else
|
|
789
|
+
printf 'active watch: none\n'
|
|
790
|
+
fi
|
|
791
|
+
printf 'remote cwd: not queried (observer mode)\n'
|
|
792
|
+
printf 'verdict: OBSERVED — ssh pane present; identity not reverified\n'
|
|
793
|
+
return 0
|
|
794
|
+
fi
|
|
795
|
+
printf 'not connected\n'
|
|
796
|
+
printf 'verdict: NOT connected (a command would need to (re)connect first)\n'
|
|
797
|
+
return 0
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
# A failed inventory proves absence ONLY when no server can exist: no socket file (ENOENT) or nothing listening
|
|
801
|
+
# (ECONNREFUSED; older tmux says "no server running"). Permission/other connect errors are UNKNOWN: the server and
|
|
802
|
+
# the session may be alive behind them.
|
|
803
|
+
no_server_proof() {
|
|
804
|
+
case "$1" in
|
|
805
|
+
*"no server running"*|*"error connecting to "*"(No such file or directory)"*|*"error connecting to "*"(Connection refused)"*) return 0 ;;
|
|
806
|
+
esac
|
|
807
|
+
return 1
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
cmd_kill() { # caller holds the host lock. Custody (ref, watch, guard) is cleared ONLY after the exact session was
|
|
811
|
+
# killed, or proven absent by a complete inventory; any uncertainty keeps all of it.
|
|
812
|
+
local q qerr inv irc
|
|
813
|
+
qerr=$(tmux -L "$SOCKET" has-session -t "$STGT" 2>&1 >/dev/null); q=$?
|
|
814
|
+
if [ "$q" = 0 ]; then
|
|
815
|
+
if ! qerr=$(tmux -L "$SOCKET" kill-session -t "$STGT" 2>&1 >/dev/null); then
|
|
816
|
+
log "kill-session FAILED (${qerr:-no message}): session $SESSION may still be alive; ref, watch and input guard KEPT"; return 125
|
|
817
|
+
fi
|
|
818
|
+
log "killed session $SESSION (remote processes started in that session keep running)"
|
|
819
|
+
else
|
|
820
|
+
inv=$(tmux -L "$SOCKET" list-sessions -F '#{session_name}' 2>&1); irc=$?
|
|
821
|
+
if [ "$irc" = 0 ] && ! printf '%s\n' "$inv" | grep -Fx -- "$SESSION" >/dev/null; then
|
|
822
|
+
log "no session to kill ($SESSION absent from the inventory)"
|
|
823
|
+
elif [ "$irc" != 0 ] && no_server_proof "$inv"; then
|
|
824
|
+
log "no session to kill (no tmux-ssh server: ${inv})"
|
|
825
|
+
else
|
|
826
|
+
log "cannot verify $SESSION is gone (has-session rc $q: ${qerr:-no message}; inventory rc $irc): ref, watch and input guard KEPT"; return 125
|
|
827
|
+
fi
|
|
828
|
+
fi
|
|
829
|
+
[ -f "$REF_FILE" ] && rm -f "$REF_FILE" && log "removed $REF_FILE"
|
|
830
|
+
clear_watch
|
|
831
|
+
guard_present && guard_clear && log "cleared the input guard"
|
|
832
|
+
return 0
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
# --abort: Ctrl-C the watched command, wait briefly for its DONE marker, then clear the watch. Typing goes through the
|
|
836
|
+
# same input guard and gate-1 (live ssh pane) checks as --send; with an unresolved guard nothing is typed.
|
|
837
|
+
cmd_abort() { # caller holds the host lock
|
|
838
|
+
if guard_present; then
|
|
839
|
+
die "cannot abort: UNRESOLVED input on $HOST ($(guard_summary | sed 's/ $//')); nothing is typed while that is unresolved. Inspect with $SCRIPT_NAME $HOST --read; then clear it with $SCRIPT_NAME $HOST --kill" 125
|
|
840
|
+
fi
|
|
841
|
+
read_watch || die "saved command watch is invalid; use $SCRIPT_NAME $HOST --kill" 125
|
|
842
|
+
if [ -z "$WATCH_NONCE" ]; then
|
|
843
|
+
log "no command is being watched on $HOST; nothing to abort (a program left open on the screen: --read, then --send --key C-c)"
|
|
844
|
+
return 0
|
|
845
|
+
fi
|
|
846
|
+
local n="$WATCH_NONCE" buf rc
|
|
847
|
+
buf=$(capture)
|
|
848
|
+
if tssh_has_done "$n" "$buf"; then
|
|
849
|
+
rc=$(printf '%s\n' "$buf" | grep -E "^TSSHDONE_${n}_ rc=[0-9]+" | tail -1 | sed 's/.*rc=//')
|
|
850
|
+
clear_watch
|
|
851
|
+
log "the command had already finished (rc=${rc:-?}); watch cleared, nothing sent"
|
|
852
|
+
return 0
|
|
853
|
+
fi
|
|
854
|
+
if ! is_connected; then
|
|
855
|
+
clear_watch
|
|
856
|
+
log "the ssh connection to $HOST is not live, so the command is gone with it; nothing was sent; watch cleared"
|
|
857
|
+
return 0
|
|
858
|
+
fi
|
|
859
|
+
guard_arm abort || { log "🔴 REFUSED (abort): could not arm the input guard in $REF_DIR. Nothing was sent; the watch is kept. Use $SCRIPT_NAME $HOST --kill"; return 125; }
|
|
860
|
+
local nerr nrc
|
|
861
|
+
nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" C-c 2>&1 >/dev/null); nrc=$?
|
|
862
|
+
if [ "$nrc" != 0 ]; then
|
|
863
|
+
guard_mark abort key "$nrc"
|
|
864
|
+
log "🔴 INPUT UNKNOWN (abort): tmux key failed (native rc $nrc: ${nerr:-no message}). The host is guarded until $SCRIPT_NAME $HOST --kill. Do NOT blindly retry."
|
|
865
|
+
return 3
|
|
866
|
+
fi
|
|
867
|
+
guard_clear
|
|
868
|
+
log "sent Ctrl-C to $HOST; waiting up to ${ABORT_WAIT}s for the command to end"
|
|
869
|
+
local deadline=$(( $(date +%s) + ABORT_WAIT ))
|
|
870
|
+
while :; do
|
|
871
|
+
buf=$(capture)
|
|
872
|
+
if tssh_has_done "$n" "$buf"; then
|
|
873
|
+
rc=$(printf '%s\n' "$buf" | grep -E "^TSSHDONE_${n}_ rc=[0-9]+" | tail -1 | sed 's/.*rc=//')
|
|
874
|
+
clear_watch
|
|
875
|
+
log "aborted: the command ended (rc=${rc:-?}); watch cleared, the next command can run"
|
|
876
|
+
return 0
|
|
877
|
+
fi
|
|
878
|
+
[ "$(date +%s)" -lt "$deadline" ] || break
|
|
879
|
+
sleep 0.2
|
|
880
|
+
done
|
|
881
|
+
clear_watch
|
|
882
|
+
# no marker: shells such as zsh drop the whole line on Ctrl-C. If the shell now answers a probe, the abort worked.
|
|
883
|
+
read_ref
|
|
884
|
+
local h prc=0
|
|
885
|
+
h=$(probe_hostname 8) || prc=$?
|
|
886
|
+
[ "$prc" = 3 ] && return 3
|
|
887
|
+
if [ "$prc" = 0 ] && [ -n "$h" ] && { [ -z "$EXPECTED_HOSTNAME" ] || [ "$h" = "$EXPECTED_HOSTNAME" ]; }; then
|
|
888
|
+
log "aborted: the command was interrupted and the shell on $HOST is ready; the next command can run"
|
|
889
|
+
return 0
|
|
890
|
+
fi
|
|
891
|
+
log "Ctrl-C was sent but the shell did not answer within ${ABORT_WAIT}s+8s (the program may ignore Ctrl-C). Watch cleared. Look with $SCRIPT_NAME $HOST --read; if the screen is still occupied use --send --key (e.g. q, Escape, C-d), or $SCRIPT_NAME $HOST --kill"
|
|
892
|
+
return 1
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
cmd_attach() {
|
|
896
|
+
has_session || die "no session for $HOST to attach to (run: $SCRIPT_NAME $HOST)" 125
|
|
897
|
+
exec tmux -L "$SOCKET" attach -t "$STGT"
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
# ---- expect rule parsing ---------------------------------------------------
|
|
901
|
+
# spec is 'PATTERN=RESPONSE' for --expect, or 'PATTERN=@file' / 'PATTERN=env:VAR'
|
|
902
|
+
# for --expect-secret. Splits on the FIRST '='.
|
|
903
|
+
add_expect() {
|
|
904
|
+
local spec="$1" secret="$2" pat resp src
|
|
905
|
+
case "$spec" in *=*) : ;; *) die "--expect needs 'PATTERN=RESPONSE' (got '$spec')" 2 ;; esac
|
|
906
|
+
pat="${spec%%=*}"; src="${spec#*=}"
|
|
907
|
+
[ -n "$pat" ] || die "--expect pattern must be non-empty" 2
|
|
908
|
+
# Single-line contract: a newline or carriage return in PATTERN is not a supported
|
|
909
|
+
# shape (matching is a literal substring test on the screen text). Refuse here, at
|
|
910
|
+
# argument parsing, so no tmux/ssh call has happened yet. Only PATTERN is checked.
|
|
911
|
+
case "$pat" in
|
|
912
|
+
*$'\n'* | *$'\r'*) die "--expect/--expect-secret PATTERN must be a single line (no newline or carriage return)" 2 ;;
|
|
913
|
+
esac
|
|
914
|
+
if [ "$secret" = "true" ]; then
|
|
915
|
+
case "$src" in
|
|
916
|
+
@*) resp=$(cat "${src#@}" 2>/dev/null) || die "--expect-secret: cannot read file '${src#@}'" 2 ;;
|
|
917
|
+
env:*) local v="${src#env:}"; resp="${!v:-}"; [ -n "$resp" ] || die "--expect-secret: env var '$v' is empty/unset" 2 ;;
|
|
918
|
+
*) die "--expect-secret source must be '@file' or 'env:VAR' (got '$src')" 2 ;;
|
|
919
|
+
esac
|
|
920
|
+
EXPECT_SECRET+=("1")
|
|
921
|
+
else
|
|
922
|
+
resp="$src"
|
|
923
|
+
EXPECT_SECRET+=("0")
|
|
924
|
+
fi
|
|
925
|
+
EXPECT_PATS+=("$pat")
|
|
926
|
+
EXPECT_RESPS+=("$resp")
|
|
927
|
+
EXPECT_FIRED+=("0")
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
# ---- argument parsing ------------------------------------------------------
|
|
931
|
+
HOST=""
|
|
932
|
+
COMMAND=""
|
|
933
|
+
MODE="ensure" # ensure | run | resume | send | read | status | kill | attach
|
|
934
|
+
have_command=false
|
|
935
|
+
RESUME_REQUESTED=false
|
|
936
|
+
|
|
937
|
+
while [ $# -gt 0 ]; do
|
|
938
|
+
case "$1" in
|
|
939
|
+
-h|--help) print_help; exit 0 ;;
|
|
940
|
+
--status) MODE="status"; shift ;;
|
|
941
|
+
--resume) MODE="resume"; RESUME_REQUESTED=true; shift ;;
|
|
942
|
+
--kill) MODE="kill"; shift ;;
|
|
943
|
+
--abort) MODE="abort"; shift ;;
|
|
944
|
+
--force-run) FORCE_RUN=true; shift ;;
|
|
945
|
+
--attach) MODE="attach"; shift ;;
|
|
946
|
+
--send) MODE="send"; shift ;;
|
|
947
|
+
--read) MODE="read"; shift ;;
|
|
948
|
+
--no-reconnect) ALLOW_RECONNECT=false; shift ;;
|
|
949
|
+
--interrupt-on-timeout) INTERRUPT_ON_TIMEOUT=true; shift ;;
|
|
950
|
+
--no-enter) SEND_ENTER=false; shift ;;
|
|
951
|
+
--settle) SETTLE=true; shift ;;
|
|
952
|
+
--key) SEND_KEY="${2:-}"; shift 2 ;;
|
|
953
|
+
--timeout) CMD_TIMEOUT="${2:-}"; TIMEOUT_GIVEN=true; shift 2 ;;
|
|
954
|
+
--lines) LINES="${2:-}"; shift 2 ;;
|
|
955
|
+
--connect-timeout) CONNECT_TIMEOUT="${2:-}"; shift 2 ;;
|
|
956
|
+
--expect) add_expect "${2:-}" false; shift 2 ;;
|
|
957
|
+
--expect-secret) add_expect "${2:-}" true; shift 2 ;;
|
|
958
|
+
--expect-host) EXPECT_HOST="${2:-}"; shift 2 ;;
|
|
959
|
+
--) shift; break ;;
|
|
960
|
+
-*) die "unknown flag: $1" 2 ;;
|
|
961
|
+
*)
|
|
962
|
+
if [ -z "$HOST" ]; then HOST="$1"
|
|
963
|
+
elif [ "$have_command" = false ]; then COMMAND="$1"; have_command=true
|
|
964
|
+
else die "unexpected extra argument: $1 (quote the command as a single argument)" 2
|
|
965
|
+
fi
|
|
966
|
+
shift ;;
|
|
967
|
+
esac
|
|
968
|
+
done
|
|
969
|
+
# Anything after `--` is the command (allows unquoted commands with flags).
|
|
970
|
+
if [ $# -gt 0 ]; then
|
|
971
|
+
if [ "$have_command" = false ]; then COMMAND="$*"; have_command=true
|
|
972
|
+
else die "command already given; unexpected: $*" 2; fi
|
|
973
|
+
fi
|
|
974
|
+
|
|
975
|
+
[ -n "$HOST" ] || { usage; exit 2; }
|
|
976
|
+
# [SECURITY] $HOST becomes an ssh argument (and a session/ref name). A legitimate ssh target is an alias or
|
|
977
|
+
# [user@]host: letters, digits, dot, underscore, hyphen, and one '@', not starting with '-' (it would be read as an
|
|
978
|
+
# ssh option). (IPv6 literals with ':' are intentionally unsupported — use an ssh-config alias.)
|
|
979
|
+
case "$HOST" in
|
|
980
|
+
-*) die "invalid host '$HOST' — a host may not start with '-'" 2 ;;
|
|
981
|
+
*[!A-Za-z0-9._@-]*) die "invalid host '$HOST' — allowed characters: letters, digits, . _ - @ (use an ssh-config alias for anything else)" 2 ;;
|
|
982
|
+
esac
|
|
983
|
+
# TMUX_SSH_OPTS becomes ssh arguments (split on spaces): refuse anything outside a safe set
|
|
984
|
+
# (C locale so the ranges mean ASCII). --status and --kill never type it.
|
|
985
|
+
if [ "$MODE" != "kill" ] && [ "$MODE" != "status" ]; then
|
|
986
|
+
_lc_all_saved="${LC_ALL-}"; LC_ALL=C
|
|
987
|
+
case "${TMUX_SSH_OPTS:-}" in
|
|
988
|
+
*[!A-Za-z0-9\ _./=:,@~+%-]*)
|
|
989
|
+
die "TMUX_SSH_OPTS contains a disallowed character (allowed: letters digits space - _ . / = : , @ ~ + %)" 2 ;;
|
|
990
|
+
esac
|
|
991
|
+
if [ -n "$_lc_all_saved" ]; then LC_ALL="$_lc_all_saved"; else unset LC_ALL; fi
|
|
992
|
+
# every token must be an option: a bare word would become ssh's destination (and the real host the remote
|
|
993
|
+
# command). An ssh option that takes a value (-o X, -i F, -p N ...) consumes the next token as that value.
|
|
994
|
+
_want_val=0
|
|
995
|
+
for _t in ${TMUX_SSH_OPTS:-}; do
|
|
996
|
+
if [ "$_want_val" = 1 ]; then _want_val=0; continue; fi
|
|
997
|
+
case "$_t" in
|
|
998
|
+
-[BbcDEeFIiJLlmOoPpQRSWw]) _want_val=1 ;;
|
|
999
|
+
-?*) ;;
|
|
1000
|
+
*) die "TMUX_SSH_OPTS token '$_t' is not an ssh option (each token must start with '-', or be the value of the option before it)" 2 ;;
|
|
1001
|
+
esac
|
|
1002
|
+
done
|
|
1003
|
+
[ "$_want_val" = 0 ] || die "TMUX_SSH_OPTS ends with an option that needs a value" 2
|
|
1004
|
+
fi
|
|
1005
|
+
if [ -n "$EXPECT_HOST" ]; then
|
|
1006
|
+
case "$EXPECT_HOST" in
|
|
1007
|
+
*[!A-Za-z0-9._-]*) die "--expect-host/TMUX_SSH_EXPECT_HOST must contain only letters, digits, . _ - (got '$EXPECT_HOST')" 2 ;;
|
|
1008
|
+
esac
|
|
1009
|
+
fi
|
|
1010
|
+
for v in CMD_TIMEOUT CONNECT_TIMEOUT LINES; do
|
|
1011
|
+
val="${!v}"
|
|
1012
|
+
case "$val" in ''|*[!0-9]*) die "$v must be a non-negative integer (got '$val')" 2 ;; esac
|
|
1013
|
+
done
|
|
1014
|
+
if [ "$CMD_TIMEOUT" -lt 1 ] || [ "$CMD_TIMEOUT" -gt 2700 ]; then
|
|
1015
|
+
die "--timeout must be between 1 and 2700 seconds (got $CMD_TIMEOUT)" 2
|
|
1016
|
+
fi
|
|
1017
|
+
if [ "$RESUME_REQUESTED" = "true" ] && [ "$MODE" != "resume" ]; then
|
|
1018
|
+
die "--resume cannot be combined with another mode" 2
|
|
1019
|
+
fi
|
|
1020
|
+
if [ "$MODE" = "resume" ]; then
|
|
1021
|
+
[ "$INTERRUPT_ON_TIMEOUT" != "true" ] || die "--resume is capture-only; --interrupt-on-timeout is not allowed" 2
|
|
1022
|
+
[ "${#EXPECT_PATS[@]}" -eq 0 ] || die "--resume is capture-only; --expect/--expect-secret are not allowed" 2
|
|
1023
|
+
[ -z "$SEND_KEY" ] && [ "$SEND_ENTER" = "true" ] || die "--resume is capture-only; send flags are not allowed" 2
|
|
1024
|
+
[ "$have_command" = false ] || die "--resume does not accept a command; use it only to observe the saved watch" 2
|
|
1025
|
+
fi
|
|
1026
|
+
if [ "${#EXPECT_PATS[@]}" -gt 0 ] && { [ "$MODE" = "send" ] || [ "$MODE" = "read" ] || [ "$MODE" = "status" ] || [ "$MODE" = "kill" ] || [ "$MODE" = "attach" ] || [ "$MODE" = "abort" ]; }; then
|
|
1027
|
+
die "--expect/--expect-secret only apply when running a command" 2
|
|
1028
|
+
fi
|
|
1029
|
+
if [ "$MODE" = "abort" ] && [ "$have_command" = true ]; then
|
|
1030
|
+
die "--abort does not accept a command" 2
|
|
1031
|
+
fi
|
|
1032
|
+
if [ "$MODE" = "send" ] && [ "$have_command" = false ] && [ -z "$SEND_KEY" ]; then
|
|
1033
|
+
die "--send needs text (or --key <name>)" 2
|
|
1034
|
+
fi
|
|
1035
|
+
|
|
1036
|
+
# ---- refuse interactive commands in run mode (before anything is sent) ----------
|
|
1037
|
+
# Looks ONLY at the leading command (after optional sudo / env VAR=x / exec / VAR=x), never at pipelines or later
|
|
1038
|
+
# commands. Prints the interactive form and returns 0 when the command would never finish; 1 otherwise.
|
|
1039
|
+
interactive_hint() {
|
|
1040
|
+
local -a w; local i=0 t base sub
|
|
1041
|
+
read -r -a w <<< "$1"
|
|
1042
|
+
[ "${#w[@]}" -gt 0 ] || return 1
|
|
1043
|
+
while [ "$i" -lt "${#w[@]}" ]; do
|
|
1044
|
+
t="${w[$i]}"
|
|
1045
|
+
case "$t" in
|
|
1046
|
+
sudo) i=$((i+1))
|
|
1047
|
+
while [ "$i" -lt "${#w[@]}" ]; do
|
|
1048
|
+
case "${w[$i]}" in
|
|
1049
|
+
-[ughprCrtTU]) i=$((i+2)) ;;
|
|
1050
|
+
-*) i=$((i+1)) ;;
|
|
1051
|
+
*=*) i=$((i+1)) ;;
|
|
1052
|
+
*) break ;;
|
|
1053
|
+
esac
|
|
1054
|
+
done ;;
|
|
1055
|
+
env) i=$((i+1))
|
|
1056
|
+
while [ "$i" -lt "${#w[@]}" ]; do
|
|
1057
|
+
case "${w[$i]}" in -*|*=*) i=$((i+1)) ;; *) break ;; esac
|
|
1058
|
+
done ;;
|
|
1059
|
+
exec) i=$((i+1)) ;;
|
|
1060
|
+
*=*) i=$((i+1)) ;;
|
|
1061
|
+
*) break ;;
|
|
1062
|
+
esac
|
|
1063
|
+
done
|
|
1064
|
+
[ "$i" -lt "${#w[@]}" ] || return 1
|
|
1065
|
+
base="${w[$i]##*/}"
|
|
1066
|
+
case "$base" in
|
|
1067
|
+
vim|vi|nvim|nano|emacs|top|htop|less|more|man|watch)
|
|
1068
|
+
INTERACTIVE_MSG="'$base' stays open and never finishes in run mode. Drive it instead: $SCRIPT_NAME $HOST --send \"$base ...\" ; $SCRIPT_NAME $HOST --read --settle ; $SCRIPT_NAME $HOST --send --key Escape (or q / C-c). To run it anyway add --force-run."
|
|
1069
|
+
return 0 ;;
|
|
1070
|
+
tmux)
|
|
1071
|
+
i=$((i+1)); sub=""
|
|
1072
|
+
while [ "$i" -lt "${#w[@]}" ]; do
|
|
1073
|
+
case "${w[$i]}" in
|
|
1074
|
+
-[LSfcT]) i=$((i+2)) ;;
|
|
1075
|
+
-*) i=$((i+1)) ;;
|
|
1076
|
+
*) sub="${w[$i]}"; break ;;
|
|
1077
|
+
esac
|
|
1078
|
+
done
|
|
1079
|
+
case "$sub" in
|
|
1080
|
+
'') INTERACTIVE_MSG="bare 'tmux' opens an interactive session and never finishes in run mode. Use: $SCRIPT_NAME $HOST \"tmux new -d -s NAME 'cmd'\" (detached), or drive it with --send / --read. To run it anyway add --force-run."
|
|
1081
|
+
return 0 ;;
|
|
1082
|
+
attach|attach-session|a|at)
|
|
1083
|
+
INTERACTIVE_MSG="'tmux $sub' attaches and never finishes in run mode. Drive the attached program with $SCRIPT_NAME $HOST --send / --read, or start work detached: $SCRIPT_NAME $HOST \"tmux new -d -s NAME 'cmd'\". To run it anyway add --force-run."
|
|
1084
|
+
return 0 ;;
|
|
1085
|
+
new|new-session)
|
|
1086
|
+
local k detached=false
|
|
1087
|
+
for k in "${w[@]:$((i+1))}"; do
|
|
1088
|
+
case "$k" in -[A-Za-z]*) case "${k#-}" in *[!A-Za-z]*) ;; *d*) detached=true ;; esac ;; esac
|
|
1089
|
+
done
|
|
1090
|
+
if [ "$detached" = false ]; then
|
|
1091
|
+
INTERACTIVE_MSG="'tmux $sub' without -d attaches and never finishes in run mode. Use: $SCRIPT_NAME $HOST \"tmux new -d -s NAME 'cmd'\" (detached), or drive it with --send / --read. To run it anyway add --force-run."
|
|
1092
|
+
return 0
|
|
1093
|
+
fi ;;
|
|
1094
|
+
esac ;;
|
|
1095
|
+
esac
|
|
1096
|
+
return 1
|
|
1097
|
+
}
|
|
1098
|
+
INTERACTIVE_MSG=""
|
|
1099
|
+
if [ "$MODE" = "ensure" ] && [ "$have_command" = true ] && [ "$FORCE_RUN" != true ]; then
|
|
1100
|
+
if interactive_hint "$COMMAND"; then die "$INTERACTIVE_MSG" 2; fi
|
|
1101
|
+
fi
|
|
1102
|
+
|
|
1103
|
+
# One tmux send-keys carries about 16,300 bytes (measured on tmux 3.6a: 16,300 passes, 16,340 fails with "failed to send
|
|
1104
|
+
# command", 16,360+ with "command too long"; the session target takes part of that). A bigger command used to fail AFTER
|
|
1105
|
+
# arming the input guard (rc 3, INPUT UNKNOWN). Refuse it up front, before any tmux call. Run mode adds the DONE wrapper.
|
|
1106
|
+
# TMUX_SSH_MAX_BYTES overrides the limit (test seam).
|
|
1107
|
+
SEND_MAX_BYTES=${TMUX_SSH_MAX_BYTES:-16000}
|
|
1108
|
+
oversize_check() { # <what> <overhead bytes>
|
|
1109
|
+
local n; n=$(printf '%s' "$COMMAND" | LC_ALL=C wc -c | tr -d ' ')
|
|
1110
|
+
if [ "$1" = command ] && cmd_needs_encoding "$COMMAND"; then n=$(( (n + 2) / 3 * 4 + 100 )); fi # sent base64-encoded: 4/3 the size plus the decoder
|
|
1111
|
+
if [ "$((n + $2))" -gt "$SEND_MAX_BYTES" ]; then
|
|
1112
|
+
die "the $1 is $n bytes; one tmux send carries at most about 16,000 bytes (limit here $SEND_MAX_BYTES, wrapper included). Nothing was sent. Do not split it into several sends: put the text in a file, copy it over (scp FILE $HOST:/tmp/FILE), then run a short command that uses it (e.g. $SCRIPT_NAME $HOST 'git apply /tmp/FILE')." 2
|
|
1113
|
+
fi
|
|
1114
|
+
}
|
|
1115
|
+
if [ "$have_command" = true ]; then
|
|
1116
|
+
case "$MODE" in
|
|
1117
|
+
ensure) oversize_check command 400 ;;
|
|
1118
|
+
send) [ -n "${SEND_KEY:-}" ] || oversize_check "text to send" 0 ;;
|
|
1119
|
+
esac
|
|
1120
|
+
fi
|
|
1121
|
+
|
|
1122
|
+
SANITIZED=$(sanitize "$HOST")
|
|
1123
|
+
SESSION="ts_${SANITIZED}"
|
|
1124
|
+
# Exact targets: a bare -t NAME also matches a unique PREFIX (tmux target-session rules), so ts_host could select
|
|
1125
|
+
# ts_host_extra. "=NAME" restricts to the exact session; "=NAME:" is the same for window/pane targets.
|
|
1126
|
+
STGT="=${SESSION}"
|
|
1127
|
+
PTGT="=${SESSION}:"
|
|
1128
|
+
REF_FILE="$REF_DIR/${SANITIZED}.session"
|
|
1129
|
+
WATCH_FILE="$REF_DIR/${SANITIZED}.watch"
|
|
1130
|
+
GUARD_FILE="$REF_DIR/${SANITIZED}.input-guard"
|
|
1131
|
+
LOCK_DIR="$REF_DIR/${SANITIZED}.lock"
|
|
1132
|
+
EXPECTED_HOSTNAME=""
|
|
1133
|
+
|
|
1134
|
+
case "$MODE" in
|
|
1135
|
+
attach) cmd_attach ;; # exec's away; no lock needed
|
|
1136
|
+
kill) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; cmd_kill; exit $? ;; # local teardown, under the host lock
|
|
1137
|
+
status) mkdir -p "$REF_DIR"; cmd_status; exit $? ;; # observer: no lock, so it works while a long command holds the host (state files are written atomically)
|
|
1138
|
+
abort) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; cmd_abort; exit $? ;;
|
|
1139
|
+
resume) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; cmd_resume; exit $? ;;
|
|
1140
|
+
send) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; refuse_if_guarded; cmd_send; exit $? ;;
|
|
1141
|
+
read) mkdir -p "$REF_DIR"; cmd_read; exit $? ;; # read-only capture: no lock, so a long watcher can be observed
|
|
1142
|
+
esac
|
|
1143
|
+
|
|
1144
|
+
# ensure (host only) or run (host + command): both need a live, confirmed session.
|
|
1145
|
+
mkdir -p "$REF_DIR"
|
|
1146
|
+
acquire_lock "$LOCK_DIR"
|
|
1147
|
+
refuse_if_guarded
|
|
1148
|
+
read_watch || die "saved command watch is invalid; use --kill before starting another command" 125
|
|
1149
|
+
if [ -n "$WATCH_NONCE" ]; then
|
|
1150
|
+
die "a previous command is still being watched on $HOST -- NOT resent. Ways out: $SCRIPT_NAME $HOST --resume [--timeout N] (keep waiting) | $SCRIPT_NAME $HOST --abort (Ctrl-C it and clear the watch) | $SCRIPT_NAME $HOST --kill (drop the ssh session; remote processes keep running)" 125
|
|
1151
|
+
fi
|
|
1152
|
+
ensure_connected
|
|
1153
|
+
if [ "$have_command" = true ]; then
|
|
1154
|
+
run_command "$COMMAND"
|
|
1155
|
+
exit $?
|
|
1156
|
+
fi
|
|
1157
|
+
log "ready: $HOST ($EXPECTED_HOSTNAME)"
|
|
1158
|
+
exit 0
|