Initial pre-release

tmux-party: share a tmux session with people on the same UNIX host.
Single-file POSIX shell (party) with a filesystem + tmux server-access
trust model. See README.md.
This commit is contained in:
veg 2026-06-01 10:45:32 +00:00
commit 6be0ac1877
20 changed files with 3869 additions and 0 deletions

20
tests/00-meta.bats Normal file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bats
load 'helpers'
setup() { setup_party_sandbox; }
teardown() { teardown_party_sandbox; }
@test "test harness can locate the party binary path" {
[ -n "$PARTY_BIN" ]
# The file does not need to exist yet at this stage; we're proving
# the path is computed correctly.
case "$PARTY_BIN" in
*/party) ;;
*) false ;;
esac
}
@test "sandbox setup creates the override dir" {
[ -d "$PARTY_SOCKET_DIR" ]
}

33
tests/10-skeleton.bats Normal file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bats
load 'helpers'
setup() { setup_party_sandbox; }
teardown() { teardown_party_sandbox; }
@test "party with no args prints usage and exits non-zero" {
run "$PARTY_BIN"
[ "$status" -ne 0 ]
[[ "$output" == *"Usage:"* ]]
[[ "$output" == *"host"* ]]
[[ "$output" == *"join"* ]]
}
@test "party --help prints usage and exits zero" {
run "$PARTY_BIN" --help
[ "$status" -eq 0 ]
[[ "$output" == *"Usage:"* ]]
}
@test "party rejects unknown subcommands" {
run "$PARTY_BIN" rumpelstiltskin
[ "$status" -ne 0 ]
[[ "$output" == *"unknown subcommand"* ]]
}
@test "library mode: source returns without dispatching" {
load_party_lib
# If the script ran dispatch, we'd never get here. Reaching this
# assertion is the test.
[ -n "${PARTY_VERSION:-}" ]
}

96
tests/20-helpers.bats Normal file
View file

@ -0,0 +1,96 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
load_party_lib
}
# party name validation
# =====================
@test "validate_party_name accepts plain names" {
validate_party_name "debug-the-deploy"
validate_party_name "mob_coding"
validate_party_name "p"
validate_party_name "Party-2026"
}
@test "validate_party_name rejects empty" {
run validate_party_name ""
[ "$status" -ne 0 ]
}
@test "validate_party_name rejects path separators" {
run validate_party_name "../etc"
[ "$status" -ne 0 ]
run validate_party_name "a/b"
[ "$status" -ne 0 ]
}
@test "validate_party_name rejects names over 63 chars" {
long=$(printf 'a%.0s' $(seq 1 64))
run validate_party_name "$long"
[ "$status" -ne 0 ]
}
@test "validate_party_name rejects whitespace and shell metas" {
for bad in "a b" "a;b" 'a$b' 'a`b' "a'b" 'a"b'; do
run validate_party_name "$bad"
[ "$status" -ne 0 ] || { echo "should have rejected: $bad"; false; }
done
}
# time
# ====
@test "iso_now produces ISO 8601 UTC with Z suffix" {
out=$(iso_now)
case "$out" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z) ;;
*) echo "bad timestamp: $out"; false ;;
esac
}
# user existence
# ==============
@test "user_exists returns 0 for the running user" {
user_exists "$USER"
}
@test "user_exists returns nonzero for a fictional user" {
! user_exists "nonexistent_$(date +%s)_user"
}
# liveness
# ========
#
# is_party_alive must not probe the PID with kill -0. POSIX kill(2) is
# allowed to return EPERM when the caller can't signal the target, and
# illumos/BSD/macOS honor that — so kill -0 against another user's PID
# returns nonzero even when the process exists. Linux and Linux-ABI
# zones (LX-branded) are the outliers that return 0; that's why the
# bug hid in the test matrix until a native illumos run surfaced it.
# Discovery now relies on `tmux -S list-clients` alone, which is
# strictly stronger (filters PID-reuse, dead servers, AF_UNIX impostors).
@test "is_party_alive does not reach for kill -0 (cross-user discovery)" {
body=$(declare -f is_party_alive)
[[ "$body" != *"kill "* ]] || { echo "$body"; false; }
}
@test "is_party_alive rejects non-numeric PID without invoking tmux" {
! PARTY_TMUX=/nonexistent/should-not-be-called \
is_party_alive "" /tmp/should-not-matter
! PARTY_TMUX=/nonexistent/should-not-be-called \
is_party_alive "abc" /tmp/should-not-matter
}
@test "is_party_alive returns nonzero when tmux server is absent" {
# Numeric PID, but no live tmux server at the socket path: tmux call
# fails the handshake and the function returns nonzero. Uses PID 1
# to also confirm a foreign-uid PID is not preventing the result.
! is_party_alive 1 "$PARTY_TMP/no-such-sock"
}

122
tests/30-roster.bats Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
load_party_lib
}
@test "roster_record_path composes deterministically" {
out=$(roster_record_path "veg" "debug-the-deploy")
[ "$out" = "$PARTY_SOCKET_DIR/party-veg:debug-the-deploy.d/roster" ]
}
@test "party_dir_path composes deterministically" {
out=$(party_dir_path "veg" "debug-the-deploy")
[ "$out" = "$PARTY_SOCKET_DIR/party-veg:debug-the-deploy.d" ]
}
@test "socket_path lives inside the per-party private dir" {
out=$(socket_path "veg" "debug-the-deploy")
[ "$out" = "$PARTY_SOCKET_DIR/party-veg:debug-the-deploy.d/sock" ]
}
@test "roster_write then roster_read round-trips all fields" {
ensure_party_dir "$USER" "rt-test"
rec=$(roster_record_path "$USER" "rt-test")
# roster_read derives RR_HOST_USER, RR_PARTY_NAME, and RR_SOCKET from
# the dir basename and filesystem; SERVER_PID, GROUP, and CREATED come
# from the file. The values written here for HOST_USER/PARTY_NAME/
# SOCKET are advisory and overlap with what roster_read derives.
derived_sock="$PARTY_SOCKET_DIR/party-$USER:rt-test.d/sock"
roster_write "$rec" \
HOST_USER "$USER" \
PARTY_NAME "rt-test" \
SOCKET "$derived_sock" \
SERVER_PID 12345 \
CREATED "2026-04-26T18:42:00Z"
[ -f "$rec" ]
roster_read "$rec"
[ "$RR_HOST_USER" = "$USER" ]
[ "$RR_PARTY_NAME" = "rt-test" ]
[ "$RR_SOCKET" = "$derived_sock" ]
[ "$RR_SERVER_PID" = "12345" ]
[ "$RR_CREATED" = "2026-04-26T18:42:00Z" ]
}
@test "roster_write is atomic: no partial files on failure" {
ensure_party_dir "$USER" "atomic"
rec=$(roster_record_path "$USER" "atomic")
roster_write "$rec" HOST_USER "$USER" PARTY_NAME "atomic" \
SOCKET "/tmp/sock" SERVER_PID 1 CREATED 0
# The write goes via a temp file; no .tmp leftover should remain in
# the per-party dir.
! ls "${rec%/*}"/*.tmp 2>/dev/null
}
@test "roster_read fails on missing file" {
run roster_read "$PARTY_SOCKET_DIR/party-nobody:nope.d/roster"
[ "$status" -ne 0 ]
}
# Helper: write a $PARTY_TMUX stub that exits with the given code on
# every invocation. Used to test is_party_alive's tmux probe in isolation.
_stub_tmux() {
cat > "$PARTY_TMP/tmux-stub" <<EOF
#!/bin/sh
exit $1
EOF
chmod +x "$PARTY_TMP/tmux-stub"
PARTY_TMUX="$PARTY_TMP/tmux-stub"
}
@test "is_party_alive: tmux probe ok → true (regardless of PID liveness)" {
# Liveness is decided by the tmux probe alone; the PID arg is kept for
# caller-API compatibility and numeric-validation, but is not signal-
# probed (POSIX kill(2) returns EPERM cross-user on illumos/BSD/macOS,
# which broke cross-user discovery in earlier revisions). With a stub
# tmux returning 0, both a live and a never-existed PID must pass.
_stub_tmux 0
( sleep 30 ) &
pid=$!
is_party_alive "$pid" /dev/null
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null || true
# Same PID, now reaped — tmux stub still says yes, so still alive.
is_party_alive "$pid" /dev/null
# And a PID we never owned (init/launchd, root) — likewise alive.
is_party_alive 1 /dev/null
}
@test "is_party_alive: empty/non-numeric PID → false" {
_stub_tmux 0
! is_party_alive "" /dev/null
! is_party_alive "abc" /dev/null
}
@test "is_party_alive: tmux probe fails → false" {
# PID-reuse / planted nc -lU socket scenario: the socket isn't a real
# tmux server, so the tmux handshake fails. is_party_alive rejects
# regardless of PID state (regression seen cross-user on illumos
# native).
_stub_tmux 1
( sleep 30 ) &
pid=$!
! is_party_alive "$pid" /dev/null
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null || true
! is_party_alive 1 /dev/null
}
@test "roster_list returns full record paths under PARTY_SOCKET_DIR" {
for n in alpha beta gamma; do
ensure_party_dir "$USER" "$n"
rec=$(roster_record_path "$USER" "$n")
roster_write "$rec" HOST_USER "$USER" PARTY_NAME "$n" \
SOCKET "/tmp/s" SERVER_PID 1 CREATED 0
done
out=$(roster_list | sort | tr '\n' ' ')
expected="$PARTY_SOCKET_DIR/party-$USER:alpha.d/roster $PARTY_SOCKET_DIR/party-$USER:beta.d/roster $PARTY_SOCKET_DIR/party-$USER:gamma.d/roster "
[ "$out" = "$expected" ]
}

29
tests/40-perms.bats Normal file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
load_party_lib
}
@test "apply_party_perms_file sets mode g+rw on a regular file" {
require_party_group
f="$PARTY_TMP/f"
: > "$f"
apply_party_perms_file "$f"
m=$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f")
case "$m" in
*6[0-9]) ;; # group has rw
*) echo "mode=$m"; false ;;
esac
g=$(stat -c '%G' "$f" 2>/dev/null || stat -f '%Sg' "$f")
[ "$g" = "$TMUX_PARTY_GROUP" ]
}
@test "validate_socket_dir_parent accepts an owner-only-writable parent" {
# PARTY_SOCKET_DIR is the per-party dir's parent. Created at mode 0700
# by setup_party_sandbox (owner-only, neither group- nor world-writable),
# which validate_socket_dir_parent should accept without complaint.
validate_socket_dir_parent
}

92
tests/50-host.bats Normal file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
require_party_group # need real group membership for chgrp
}
teardown() { teardown_party_sandbox; }
# Per-party roster path: ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/roster.
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
@test "party host <name> creates a roster record and a live tmux server" {
run "$PARTY_BIN" host smoke
[ "$status" -eq 0 ]
[[ "$output" == *"smoke"* ]]
rec=$(party_rec smoke)
[ -f "$rec" ]
grep -q "^PARTY_NAME=smoke$" "$rec"
grep -q "^HOST_USER=$USER$" "$rec"
! grep -q "^MODE=" "$rec"
! grep -q "^OPS=" "$rec"
pid=$(awk -F= '$1=="SERVER_PID"{print $2}' "$rec")
kill -0 "$pid"
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
[ -S "$sock" ]
tmux -S "$sock" has-session -t smoke
}
@test "party host enforces unique party name" {
"$PARTY_BIN" host dupe
run "$PARTY_BIN" host dupe
[ "$status" -ne 0 ]
[[ "$output" == *"already running"* ]]
}
@test "party host rejects bad names" {
run "$PARTY_BIN" host "../escape"
[ "$status" -ne 0 ]
}
@test "party host without name uses a default" {
run "$PARTY_BIN" host
[ "$status" -eq 0 ]
ls -d "$PARTY_SOCKET_DIR"/party-"$USER":*.d >/dev/null
}
@test "party host puts host on access list with write" {
"$PARTY_BIN" host acl
rec=$(party_rec acl)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
tmux -S "$sock" server-access -l | grep -q "^$USER"
}
@test "party close kills the server and removes the roster record" {
"$PARTY_BIN" host clo
rec=$(party_rec clo)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
pid=$(awk -F= '$1=="SERVER_PID"{print $2}' "$rec")
run "$PARTY_BIN" close clo
[ "$status" -eq 0 ]
[ ! -f "$rec" ]
! kill -0 "$pid" 2>/dev/null
[ ! -S "$sock" ]
}
@test "party close rejects bad names" {
# Previous incarnation of this test edited HOST_USER in the roster
# file and expected close to refuse. That's no longer the trust
# model — host identity is anchored on dir ownership, not file
# content (commit 94befac), so editing HOST_USER is a no-op.
# The "not the host" path now tests as: another user owns the dir,
# which we can't fake without root. Coverage for the symlink and
# forged-field cases lives in tests/95-stub-roundtrip.bats. What we
# add here is parity with `party host rejects bad names`: cmd_close
# validates the name before any path or filesystem work.
run "$PARTY_BIN" close "../escape"
[ "$status" -ne 0 ]
}
@test "party host puts only the host on the access list (no auto-population)" {
"$PARTY_BIN" host inv
rec=$(party_rec inv)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
count=$(tmux -S "$sock" server-access -l | wc -l)
[ "$count" -eq 1 ]
"$PARTY_BIN" close inv
}

View file

@ -0,0 +1,135 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
require_party_group
}
teardown() { teardown_party_sandbox; }
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
@test "party list shows live parties only" {
"$PARTY_BIN" host alpha
"$PARTY_BIN" host bravo
# Inject a fake stale record. The per-party dir must exist with the
# right group + mode so roster_list can find the record inside.
zdir="$PARTY_SOCKET_DIR/party-$USER:zombie.d"
mkdir -p "$zdir"
chgrp "$TMUX_PARTY_GROUP" "$zdir"
chmod 0750 "$zdir"
rec="$zdir/roster"
cat > "$rec" <<EOF
HOST_USER=$USER
PARTY_NAME=zombie
SOCKET=$zdir/sock
SERVER_PID=999999
CREATED=2026-04-26T18:42:00Z
EOF
chgrp "$TMUX_PARTY_GROUP" "$rec"
chmod 0640 "$rec"
run "$PARTY_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"alpha"* ]]
[[ "$output" == *"bravo"* ]]
[[ "$output" != *"zombie"* ]]
}
@test "party list with no parties is friendly, not noisy" {
run "$PARTY_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"no parties"* ]]
}
# Cross-user discovery regression. Earlier revisions of is_party_alive
# probed the PID with `kill -0`, which returns EPERM under POSIX semantics
# when the caller can't signal the target — illumos, all BSDs, and macOS
# honor that, so guests couldn't discover parties hosted by other users
# on the same box. To simulate the foreign-uid case without privilege,
# rewrite the roster's SERVER_PID to 1 (init/launchd, root-owned on every
# supported platform) and confirm `party list`/`join` still find it. With
# the bug present this test fails on every non-Linux target; on Linux and
# Linux-ABI zones it passes regardless because Linux's kill(2) returns 0
# for "exists, even if not signalable."
@test "party list discovers parties whose host PID we cannot signal" {
"$PARTY_BIN" host crossuser
rec=$(party_rec crossuser)
[ -f "$rec" ]
# Rewrite SERVER_PID=<our-pid> -> SERVER_PID=1 in place. Avoids `sed -i`
# portability snags between GNU and BSD sed.
awk -F= 'BEGIN{OFS="="} $1=="SERVER_PID"{$2="1"} {print}' "$rec" >"$rec.new"
cat "$rec.new" >"$rec"
rm -f "$rec.new"
run "$PARTY_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"crossuser"* ]]
PARTY_DRY_RUN=1 run "$PARTY_BIN" join crossuser
[ "$status" -eq 0 ]
}
@test "party join <name> attaches via a guest session in group mode" {
"$PARTY_BIN" host joinable
rec=$(party_rec joinable)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
# Joining is interactive (it execs into tmux). We can't test the attach
# without a real terminal, so we exercise the underlying path: with
# PARTY_DRY_RUN=1 the join function builds the guest session and prints
# the command that WOULD be exec'd, then exits.
PARTY_DRY_RUN=1 "$PARTY_BIN" join joinable
tmux -S "$sock" has-session -t "__party_guest_$USER"
}
@test "party join with no name auto-attaches single live party" {
"$PARTY_BIN" host onlyone
PARTY_DRY_RUN=1 "$PARTY_BIN" join
rec=$(party_rec onlyone)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
tmux -S "$sock" has-session -t "__party_guest_$USER"
}
@test "party join with no name and no parties errors clearly" {
run "$PARTY_BIN" join
[ "$status" -ne 0 ]
[[ "$output" == *"no parties"* ]]
}
@test "party join --passive attaches read-only to host session, no guest session" {
"$PARTY_BIN" host mirror
rec=$(party_rec mirror)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
# --passive must use `attach -r` so a write-capable invitee who chooses
# passive can't type into the host's panes (the README's "watcher"
# boundary). PARTY_DRY_RUN=1 prints the attach command instead of execing.
run env PARTY_DRY_RUN=1 "$PARTY_BIN" join mirror --passive
[ "$status" -eq 0 ]
[[ "$output" == *"attach-session -r -t mirror"* ]]
! tmux -S "$sock" has-session -t "__party_guest_$USER" 2>/dev/null
}
@test "party leave kills the guest session and detaches the client" {
"$PARTY_BIN" host leaveme
rec=$(party_rec leaveme)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
PARTY_DRY_RUN=1 "$PARTY_BIN" join leaveme
tmux -S "$sock" has-session -t "__party_guest_$USER"
"$PARTY_BIN" leave
! tmux -S "$sock" has-session -t "__party_guest_$USER" 2>/dev/null
}
@test "party leave with no joined party errors helpfully" {
run "$PARTY_BIN" leave
[ "$status" -ne 0 ]
[[ "$output" == *"not joined"* ]]
}

51
tests/70-moderation.bats Normal file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
require_party_group
}
teardown() { teardown_party_sandbox; }
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
@test "party invite adds user to access list" {
"$PARTY_BIN" host invtest
other=$(pick_other_user); [ -n "$other" ] || skip "no second user"
"$PARTY_BIN" invite "$other"
rec=$(party_rec invtest)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
tmux -S "$sock" server-access -l | grep -q "^$other"
}
@test "party invite -r adds user as read-only" {
"$PARTY_BIN" host invro
other=$(pick_other_user); [ -n "$other" ] || skip
"$PARTY_BIN" invite "$other" -r
rec=$(party_rec invro)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
tmux -S "$sock" server-access -l | grep "^$other" | grep -qiE 'read|\(R\)'
}
@test "party voice / party mute toggle write access" {
"$PARTY_BIN" host vmtest
other=$(pick_other_user); [ -n "$other" ] || skip
"$PARTY_BIN" invite "$other" -r
"$PARTY_BIN" voice "$other"
"$PARTY_BIN" mute "$other"
}
@test "party kick removes from access list and kills guest session" {
"$PARTY_BIN" host ktest
other=$(pick_other_user); [ -n "$other" ] || skip
"$PARTY_BIN" invite "$other"
rec=$(party_rec ktest)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
# Inject a fake guest session as if the user had joined.
tmux -S "$sock" new-session -d -t ktest -s "__party_guest_$other"
"$PARTY_BIN" kick "$other"
! tmux -S "$sock" server-access -l | grep -q "^$other"
! tmux -S "$sock" has-session -t "__party_guest_$other" 2>/dev/null
}

73
tests/80-info.bats Normal file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
require_party_group
}
teardown() { teardown_party_sandbox; }
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
@test "party who lists invited and attached users" {
"$PARTY_BIN" host whotest
run "$PARTY_BIN" who --party whotest
[ "$status" -eq 0 ]
[[ "$output" == *"Party 'whotest'"* ]]
[[ "$output" == *"Invited:"* ]]
[[ "$output" == *"$USER"* ]]
[[ "$output" == *"Attached:"* ]]
}
@test "party who --short emits a single line for status-right" {
"$PARTY_BIN" host shorttest
rec=$(party_rec shorttest)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
out=$("$PARTY_BIN" who --short --socket "$sock")
case "$out" in
party:*) ;;
*) echo "got: $out"; false ;;
esac
}
@test "party who --socket prints Party line (long form)" {
"$PARTY_BIN" host wstest
rec=$(party_rec wstest)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
run "$PARTY_BIN" who --socket "$sock"
[ "$status" -eq 0 ]
[[ "$output" == *"Party 'wstest'"* ]]
[[ "$output" == *"host: $USER"* ]]
}
@test "party who --socket survives a missing roster (set -u regression)" {
"$PARTY_BIN" host wsroster
rec=$(party_rec wsroster)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
rm -f "$rec"
run "$PARTY_BIN" who --socket "$sock"
[ "$status" -eq 0 ]
[[ "$output" == *"Party 'wsroster'"* ]]
[[ "$output" == *"host: $USER"* ]]
}
@test "party who --socket rejects a path that is not a party socket" {
run "$PARTY_BIN" who --socket /tmp/not-a-party-socket
[ "$status" -eq 2 ]
[[ "$output" == *"is not a party socket"* ]]
}
@test "party status reports hosting" {
"$PARTY_BIN" host stat
run "$PARTY_BIN" status
[ "$status" -eq 0 ]
[[ "$output" == *"hosting"* ]]
[[ "$output" == *"stat"* ]]
}
@test "party status reports nothing when not in or hosting any party" {
run "$PARTY_BIN" status
[ "$status" -eq 0 ]
[[ "$output" == *"not in any party"* ]]
}

View file

@ -0,0 +1,53 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
require_party_group
}
teardown() { teardown_party_sandbox; }
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
@test "party host installs client-attached and client-detached hooks" {
"$PARTY_BIN" host hooks
rec=$(party_rec hooks)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
out=$(tmux -S "$sock" show-hooks -g)
[[ "$out" == *"client-attached"* ]]
[[ "$out" == *"client-detached"* ]]
}
@test "status-right widget is configured" {
"$PARTY_BIN" host srtest
rec=$(party_rec srtest)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
sr=$(tmux -S "$sock" show-options -gv status-right)
[[ "$sr" == *"party who --short"* ]]
}
@test "client-attached hook invokes a notify helper that broadcasts display-message" {
"$PARTY_BIN" host bcast
rec=$(party_rec bcast)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
# The hook body is a run-shell that invokes the notify helper script.
# Verify the hook references the helper, and the helper contains display-message.
hook=$(tmux -S "$sock" show-hooks -g | grep client-attached)
[[ "$hook" == *"run-shell"* ]]
# Extract the notify script path from the hook line and verify its content.
notify_script="${sock%/*}/.party-notify"
[ -f "$notify_script" ]
grep -q "display-message" "$notify_script"
}
@test ".party-notify is chgrp'd to TMUX_PARTY_GROUP (no :other outlier)" {
"$PARTY_BIN" host notifyperms
rec=$(party_rec notifyperms)
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
notify_script="${sock%/*}/.party-notify"
[ -f "$notify_script" ]
g=$(stat -c '%G' "$notify_script" 2>/dev/null || stat -f '%Sg' "$notify_script")
[ "$g" = "$TMUX_PARTY_GROUP" ] \
|| { echo "notify_script group=$g, expected $TMUX_PARTY_GROUP"; false; }
}

View file

@ -0,0 +1,812 @@
#!/usr/bin/env bats
#
# End-to-end host -> list -> close roundtrip with a stubbed PARTY_TMUX.
# The dispatch layer talks to tmux exclusively through PARTY_TMUX, so we
# hand it a recorder script and verify the side effects party owns
# directly: per-party private dirs, the roster file inside them, the
# notify helper, and the tmux command stream itself.
load 'helpers'
setup() {
setup_party_sandbox
# Use the caller's primary group, which they are guaranteed to be in.
# require_caller_in_group and chgrp both succeed regardless of the
# default 'party' group existing on the host.
export TMUX_PARTY_GROUP="$(id -gn)"
# Spawn a long-lived sleeper so cmd_list's is_party_alive check sees a
# live PID for the "fake server". setup_party_sandbox provides PARTY_TMP.
( exec sleep 60 ) &
export FAKE_SERVER_PID=$!
# Recorder: appends every invocation to TMUX_LOG, fakes the few tmux
# subcommands cmd_host depends on (creating a placeholder socket file,
# answering display-message -p '#{pid}' with the sleeper's PID so
# is_party_alive sees it as live), and returns 0 for the rest.
export TMUX_LOG="$PARTY_TMP/tmux.log"
: > "$TMUX_LOG"
cat > "$PARTY_TMP/tmux-stub" <<'STUB'
#!/bin/sh
printf '%s\n' "$*" >> "$TMUX_LOG"
sock=
prev=
for a in "$@"; do
if [ "$prev" = "-S" ]; then sock="$a"; fi
prev="$a"
done
case "$*" in
*"new-session -d "*)
# cmd_host expects the socket file to exist for apply_party_perms_file.
[ -n "$sock" ] && : > "$sock"
;;
*"display-message -p "*)
# Caller is fishing for #{pid}; emit the sleeper's PID.
echo "$FAKE_SERVER_PID"
;;
*"set-hook -g client-attached "*)
# Optional injection point for the rollback test: a failure here
# exits cmd_host between new-session and roster_write.
[ -n "${FAIL_ON_SETHOOK:-}" ] && exit 17
;;
esac
exit 0
STUB
chmod +x "$PARTY_TMP/tmux-stub"
export PARTY_TMUX="$PARTY_TMP/tmux-stub"
}
teardown() {
if [ -n "${FAKE_SERVER_PID:-}" ]; then
kill "$FAKE_SERVER_PID" 2>/dev/null || true
wait "$FAKE_SERVER_PID" 2>/dev/null || true
fi
teardown_party_sandbox
}
# Helper: per-party roster path under the new layout.
party_rec() { printf '%s/party-%s:%s.d/roster\n' "$PARTY_SOCKET_DIR" "$USER" "$1"; }
@test "host -> list -> close roundtrip with stubbed tmux" {
# host
run "$PARTY_BIN" host roundtrip
[ "$status" -eq 0 ]
rec=$(party_rec roundtrip)
[ -f "$rec" ]
grep -q "^HOST_USER=$USER$" "$rec"
grep -q "^PARTY_NAME=roundtrip$" "$rec"
grep -q "^GROUP=$TMUX_PARTY_GROUP$" "$rec"
grep -q "^SERVER_PID=[0-9][0-9]*$" "$rec"
# The per-party directory and its socket placeholder are present, mode
# 0750 on the dir.
party_dir="$PARTY_SOCKET_DIR/party-$USER:roundtrip.d"
[ -d "$party_dir" ]
[ -f "$party_dir/sock" ]
[ -x "$party_dir/.party-notify" ]
m=$(stat -c '%a' "$party_dir" 2>/dev/null || stat -f '%Lp' "$party_dir")
[ "$m" = "750" ]
# tmux command stream: the host is on the access list, hooks are set,
# status-right widget is configured.
grep -q "server-access -aw $USER" "$TMUX_LOG"
grep -q "set-hook -g client-attached" "$TMUX_LOG"
grep -q "set-hook -g client-detached" "$TMUX_LOG"
grep -q "status-right" "$TMUX_LOG"
# list shows the running party.
run "$PARTY_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"roundtrip"* ]]
# close removes the per-party directory entirely (which takes the
# roster file with it) and runs kill-server.
run "$PARTY_BIN" close roundtrip
[ "$status" -eq 0 ]
[ ! -d "$party_dir" ]
[ ! -f "$rec" ]
grep -q "kill-server" "$TMUX_LOG"
}
@test "host --group <name> records the chosen group in the roster" {
# The stub doesn't care which group we pick — exercise the path that
# plumbs --group through to the GROUP= field. Use the caller's primary
# group so chgrp succeeds.
primary=$(id -gn)
run "$PARTY_BIN" host grouped --group "$primary"
[ "$status" -eq 0 ]
rec=$(party_rec grouped)
[ -f "$rec" ]
grep -q "^GROUP=$primary$" "$rec"
"$PARTY_BIN" close grouped
}
@test "host with --group works without any pre-existing shared install dir" {
# Regression: the old layout required /tmp/roster to be installed at
# mode 1770 g=$TMUX_PARTY_GROUP at install time. The new layout has
# nothing of the sort — every party is self-contained under PARTY_SOCKET_DIR.
# Confirm `party host --group` succeeds with a clean sandbox that
# never had a shared roster directory.
primary=$(id -gn)
run "$PARTY_BIN" host noshareddir --group "$primary"
[ "$status" -eq 0 ]
rec=$(party_rec noshareddir)
[ -f "$rec" ]
"$PARTY_BIN" close noshareddir
}
@test "host refuses a non-trustworthy PARTY_SOCKET_DIR" {
bad="$PARTY_TMP/bad"
mkdir -p "$bad"
chmod 0777 "$bad" # not sticky, world-writable
PARTY_SOCKET_DIR="$bad" run "$PARTY_BIN" host shouldfail
[ "$status" -ne 0 ]
[[ "$output" == *"refusing to operate"* ]]
}
@test "host refuses group-writable, non-sticky PARTY_SOCKET_DIR" {
# Owned by us, mode 0770, no sticky. The parent's group may differ
# from TMUX_PARTY_GROUP, so its members would be outside the trust
# perimeter yet able to rename or unlink the per-party dir.
bad="$PARTY_TMP/groupw"
mkdir -p "$bad"
chmod 0770 "$bad"
PARTY_SOCKET_DIR="$bad" run "$PARTY_BIN" host shouldfail
[ "$status" -ne 0 ]
[[ "$output" == *"refusing to operate"* ]]
}
@test "host party_dir ends at mode 0750 even with permissive umask" {
# Regression for the umask race: with umask 0002, plain `mkdir` would
# produce 0775 until the later chmod 0750 closed the window. mkdir -m
# 0700 in cmd_host removes the window entirely — and the postcondition
# is still 0750.
( umask 0002 && "$PARTY_BIN" host umasked )
party_dir="$PARTY_SOCKET_DIR/party-$USER:umasked.d"
m=$(stat -c '%a' "$party_dir" 2>/dev/null || stat -f '%Lp' "$party_dir")
[ "$m" = "750" ]
"$PARTY_BIN" close umasked
}
@test "host refuses to bind a socket path past AF_UNIX sun_path" {
# Build a long-but-trustworthy parent under PARTY_TMP, then host with a
# near-max-length name. Total length should exceed the ~100-byte cap
# cmd_host enforces. The error must come from us, not from tmux.
pad=$(printf '%080s' '' | tr ' ' p) # 80-char component
long="$PARTY_TMP/$pad"
mkdir "$long" && chmod 0700 "$long"
longname=$(printf '%050s' '' | tr ' ' n) # 50 chars, within validator's 63
PARTY_SOCKET_DIR="$long" run "$PARTY_BIN" host "$longname"
[ "$status" -ne 0 ]
[[ "$output" == *"socket path too long"* ]]
# And the tmux stub never got invoked for new-session.
! grep -q "new-session" "$TMUX_LOG"
}
@test "roster record is mode 0640, not group-writable" {
# If the roster is g+w, peers can rewrite SOCKET= to point cmd_close at
# arbitrary host-owned paths. Lock it to g+r.
"$PARTY_BIN" host modecheck
rec=$(party_rec modecheck)
m=$(stat -c '%a' "$rec" 2>/dev/null || stat -f '%Lp' "$rec")
[ "$m" = "640" ]
"$PARTY_BIN" close modecheck
}
@test "join refuses when caller is not in the party's recorded group" {
# The cmd_join group check uses RR_GROUP (per-party), not env
# TMUX_PARTY_GROUP. Forge a roster record whose GROUP= names a group
# nobody is in, point SERVER_PID at the live sleeper so it looks
# alive, and verify cmd_join rejects with the friendly diagnostic
# before any tmux work.
bogus="party_no_such_group_$$"
party_dir="$PARTY_SOCKET_DIR/party-$USER:groupgated.d"
mkdir -p "$party_dir"
chmod 0750 "$party_dir"
: > "$party_dir/sock"
rec="$party_dir/roster"
cat > "$rec" <<EOF
HOST_USER=$USER
PARTY_NAME=groupgated
SOCKET=$party_dir/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$bogus
CREATED=2026-04-28T00:00:00Z
EOF
run "$PARTY_BIN" join groupgated
[ "$status" -ne 0 ]
[[ "$output" == *"uses group '$bogus'"* ]]
[[ "$output" == *"not a member"* ]]
}
@test "close ignores forged SOCKET= field; uses derived path" {
# roster_read derives RR_SOCKET from $d/sock and ignores the file's
# SOCKET= field, and cmd_close derives both paths from $USER + $name
# without consulting the roster at all. A forged SOCKET= cannot
# redirect the destructive ops at unrelated paths.
"$PARTY_BIN" host tampered
rec=$(party_rec tampered)
party_dir="$PARTY_SOCKET_DIR/party-$USER:tampered.d"
[ -d "$party_dir" ]
sentinel="$PARTY_TMP/sentinel"
mkdir -p "$sentinel"
: > "$sentinel/canary"
# Forge SOCKET= to point inside the sentinel.
awk -v new="$sentinel/sock" 'BEGIN{FS=OFS="="} $1=="SOCKET"{$2=new}1' \
"$rec" > "$rec.new" && mv "$rec.new" "$rec"
run "$PARTY_BIN" close tampered
[ "$status" -eq 0 ]
# Sentinel survives: cmd_close used the derived path, not the forged
# SOCKET=. This is the security property.
[ -d "$sentinel" ]
[ -f "$sentinel/canary" ]
# The real per-party dir is cleaned up regardless.
[ ! -d "$party_dir" ]
}
@test "close refuses to operate on a symlinked per-party dir" {
# With a sticky parent (e.g. /tmp), another local user can pre-create
# party-$USER:<name>.d as a symlink before we ever host. cmd_close
# must lstat-and-refuse before reading the (potentially forged) roster
# or invoking tmux against a symlinked socket path.
victim="$PARTY_TMP/victim"
mkdir -p "$victim"
: > "$victim/sock"
cat > "$victim/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=hijack
SOCKET=$victim/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
party_dir="$PARTY_SOCKET_DIR/party-$USER:hijack.d"
ln -s "$victim" "$party_dir"
run "$PARTY_BIN" close hijack
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]]
# tmux must not have been touched: the recorder log has nothing for
# this party.
! grep -q "kill-server" "$TMUX_LOG"
# Symlink and victim contents both intact — we did not chase the
# symlink to delete the attacker's tree.
[ -L "$party_dir" ]
[ -d "$victim" ]
[ -f "$victim/roster" ]
}
@test "host warns when PARTY_SOCKET_DIR is not group/world readable" {
# The sandbox parent is mode 0700 — trustworthy but undiscoverable to
# invitees. cmd_host should accept it (validator is happy) but emit
# a warning so the host notices before sending invites.
run "$PARTY_BIN" host noreach
[ "$status" -eq 0 ]
[[ "$output" == *"not readable"* ]]
[[ "$output" == *"discover"* ]]
"$PARTY_BIN" close noreach
}
@test "resolve_authoritative_party skips symlinked party dirs but roster_list does not" {
load_party_lib
ensure_party_dir "$USER" real
rec_real="$PARTY_SOCKET_DIR/party-$USER:real.d/roster"
cat >"$rec_real" <<EOF
HOST_USER=$USER
PARTY_NAME=real
SOCKET=$PARTY_SOCKET_DIR/party-$USER:real.d/sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec_real"
# Forge a symlink-shaped fake dir pointing at attacker-controlled content.
victim="$BATS_TEST_TMPDIR/victim"
mkdir -p "$victim"
cat >"$victim/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=fake
SOCKET=/tmp/attacker.sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
ln -s "$victim" "$PARTY_SOCKET_DIR/party-$USER:fake.d"
# roster_list is the cross-user discovery primitive; it must surface
# both entries so guests can find host-foreign parties.
out=$(roster_list)
[[ "$out" == *"party-$USER:real.d/roster"* ]]
[[ "$out" == *"party-$USER:fake.d/roster"* ]]
# resolve_authoritative_party is the host-only walker; without its
# symlink gate, two candidates would match HOST_USER=$USER and the
# forged one could redirect host-only commands. With the gate, only
# the real entry resolves.
resolve_authoritative_party ""
[ "$RR_PARTY_NAME" = "real" ]
}
@test "no-arg cmd_close ignores forged symlinked party dirs" {
# Set up one real party dir with a roster that looks host-owned.
ensure_party_dir "$USER" real
rec_real="$PARTY_SOCKET_DIR/party-$USER:real.d/roster"
cat >"$rec_real" <<EOF
HOST_USER=$USER
PARTY_NAME=real
SOCKET=$PARTY_SOCKET_DIR/party-$USER:real.d/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec_real"
# Create a placeholder socket so apply_party_perms_file in close doesn't
# choke; close runs kill-server then rm -rf, so just a file is enough.
: > "$PARTY_SOCKET_DIR/party-$USER:real.d/sock"
# Forge a symlinked fake dir: attacker plants party-$USER:fake.d -> victim.
victim="$BATS_TEST_TMPDIR/victim"
mkdir -p "$victim"
cat >"$victim/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=fake
SOCKET=$victim/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
ln -s "$victim" "$PARTY_SOCKET_DIR/party-$USER:fake.d"
# no-arg close: should find exactly one candidate (real), close it, exit 0.
# If the forged dir were not filtered, it would see two candidates and
# exit 1 with "you host multiple parties".
run "$PARTY_BIN" close
[ "$status" -eq 0 ]
[[ "$output" == *"real"* ]]
# The real party dir must be gone.
[ ! -d "$PARTY_SOCKET_DIR/party-$USER:real.d" ]
# kill-server must not have been invoked against any path under victim.
! grep -q "$victim" "$TMUX_LOG"
}
@test "roster_read derives RR_SOCKET from dir; ignores file SOCKET= field" {
load_party_lib
ensure_party_dir "$USER" derived
rec="$PARTY_SOCKET_DIR/party-$USER:derived.d/roster"
cat >"$rec" <<EOF
HOST_USER=$USER
PARTY_NAME=derived
SOCKET=/tmp/decoy-sock-$$
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec"
roster_read "$rec"
# RR_SOCKET is the co-located sock, not the forged path in the file.
[ "$RR_SOCKET" = "$PARTY_SOCKET_DIR/party-$USER:derived.d/sock" ]
}
@test "roster_read derives RR_HOST_USER and RR_PARTY_NAME from basename; ignores file fields" {
load_party_lib
ensure_party_dir "$USER" bona-fide
rec="$PARTY_SOCKET_DIR/party-$USER:bona-fide.d/roster"
# File claims wrong HOST_USER and PARTY_NAME; basename is authoritative.
cat >"$rec" <<EOF
HOST_USER=lies
PARTY_NAME=alsolies
SOCKET=/wrong/sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec"
roster_read "$rec"
[ "$RR_HOST_USER" = "$USER" ]
[ "$RR_PARTY_NAME" = "bona-fide" ]
}
@test "roster_read rejects roster whose sock is a symlink" {
load_party_lib
ensure_party_dir "$USER" symtrap
d="$PARTY_SOCKET_DIR/party-$USER:symtrap.d"
rm -f "$d/sock"
victim="$BATS_TEST_TMPDIR/victim_sock"
: > "$victim"
ln -s "$victim" "$d/sock"
rec="$d/roster"
cat >"$rec" <<EOF
HOST_USER=$USER
PARTY_NAME=symtrap
SOCKET=$d/sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec"
! roster_read "$rec"
}
@test "roster_read rejects dir whose owner does not match basename HOST_USER" {
[ "$USER" = "root" ] && skip "running as root: -user root would trivially match"
load_party_lib
# Basename claims the dir is hosted by 'root', but mkdir runs as us.
fake="$PARTY_SOCKET_DIR/party-root:foo.d"
mkdir -p "$fake"
chmod 0750 "$fake"
: > "$fake/sock"
rec="$fake/roster"
cat >"$rec" <<EOF
HOST_USER=root
PARTY_NAME=foo
SOCKET=$fake/sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec"
# Owner of $fake is $USER, but basename says root → reject.
! roster_read "$rec"
}
@test "cmd_leave ignores forged roster pointing at a different socket" {
# Set up a legitimate party that the "victim" (us) is joined to.
# The stub answers has-session with success so _disconnect_user fires.
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit.d"
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
: > "$legit_dir/sock"
cat >"$legit_dir/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=legit
SOCKET=$legit_dir/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$legit_dir/roster"
# Mallory plants a forged party dir whose roster points at a victim socket.
victim_sock="$BATS_TEST_TMPDIR/victim-sock"
trap_dir="$PARTY_SOCKET_DIR/party-mallory:trap.d"
mkdir -p "$trap_dir" && chmod 0750 "$trap_dir"
cat >"$trap_dir/roster" <<EOF
HOST_USER=mallory
PARTY_NAME=trap
SOCKET=$victim_sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$trap_dir/roster"
# Wire up the stub: has-session returns 0 for the legit party so
# cmd_leave considers us attached there; list-clients returns our USER.
cat > "$PARTY_TMP/tmux-stub" <<STUB
#!/bin/sh
printf '%s\n' "\$*" >> "\$TMUX_LOG"
sock=
prev=
for a in "\$@"; do
if [ "\$prev" = "-S" ]; then sock="\$a"; fi
prev="\$a"
done
case "\$*" in
*"new-session -d "*)
[ -n "\$sock" ] && : > "\$sock"
;;
*"display-message -p "*)
echo "\$FAKE_SERVER_PID"
;;
*"has-session"*)
exit 0
;;
*"list-clients"*)
printf '#{client_user}\n' | sed "s/.*/$USER/"
;;
esac
exit 0
STUB
chmod +x "$PARTY_TMP/tmux-stub"
run "$PARTY_BIN" leave
# cmd_leave must not have contacted the victim socket.
! grep -qF "$victim_sock" "$TMUX_LOG"
# It must have operated on the legit party socket instead.
grep -q "$legit_dir/sock" "$TMUX_LOG"
}
@test "host rolls back on partial setup failure (no orphan dir or server)" {
# Without rollback, a failure between new-session and roster_write
# leaves a live tmux server with no record. The trap inside cmd_host
# must kill-server and rm -rf the per-party dir before exiting.
party_dir="$PARTY_SOCKET_DIR/party-$USER:rollback.d"
FAIL_ON_SETHOOK=1 run "$PARTY_BIN" host rollback
[ "$status" -ne 0 ]
# Trap fired: kill-server invoked against the derived socket.
grep -q "$party_dir/sock kill-server" "$TMUX_LOG"
# Per-party dir is gone — no leftover roster, no leftover sock.
[ ! -e "$party_dir" ]
}
@test "host refuses leftover per-party dir without a roster" {
# Simulate a prior incarnation that left a per-party dir behind with
# no roster (the rollback trap couldn't fire — SIGKILL or panic).
# cmd_host's mkdir-as-lock acquisition will fail with EEXIST. We
# deliberately do NOT auto-clean: the same code path triggers on a
# concurrent in-flight host attempt for the same name, and racing
# rm -rf calls would wipe each other's fresh dirs.
# Recovery is a manual `rm -rf` per the error message.
party_dir="$PARTY_SOCKET_DIR/party-$USER:resurrect.d"
mkdir -p "$party_dir"
: > "$party_dir/sock"
run "$PARTY_BIN" host resurrect
[ "$status" -ne 0 ]
[[ "$output" == *"already exists"* ]]
[[ "$output" == *"rm -rf $party_dir"* ]]
# The leftover dir is untouched — proves we did not race-clean.
[ -d "$party_dir" ]
[ -e "$party_dir/sock" ]
# And we never spoke to tmux at all on the refusal path.
! grep -q "kill-server" "$TMUX_LOG"
! grep -q "new-session" "$TMUX_LOG"
}
@test "roster_read rejects per-party dir with other-readable mode bits" {
# cmd_host writes the per-party dir as 0750 (no other-bits). A non-member
# outside TMUX_PARTY_GROUP cannot traverse such a dir. Anything appearing
# in roster_list with o+r/o+w/o+x is a spoof attempt: a local user planted
# a world-readable party-attacker:foo.d under a sticky parent (e.g. /tmp)
# to inject a fake party into someone else's `party list`. roster_read
# must reject before tmux is contacted.
load_party_lib
ensure_party_dir "$USER" worldread
d="$PARTY_SOCKET_DIR/party-$USER:worldread.d"
chmod 0755 "$d"
rec="$d/roster"
cat >"$rec" <<EOF
HOST_USER=$USER
PARTY_NAME=worldread
SOCKET=$d/sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec"
! roster_read "$rec"
}
@test "roster_read rejects basename whose party name fails validate_party_name" {
# The basename glob (party-*:*.d) is permissive — names like '.' (basename
# party-USER:..d) match it. cmd_host's validate_party_name rejects '.' as
# a reserved name; roster_read runs the same check on read so a forged
# dir whose basename derives an illegal name can't pass.
load_party_lib
d="$PARTY_SOCKET_DIR/party-$USER:..d"
mkdir -p "$d"
chmod 0750 "$d"
: > "$d/sock"
rec="$d/roster"
cat >"$rec" <<EOF
HOST_USER=$USER
PARTY_NAME=.
SOCKET=$d/sock
SERVER_PID=$$
GROUP=${TMUX_PARTY_GROUP:-party}
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$rec"
! roster_read "$rec"
}
@test "cmd_list skips parties whose tmux list-clients fails" {
# SERVER_PID liveness alone doesn't prove a party is real — init's PID 1
# always passes, and a non-tmux daemon (or `nc -lU`) can hold a real
# AF_UNIX socket. cmd_list must run list-clients and treat non-zero as
# "not a real party," skipping the entry rather than reporting it with
# 0 attendees.
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit.d"
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
: > "$legit_dir/sock"
cat >"$legit_dir/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=legit
SOCKET=$legit_dir/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$legit_dir/roster"
fake_dir="$PARTY_SOCKET_DIR/party-$USER:imposter.d"
mkdir -p "$fake_dir" && chmod 0750 "$fake_dir"
: > "$fake_dir/sock"
cat >"$fake_dir/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=imposter
SOCKET=$fake_dir/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-29T00:00:00Z
EOF
chmod 0640 "$fake_dir/roster"
# Stub: list-clients exits 1 against the imposter sock, 0 (empty list)
# against the legit one. Anything else is a no-op so the rest of cmd_list
# behaves normally.
cat > "$PARTY_TMUX" <<STUB
#!/bin/sh
printf '%s\n' "\$*" >> "\$TMUX_LOG"
case "\$*" in
*list-clients*)
case "\$*" in
*party-$USER:imposter.d*) exit 1 ;;
esac
;;
esac
exit 0
STUB
chmod +x "$PARTY_TMUX"
run "$PARTY_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"legit"* ]]
[[ "$output" != *"imposter"* ]]
}
@test "invite warns when invitee is not in the party's group" {
# Need a real OS user that's not in TMUX_PARTY_GROUP. 'nobody' exists
# on Linux, FreeBSD, OpenBSD, macOS, and illumos; on the matrix it
# is never a member of the host's primary group.
id -u nobody >/dev/null 2>&1 || skip "no 'nobody' user on this host"
if id -nG nobody 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"; then
skip "nobody is unexpectedly a member of $TMUX_PARTY_GROUP on this host"
fi
"$PARTY_BIN" host inv >/dev/null
run "$PARTY_BIN" invite nobody --party inv
[ "$status" -eq 0 ]
# Warning surfaces with the right group name and remediation.
[[ "$output" == *"not in group '$TMUX_PARTY_GROUP'"* ]]
[[ "$output" == *"usermod -aG $TMUX_PARTY_GROUP nobody"* ]]
# Warn-and-proceed: the auth-list mutation still happened, so a later
# group grant doesn't require a re-invite.
[[ "$output" == *"Invited nobody"* ]]
grep -q "server-access -aw nobody" "$TMUX_LOG"
}
@test "find_party_by_name skips parties whose tmux list-clients fails" {
# Parity with test 76 ('cmd_list skips parties...'), but for the join
# resolver. A roster can have a live SERVER_PID (PID reuse, init's pid 1)
# and an [ -S ]-passing socket (planted via `nc -lU`) yet not be a real
# tmux server. is_party_alive must probe list-clients on every resolver,
# not just cmd_list — otherwise `party join
# imposter` would resolve to the imposter and try to attach.
legit_dir="$PARTY_SOCKET_DIR/party-$USER:legit2.d"
mkdir -p "$legit_dir" && chmod 0750 "$legit_dir"
: > "$legit_dir/sock"
cat >"$legit_dir/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=legit2
SOCKET=$legit_dir/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-30T00:00:00Z
EOF
chmod 0640 "$legit_dir/roster"
fake_dir="$PARTY_SOCKET_DIR/party-$USER:imposter2.d"
mkdir -p "$fake_dir" && chmod 0750 "$fake_dir"
: > "$fake_dir/sock"
cat >"$fake_dir/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=imposter2
SOCKET=$fake_dir/sock
SERVER_PID=$FAKE_SERVER_PID
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-04-30T00:00:00Z
EOF
chmod 0640 "$fake_dir/roster"
cat > "$PARTY_TMUX" <<STUB
#!/bin/sh
printf '%s\n' "\$*" >> "\$TMUX_LOG"
case "\$*" in
*list-clients*)
case "\$*" in
*party-$USER:imposter2.d*) exit 1 ;;
esac
;;
esac
exit 0
STUB
chmod +x "$PARTY_TMUX"
# cmd_join refuses to attach when already inside tmux; force-clear so
# the resolver runs to completion without the early bail.
unset TMUX
run "$PARTY_BIN" join imposter2
[ "$status" -ne 0 ]
[[ "$output" == *"not found"* ]]
}
@test "host refuses to start when tmux lacks server-access (auth gate missing)" {
# cmd_host MUST require server-access at runtime, not just in the
# README. tmux < 3.3 doesn't support the command, and silently
# swallowing its failure (the prior behavior) would degrade the auth
# gate to nothing — group members with FS access could attach without
# being invited.
cat > "$PARTY_TMUX" <<'STUB'
#!/bin/sh
printf '%s\n' "$*" >> "$TMUX_LOG"
sock=
prev=
for a in "$@"; do
if [ "$prev" = "-S" ]; then sock="$a"; fi
prev="$a"
done
case "$*" in
*"new-session -d "*)
[ -n "$sock" ] && : > "$sock"
;;
*"display-message -p "*)
echo "$FAKE_SERVER_PID"
;;
*"server-access "*)
# Simulate tmux < 3.3 — command unknown.
echo "unknown command: server-access" >&2
exit 1
;;
esac
exit 0
STUB
chmod +x "$PARTY_TMUX"
run "$PARTY_BIN" host noauth
[ "$status" -ne 0 ]
[[ "$output" == *"server-access"* ]]
[[ "$output" == *"3.3"* ]]
# Rollback: trap fired, party dir is gone, no roster.
party_dir="$PARTY_SOCKET_DIR/party-$USER:noauth.d"
[ ! -d "$party_dir" ]
# And we never widened the socket: chmod g+rw runs only after the
# capability probe passes. apply_party_perms_file lives on the file
# itself, so confirm the cmd stream stopped before that path.
! grep -q "server-access -aw $USER" "$TMUX_LOG" || \
fail "should not have attempted -aw before capability check"
}

45
tests/99-e2e.bats Normal file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bats
load 'helpers'
setup() {
setup_party_sandbox
require_party_group
}
teardown() { teardown_party_sandbox; }
@test "full lifecycle: host → invite → voice → mute → kick → close" {
other=$(pick_other_user)
[ -n "$other" ] || skip "no second user on this box"
"$PARTY_BIN" host e2e
# discover
run "$PARTY_BIN" list
[[ "$output" == *"e2e"* ]]
# invite
"$PARTY_BIN" invite "$other" -r
rec="$PARTY_SOCKET_DIR/party-$USER:e2e.d/roster"
sock=$(awk -F= '$1=="SOCKET"{print $2}' "$rec")
tmux -S "$sock" server-access -l | grep -q "^$other"
# voice → mute
"$PARTY_BIN" voice "$other"
"$PARTY_BIN" mute "$other"
# kick
"$PARTY_BIN" kick "$other"
# close
"$PARTY_BIN" close e2e
[ ! -f "$rec" ]
[ ! -S "$sock" ]
}
@test "all subcommands appear in --help" {
run "$PARTY_BIN" --help
for sub in host close join leave invite voice mute kick detach role list who status; do
[[ "$output" == *"$sub"* ]] || { echo "missing in --help: $sub"; false; }
done
}

136
tests/helpers.bash Normal file
View file

@ -0,0 +1,136 @@
# Bats helpers shared by every test file.
# `load 'helpers'` from a .bats file pulls these in.
# helpers.bash lives at <repo>/tests/helpers.bash, so repo root is one level
# up from its own directory. This avoids depending on .git, which may not
# be present on remote test hosts (e.g. rsynced without history).
PARTY_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/party"
# Per-test scratch dir. Bats sets BATS_TEST_TMPDIR but we centralize so
# party's overridable env vars all point under one tree.
setup_party_sandbox() {
# Pretend the test runner is NOT inside tmux. iTerm2's tmux -CC mode
# (or any nested tmux) sets $TMUX, which several party subcommands
# treat as "you're already attached somewhere." Tests that need
# $TMUX set re-export it themselves.
unset TMUX TMUX_PANE TMUX_TMPDIR
# USER may not be exported in all environments (e.g. some CI shells,
# zsh with clean env). party uses $USER under set -eu, so guarantee it.
export USER="${USER:-$(id -un)}"
export PARTY_TMP="$BATS_TEST_TMPDIR/party"
export PARTY_SOCKET_DIR="$PARTY_TMP/sockets"
mkdir -p "$PARTY_SOCKET_DIR"
chmod 0700 "$PARTY_SOCKET_DIR"
# No shared roster directory in this layout. Each per-party dir under
# PARTY_SOCKET_DIR carries its own roster file. validate_socket_dir_parent
# accepts an owner-only-writable parent like this one.
}
# Tear down any tmux servers we spawned under PARTY_SOCKET_DIR.
# Sockets live at ${PARTY_SOCKET_DIR}/party-USER:NAME.d/sock under the
# per-party-private-dir layout. The old glob (party-*) matched the
# *directory* and the [ -S ] filter dropped everything, so servers
# leaked across tests.
teardown_party_sandbox() {
if [ -d "${PARTY_SOCKET_DIR:-}" ]; then
for d in "$PARTY_SOCKET_DIR"/party-*.d; do
[ -d "$d" ] || continue
sock="$d/sock"
[ -S "$sock" ] || continue
tmux -S "$sock" kill-server 2>/dev/null || true
done
fi
}
# Pre-create the per-party private dir so roster_write can land its
# record inside. Mirrors cmd_host's mkdir -m 0700 + chmod 0750. Also
# creates a placeholder sock file so roster_read (which requires
# $d/sock to exist and be owned by the basename's HOST_USER) accepts
# the dir.
ensure_party_dir() {
local d="$PARTY_SOCKET_DIR/party-$1:$2.d"
mkdir -p "$d"
chmod 0750 "$d"
: > "$d/sock"
}
# Skip the test if the running user is not a member of TMUX_PARTY_GROUP.
require_party_group() {
local g="${TMUX_PARTY_GROUP:-party}"
id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$g" \
|| skip "user not in group '$g'; run: sudo usermod -aG $g $USER && relog"
}
# Print one username per line for users in group $1 — both supplementary
# members (4th /etc/group field) and primary-group members (passwd gid
# match). Linux / illumos / FreeBSD / OpenBSD use getent(1); macOS has
# no getent and stores users in Directory Services, so fall back to
# dscl(1) there. Returns silently if neither resolver is available or
# the group is unknown.
list_users_in_group() {
local g="$1" gid
if command -v getent >/dev/null 2>&1; then
gid=$(getent group "$g" 2>/dev/null | awk -F: '{print $3}')
[ -n "$gid" ] || return 0
{
getent group "$g" | awk -F: '{n=split($4,m,","); for(i=1;i<=n;i++) if(m[i]!="") print m[i]}'
getent passwd | awk -F: -v gid="$gid" '$4==gid {print $1}'
}
elif command -v dscl >/dev/null 2>&1; then
gid=$(dscl . -read "/Groups/$g" PrimaryGroupID 2>/dev/null \
| awk '/^PrimaryGroupID:/ {print $2}')
[ -n "$gid" ] || return 0
{
# Supplementary members. macOS records gid 20 (staff) members as
# primary-only, so this branch is usually empty for staff but
# populates for groups created with `dseditgroup -o edit -a`.
dscl . -read "/Groups/$g" GroupMembership 2>/dev/null \
| sed -n 's/^GroupMembership: //p' | tr ' ' '\n'
# Primary members: every user whose PrimaryGroupID matches.
dscl . -list /Users PrimaryGroupID | awk -v gid="$gid" '$2==gid {print $1}'
} | grep -v '^$' || true
fi
}
# Print the login shell of user $1, or nothing if unknown. Mirrors
# list_users_in_group's portability split: getent(1) where present,
# dscl(1) on macOS.
lookup_user_shell() {
local u="$1"
if command -v getent >/dev/null 2>&1; then
getent passwd "$u" 2>/dev/null | awk -F: '{print $7}'
elif command -v dscl >/dev/null 2>&1; then
dscl . -read "/Users/$u" UserShell 2>/dev/null | sed -n 's/^UserShell: //p'
fi
}
# Pick another invitable user on this box: a member of TMUX_PARTY_GROUP
# (supplementary or primary) other than $USER. Empty output if none.
# Uses group membership rather than a UID floor so it works across
# Linux (uid>=1000), illumos/Solaris (uid>=100), and BSD conventions.
# Excludes root and no-login system accounts (tmux server-access refuses
# to manage root, and system pseudo-users aren't sensible test peers).
pick_other_user() {
local g="${TMUX_PARTY_GROUP:-party}"
local candidates u shell
candidates=$(list_users_in_group "$g")
for u in $candidates; do
[ "$u" = "$USER" ] && continue
[ "$u" = "root" ] && continue
shell=$(lookup_user_shell "$u")
case "$shell" in
*false|*nologin|"") continue ;;
esac
printf '%s\n' "$u"
return 0
done
}
# Source ./party in library mode for pure-function tests.
load_party_lib() {
__PARTY_LIB_ONLY=1
# shellcheck disable=SC1090
. "$PARTY_BIN"
}