From 8ed848d84de26c6a3511ed00f78808ccbabe9d1d Mon Sep 17 00:00:00 2001 From: Argus Bot Date: Thu, 26 Mar 2026 19:49:00 -0500 Subject: [PATCH 1/4] Add share-ttl: auto-expire shares after configurable timeout Shared sessions now expire automatically after share-ttl seconds (default: 3600 / 1 hour). Running `ds --share` on an already-shared session resets the timer. Set share-ttl = 0 to disable. Implementation: - New config key share-ttl / env DS_UPTERM_SHARE_TTL - _upterm_start_ttl_watcher: spawns a detached background process (via setsid or plain subshell) that sleeps for TTL seconds then calls `ds --unshare`; saves watcher PID to .upterm.ttl.pid - _upterm_cancel_ttl_watcher: kills watcher + sleep child (SIGTERM process group), removes pid file - _share_start: starts watcher on successful share; resets timer (cancels old watcher, starts new) when re-sharing same session - _share_stop: cancels watcher before cleanup - README + examples/share-upterm.conf updated --- README.md | 5 ++ examples/share-upterm.conf | 1 + lib/plugins/share-upterm.sh | 80 ++++++++++++++++++- tests/ds-test | 150 ++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eb013b7..0b31464 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ Config file: `~/.config/ds/share-upterm.conf` (env vars `DS_UPTERM_*` override): | `authorized-keys` | `DS_UPTERM_AUTHORIZED_KEYS` | Restrict access via authorized_keys | | `push` | `DS_UPTERM_PUSH` | `user@host` — push share info via SCP | | `proxy-session` | `DS_UPTERM_PROXY_SESSION` | tmux session name for the share proxy (default: `_share`) | +| `share-ttl` | `DS_UPTERM_SHARE_TTL` | seconds before share auto-expires (default: `3600`, set to `0` to disable) | See `examples/share-upterm.conf` for a template. @@ -144,6 +145,10 @@ When sharing, connecting clients are placed into a dedicated proxy tmux session - Clients can interact with your sessions non-destructively via `tmux capture-pane` (read) and `tmux send-keys` (write) - The proxy session is created automatically on `--share` and destroyed on `--unshare` +#### Share TTL + +Shares expire automatically after `share-ttl` seconds (default: 1 hour). When the timer fires, `ds --unshare` is called automatically. Running `ds --share` on an already-shared session resets the timer. Set `share-ttl = 0` to disable auto-expiry. + ## Shell Integration Add to `~/.bashrc`: diff --git a/examples/share-upterm.conf b/examples/share-upterm.conf index c325dda..f901ee5 100644 --- a/examples/share-upterm.conf +++ b/examples/share-upterm.conf @@ -7,3 +7,4 @@ # github-user=myghuser # authorized-keys=~/.ssh/upterm_authorized_keys # push=user@remotehost +# share-ttl=3600 diff --git a/lib/plugins/share-upterm.sh b/lib/plugins/share-upterm.sh index 95c5e4e..07db403 100644 --- a/lib/plugins/share-upterm.sh +++ b/lib/plugins/share-upterm.sh @@ -20,6 +20,9 @@ # (default: _share). A dedicated background session is # created and shared so connecting clients get a shell # without mirroring into the user's active session. +# share-ttl seconds before the share automatically expires (default: +# 3600). Set to 0 to disable auto-expiry. Calling +# `ds --share` resets the timer. # # Env vars (all optional, override config): # DS_UPTERM_HOST maps to server @@ -30,6 +33,7 @@ # DS_UPTERM_PUSH maps to push # DS_UPTERM_PID_FILE override PID file path # DS_UPTERM_PROXY_SESSION maps to proxy-session +# DS_UPTERM_SHARE_TTL maps to share-ttl DS_UPTERM_HOST="${DS_UPTERM_HOST:-}" DS_UPTERM_PRIVATE_KEY="${DS_UPTERM_PRIVATE_KEY:-}" @@ -39,6 +43,7 @@ DS_UPTERM_AUTHORIZED_KEYS="${DS_UPTERM_AUTHORIZED_KEYS:-}" DS_UPTERM_PID_FILE="${DS_UPTERM_PID_FILE:-}" DS_UPTERM_PUSH="${DS_UPTERM_PUSH:-}" DS_UPTERM_PROXY_SESSION="${DS_UPTERM_PROXY_SESSION:-}" +DS_UPTERM_SHARE_TTL="${DS_UPTERM_SHARE_TTL:-}" _UPTERM_REMOTE_STATE_DIR=".local/state/ds" @@ -64,6 +69,10 @@ _upterm_log_file() { echo "$(_state_file_prefix).upterm.log" } +_upterm_ttl_pid_file() { + echo "$(_state_file_prefix).upterm.ttl.pid" +} + # --- Internal helpers --- _upterm_resolve_key() { @@ -172,10 +181,68 @@ _share_load_config() { authorized-keys) [[ -z "$DS_UPTERM_AUTHORIZED_KEYS" ]] && DS_UPTERM_AUTHORIZED_KEYS="${val/#\~/$HOME}" ;; push) [[ -z "$DS_UPTERM_PUSH" ]] && DS_UPTERM_PUSH="$val" ;; proxy-session) [[ -z "$DS_UPTERM_PROXY_SESSION" ]] && DS_UPTERM_PROXY_SESSION="$val" ;; + share-ttl) [[ -z "$DS_UPTERM_SHARE_TTL" ]] && DS_UPTERM_SHARE_TTL="$val" ;; esac done < "$conf" || true } +# Cancel any running TTL expiry watcher. +_upterm_cancel_ttl_watcher() { + local ttl_pid_file + ttl_pid_file=$(_upterm_ttl_pid_file) + if [[ -f "$ttl_pid_file" ]]; then + local pid + pid=$(cat "$ttl_pid_file" 2>/dev/null || true) + if [[ "$pid" =~ ^[0-9]+$ ]]; then + # SIGTERM the process group (kills sleep child too); fall back to + # killing just the PID if the group signal fails. + kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + fi + rm -f "$ttl_pid_file" + fi +} + +# Spawn a background watcher that calls `ds --unshare` after $ttl seconds. +# Stores the watcher's PID in the TTL pid file for later cancellation. +_upterm_start_ttl_watcher() { + local ttl="$1" + local session="$2" + local ttl_pid_file + ttl_pid_file=$(_upterm_ttl_pid_file) + + _upterm_cancel_ttl_watcher + + # Resolve the ds binary path before forking. + local ds_bin + ds_bin=$(command -v ds 2>/dev/null || true) + [[ -z "$ds_bin" ]] && ds_bin="ds" + + local esc_bin esc_session + esc_bin=$(printf '%q' "$ds_bin") + esc_session=$(printf '%q' "$session") + + # setsid gives the subshell its own process group so killing the group + # also kills the sleep child. Fall back to a plain subshell if unavailable. + if command -v setsid >/dev/null 2>&1; then + setsid bash -c "sleep ${ttl} && ${esc_bin} --unshare ${esc_session}" & + else + ( sleep "$ttl" && "$ds_bin" --unshare "$session" ) & + fi + local watcher_pid=$! + umask 077 + echo "$watcher_pid" > "$ttl_pid_file" +} + +# Start TTL watcher if share-ttl > 0; print expiry message. No-op if TTL=0. +_upterm_maybe_start_ttl_watcher() { + local session="$1" + local ttl="${DS_UPTERM_SHARE_TTL:-3600}" + if [[ "$ttl" =~ ^[0-9]+$ && "$ttl" -gt 0 ]]; then + _upterm_start_ttl_watcher "$ttl" "$session" + echo "ds: share will auto-expire in ${ttl}s (run 'ds --share' to reset)" + fi +} + _share_running() { local pid_file pid_file=$(_upterm_pid_file) @@ -203,7 +270,14 @@ _share_start() { local current_session current_session=$(_share_current_session) if [[ "$current_session" == "$session" ]]; then - echo "ds: already sharing session '$session'" + # Reset the TTL timer if configured. + local ttl="${DS_UPTERM_SHARE_TTL:-3600}" + if [[ "$ttl" =~ ^[0-9]+$ && "$ttl" -gt 0 ]]; then + _upterm_start_ttl_watcher "$ttl" "$session" + echo "ds: already sharing session '$session' (TTL reset to ${ttl}s)" + else + echo "ds: already sharing session '$session'" + fi _share_info return 0 else @@ -391,6 +465,7 @@ _share_start() { _write_share_info "$content" printf '%s\n' "$content" _upterm_push_share_info "$session" + _upterm_maybe_start_ttl_watcher "$session" return 0 fi echo "ds: timed out waiting for upterm share info (admin socket unavailable; log: $log_file)" >&2 @@ -404,6 +479,7 @@ _share_start() { _write_share_info "$content" printf '%s\n' "$content" _upterm_push_share_info "$session" + _upterm_maybe_start_ttl_watcher "$session" return 0 fi sleep 0.5 @@ -413,6 +489,7 @@ _share_start() { _write_share_info "$content" printf '%s\n' "$content" _upterm_push_share_info "$session" + _upterm_maybe_start_ttl_watcher "$session" return 0 fi @@ -439,6 +516,7 @@ _share_stop() { fi rm -f "$pid_file" "$DS_SHARE_INFO_FILE" \ "$(_upterm_admin_file)" "$(_upterm_session_file)" "$(_upterm_log_file)" + _upterm_cancel_ttl_watcher _upterm_unpush_share_info "$session" # Kill the proxy session if it exists. local proxy_session="${DS_UPTERM_PROXY_SESSION:-_share}" diff --git a/tests/ds-test b/tests/ds-test index 582e2cc..df9814d 100755 --- a/tests/ds-test +++ b/tests/ds-test @@ -357,6 +357,7 @@ _reset_tmux_log export MOCK_SESSIONS="ds" DS_SHARE_VIA="upterm" DS_UPTERM_GITHUB_USER="testuser" +DS_UPTERM_SHARE_TTL="0" # disable TTL for this test to avoid background watchers SESSION="ds" HOST="" # _do_share calls _share_start, which runs the full share flow with mock upterm @@ -469,13 +470,161 @@ _assert_eq "env override: github-user" "envuser" "$DS_UPTERM_GITHUB_USER" _assert_eq "env override: authorized-keys" "/env/authorized_keys" "$DS_UPTERM_AUTHORIZED_KEYS" _assert_eq "env override: push" "envuser@envhost" "$DS_UPTERM_PUSH" +# share-ttl config key loaded +cat > "$CONF_DIR/share-upterm.conf" <<'EOF' +server=myupterm.internal:2222 +share-ttl=1800 +EOF +DS_UPTERM_HOST="" +DS_UPTERM_SHARE_TTL="" +_share_load_config +_assert_eq "config: share-ttl" "1800" "$DS_UPTERM_SHARE_TTL" + +# Env var overrides share-ttl from config +DS_UPTERM_SHARE_TTL="600" +_share_load_config +_assert_eq "env override: share-ttl" "600" "$DS_UPTERM_SHARE_TTL" + # Missing config file is silently ignored rm -f "$CONF_DIR/share-upterm.conf" DS_UPTERM_HOST="" DS_UPTERM_PUSH="" +DS_UPTERM_SHARE_TTL="" _share_load_config _assert_eq "missing config: server empty" "" "$DS_UPTERM_HOST" _assert_eq "missing config: push empty" "" "$DS_UPTERM_PUSH" +_assert_eq "missing config: share-ttl empty" "" "$DS_UPTERM_SHARE_TTL" + +# --------------------------------------------------------------------------- +# Tests: Share TTL watcher +# --------------------------------------------------------------------------- + +echo "" +echo "=== Share TTL watcher ===" + +# Add a mock setsid that just runs its args directly (no new process group +# needed in tests — we just need to verify the watcher pid file is created +# and the watcher can be cancelled). +cat > "$MOCK_BIN/setsid" <<'MOCK' +#!/usr/bin/env bash +# Run in background so sleep doesn't block the test process. +"$@" & +MOCK +chmod +x "$MOCK_BIN/setsid" + +# Add a mock ds that records --unshare calls +DS_UNSHARE_LOG="$MOCK_BIN/ds-unshare.log" +cat > "$MOCK_BIN/ds" <> "${DS_UNSHARE_LOG}" +MOCK +chmod +x "$MOCK_BIN/ds" + +# --- _upterm_start_ttl_watcher creates a pid file --- +_reset_tmux_log +DS_UPTERM_PID_FILE="$(_upterm_pid_file)" +DS_UPTERM_SHARE_TTL="" +: > "$DS_UNSHARE_LOG" +_upterm_start_ttl_watcher 60 "ds" +ttl_pid_file=$(_upterm_ttl_pid_file) +_assert_file_exists "watcher: ttl pid file created" "$ttl_pid_file" +watcher_pid=$(cat "$ttl_pid_file" 2>/dev/null || true) +if [[ "$watcher_pid" =~ ^[0-9]+$ ]]; then + _pass "watcher: pid file contains numeric PID" +else + _fail "watcher: pid file contains non-numeric value: '$watcher_pid'" +fi +# Clean up — kill the watcher (it's sleeping for 60s) +_upterm_cancel_ttl_watcher +_assert_file_missing "watcher: cancel removes pid file" "$ttl_pid_file" +# Watcher process should be gone +if [[ "$watcher_pid" =~ ^[0-9]+$ ]] && kill -0 "$watcher_pid" 2>/dev/null; then + _fail "watcher: cancel killed process" +else + _pass "watcher: cancel killed process" +fi + +# --- _upterm_cancel_ttl_watcher is idempotent (no pid file) --- +_upterm_cancel_ttl_watcher +_assert_file_missing "cancel: idempotent when no pid file" "$(_upterm_ttl_pid_file)" +_pass "cancel: no error when pid file absent" + +# --- re-starting watcher cancels previous one --- +_upterm_start_ttl_watcher 60 "ds" +first_pid=$(cat "$(_upterm_ttl_pid_file)" 2>/dev/null || true) +_upterm_start_ttl_watcher 60 "ds" +second_pid=$(cat "$(_upterm_ttl_pid_file)" 2>/dev/null || true) +if [[ "$first_pid" != "$second_pid" ]]; then + _pass "watcher: re-start spawns new watcher" +else + _fail "watcher: re-start should spawn a new watcher PID" +fi +if [[ "$first_pid" =~ ^[0-9]+$ ]] && kill -0 "$first_pid" 2>/dev/null; then + _fail "watcher: re-start killed previous watcher" +else + _pass "watcher: re-start killed previous watcher" +fi +_upterm_cancel_ttl_watcher + +# --- TTL=0 disables the watcher --- +DS_UPTERM_SHARE_TTL="0" +export MOCK_SESSIONS="ds" +DS_UPTERM_GITHUB_USER="testuser" +DS_SHARE_INFO_FILE="$(_default_share_info_file)" +DS_UPTERM_PID_FILE="$(_upterm_pid_file)" +SESSION="ds" HOST="" +result=$(_do_share 2>&1) || true +_assert_file_missing "ttl=0: no ttl pid file created" "$(_upterm_ttl_pid_file)" +_assert_not_contains "ttl=0: no auto-expire message" "auto-expire" "$result" +# Clean up share state +_pid_f=$(_upterm_pid_file) +if [[ -f "$_pid_f" ]]; then + _pid=$(cat "$_pid_f" 2>/dev/null || true) + [[ "$_pid" =~ ^[0-9]+$ ]] && kill "$_pid" 2>/dev/null || true + rm -f "$_pid_f" +fi +rm -f "$DS_SHARE_INFO_FILE" "$(_upterm_admin_file)" "$(_upterm_session_file)" "$(_upterm_log_file)" + +# --- TTL>0 creates watcher on successful share --- +# Note: _do_share must NOT be captured in $() because the mock upterm starts +# background jobs (the hosted_cmd loop) that would make the subshell hang. +# Use a temp file to capture output instead. +_share_out=$(_tmpdir)/share.out +DS_UPTERM_SHARE_TTL="3600" +DS_UPTERM_PRIVATE_KEY="$TEST_HOME/.ssh/test_upterm_key" +DS_UPTERM_KNOWN_HOSTS="" +DS_UPTERM_HOST="" +DS_UPTERM_AUTHORIZED_KEYS="" +DS_UPTERM_PUSH="" +export MOCK_SESSIONS="ds" +DS_UPTERM_GITHUB_USER="testuser" +DS_SHARE_INFO_FILE="$(_default_share_info_file)" +DS_UPTERM_PID_FILE="$(_upterm_pid_file)" +SESSION="ds" HOST="" +_do_share > "$_share_out" 2>&1 || true +result=$(cat "$_share_out") +_assert_file_exists "ttl>0: ttl pid file created on share" "$(_upterm_ttl_pid_file)" +_assert_contains "ttl>0: auto-expire message shown" "auto-expire" "$result" +share_watcher_pid=$(cat "$(_upterm_ttl_pid_file)" 2>/dev/null || true) + +# --- _share_stop cancels the watcher --- +SESSION="ds" HOST="" +_do_unshare 2>&1 || true +_assert_file_missing "unshare: ttl pid file removed" "$(_upterm_ttl_pid_file)" +if [[ "$share_watcher_pid" =~ ^[0-9]+$ ]] && kill -0 "$share_watcher_pid" 2>/dev/null; then + _fail "unshare: watcher process killed" +else + _pass "unshare: watcher process killed" +fi + +# --- TTL fires and calls ds --unshare --- +DS_UPTERM_SHARE_TTL="" +: > "$DS_UNSHARE_LOG" +_upterm_start_ttl_watcher 1 "myses" +sleep 2 +unshare_calls=$(cat "$DS_UNSHARE_LOG" 2>/dev/null || true) +_assert_contains "ttl fire: ds --unshare called after TTL" "--unshare myses" "$unshare_calls" +rm -f "$(_upterm_ttl_pid_file)" # Reset sharing vars for remaining tmux tests DS_SHARE_VIA="" @@ -485,6 +634,7 @@ DS_UPTERM_PID_FILE="" DS_UPTERM_GITHUB_USER="" DS_UPTERM_AUTHORIZED_KEYS="" DS_UPTERM_PUSH="" +DS_UPTERM_SHARE_TTL="" SESSION="" HOST="" From 19c2263113535696765dfe5d3d65b0c5554d1a97 Mon Sep 17 00:00:00 2001 From: Argus Bot Date: Thu, 26 Mar 2026 20:26:48 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20SC2015=20shellcheck=20warning=20in?= =?UTF-8?q?=20test=20cleanup=20(A=20&&=20B=20||=20C=20=E2=86=92=20if/then)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/ds-test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ds-test b/tests/ds-test index df9814d..b5c2347 100755 --- a/tests/ds-test +++ b/tests/ds-test @@ -580,7 +580,7 @@ _assert_not_contains "ttl=0: no auto-expire message" "auto-expire" "$result" _pid_f=$(_upterm_pid_file) if [[ -f "$_pid_f" ]]; then _pid=$(cat "$_pid_f" 2>/dev/null || true) - [[ "$_pid" =~ ^[0-9]+$ ]] && kill "$_pid" 2>/dev/null || true + if [[ "$_pid" =~ ^[0-9]+$ ]]; then kill "$_pid" 2>/dev/null || true; fi rm -f "$_pid_f" fi rm -f "$DS_SHARE_INFO_FILE" "$(_upterm_admin_file)" "$(_upterm_session_file)" "$(_upterm_log_file)" From 225c3edfb831208e4204a51517673475f168e8bc Mon Sep 17 00:00:00 2001 From: Argus Bot Date: Thu, 26 Mar 2026 20:32:08 -0500 Subject: [PATCH 3/4] fix: suppress 'stopped sharing' output when TTL watcher auto-expires share --- lib/plugins/share-upterm.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/plugins/share-upterm.sh b/lib/plugins/share-upterm.sh index 07db403..5698465 100644 --- a/lib/plugins/share-upterm.sh +++ b/lib/plugins/share-upterm.sh @@ -224,9 +224,9 @@ _upterm_start_ttl_watcher() { # setsid gives the subshell its own process group so killing the group # also kills the sleep child. Fall back to a plain subshell if unavailable. if command -v setsid >/dev/null 2>&1; then - setsid bash -c "sleep ${ttl} && ${esc_bin} --unshare ${esc_session}" & + setsid bash -c "sleep ${ttl} && ${esc_bin} --unshare ${esc_session}" >/dev/null 2>&1 & else - ( sleep "$ttl" && "$ds_bin" --unshare "$session" ) & + ( sleep "$ttl" && "$ds_bin" --unshare "$session" ) >/dev/null 2>&1 & fi local watcher_pid=$! umask 077 From f3acd5575d775ee1fc7cf96eef7055654ed6b9fd Mon Sep 17 00:00:00 2001 From: Argus Bot Date: Thu, 26 Mar 2026 20:35:42 -0500 Subject: [PATCH 4/4] fix: restore umask after writing TTL watcher pid file --- lib/plugins/share-upterm.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/plugins/share-upterm.sh b/lib/plugins/share-upterm.sh index 5698465..6643ee4 100644 --- a/lib/plugins/share-upterm.sh +++ b/lib/plugins/share-upterm.sh @@ -229,8 +229,11 @@ _upterm_start_ttl_watcher() { ( sleep "$ttl" && "$ds_bin" --unshare "$session" ) >/dev/null 2>&1 & fi local watcher_pid=$! + local old_umask + old_umask=$(umask) umask 077 echo "$watcher_pid" > "$ttl_pid_file" + umask "$old_umask" } # Start TTL watcher if share-ttl > 0; print expiry message. No-op if TTL=0.