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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Test scratch
tests/.tmp/

77
Makefile Normal file
View file

@ -0,0 +1,77 @@
PREFIX =
DESTDIR =
BINDIR =
MANDIR =
INSTALL = install
# Default install prefix follows the host OS convention (override on the
# command line: `make PREFIX=...`):
# illumos, Solaris (uname -s = SunOS): /opt/party
# Linux, *BSD, macOS, everywhere else: /usr/local
# POSIX make has no ifeq or $(shell), so the OS dispatch lives in the
# install/uninstall recipes themselves. BINDIR and MANDIR remain
# independently overridable. Plain `=` rather than `?=` keeps illumos
# /usr/bin/make happy (it predates POSIX-2008's conditional assignment).
#
# Beyond copying files, `make install` also wires the install bindir into
# the system PATH so `party` and `man party` work from a fresh login shell
# without -M or rc-file edits. The wiring is done by tools/post-install.sh
# and is OS-aware: on illumos /opt/party is appended to /etc/default/login;
# on Linux/BSD/macOS at /usr/local nothing needs doing; for non-standard
# prefixes elsewhere the script prints the export lines to add manually.
# `make uninstall` reverses the wiring symmetrically. Both steps are
# skipped when DESTDIR is set (package staging context).
all:
@echo "party is a single shell script; nothing to build."
@echo "Run 'make install' (PREFIX auto-detects from uname -s)."
install:
@prefix='$(PREFIX)'; \
[ -n "$$prefix" ] || case `uname -s` in \
SunOS) prefix=/opt/party ;; \
*) prefix=/usr/local ;; \
esac; \
bindir='$(BINDIR)'; [ -n "$$bindir" ] || bindir="$$prefix/bin"; \
mandir='$(MANDIR)'; [ -n "$$mandir" ] || mandir="$$prefix/share/man/man1"; \
echo "Installing to $$prefix (bin=$$bindir, man=$$mandir)"; \
$(INSTALL) -d "$(DESTDIR)$$bindir" "$(DESTDIR)$$mandir" && \
$(INSTALL) -m 0755 party "$(DESTDIR)$$bindir/party" && \
$(INSTALL) -m 0644 party.1 "$(DESTDIR)$$mandir/party.1" && \
{ [ -n "$(DESTDIR)" ] || sh tools/post-install.sh install "$$prefix"; }
uninstall:
@prefix='$(PREFIX)'; \
[ -n "$$prefix" ] || case `uname -s` in \
SunOS) prefix=/opt/party ;; \
*) prefix=/usr/local ;; \
esac; \
bindir='$(BINDIR)'; [ -n "$$bindir" ] || bindir="$$prefix/bin"; \
mandir='$(MANDIR)'; [ -n "$$mandir" ] || mandir="$$prefix/share/man/man1"; \
rm -f "$(DESTDIR)$$bindir/party" "$(DESTDIR)$$mandir/party.1"; \
[ -n "$(DESTDIR)" ] || sh tools/post-install.sh uninstall "$$prefix"
check:
sh -n party
@if command -v bats >/dev/null 2>&1; then \
bats tests; \
else \
echo "bats not installed; skipping tests."; \
fi
# Static analysis. Deliberately a local-only target rather than a bats
# test: shellcheck reads bytes, not the OS, so running it across the
# remote test matrix would be redundant. SC3065/SC3067 flag `-k`, `-O`,
# and `-L` as non-strict-POSIX, but every /bin/sh in our matrix (dash,
# bash, mksh, ksh) honors them and rewriting through stat would be a
# portability step backward.
lint:
@if command -v shellcheck >/dev/null 2>&1; then \
shellcheck -s sh -e SC3065,SC3067 party; \
else \
echo "shellcheck not installed; install it locally to lint."; \
exit 1; \
fi
.PHONY: all install uninstall check lint

124
README.md Normal file
View file

@ -0,0 +1,124 @@
# tmux-party
Share a live tmux session with your buddies on a wide range of UNICES!
`tmux-party` ships `party`, a POSIX shell utility. One person hosts a party, and others join the fun! Wanna pair-program, mentor, debug an outage together, or demo a workflow? `ssh` in, `party join` & off you go!
Built for small, mutually trusted groups: a hacklab, a tech team, a circle of friends, not strangers across the internet. Inspired by epic sessions running GNU's `screen -x` way back when, now on BSD's `tmux` modern codebase!
## Requirements
- POSIX shell (`/bin/sh`)
- `tmux` ≥ 3.3 (for `server-access`), running when you invoke the script
- A shared system group (default name `party`, override via `TMUX_PARTY_GROUP`). Every host and guest must be a member.
- `write(1)`, optional: used to ping invited guests. Silently skipped when absent.
## Install
```sh
make install # installs to /usr/local
make install PREFIX=$HOME/.local # or any prefix you prefer
```
Installs `party` to `$(PREFIX)/bin` and `party.1` to `$(PREFIX)/share/man/man1`; `man party` is the full reference. `make uninstall` removes both.
On illumos/Solaris the default prefix is `/opt/party` and the install needs root (`pfexec make install`) for the system bindir and `/etc/default/login`. The Makefile is plain POSIX make (works with `/usr/bin/make`, BSD make, GNU make).
## Quick start
One-time setup, as root. Create the group and add members:
```sh
groupadd party
usermod -aG party alice
usermod -aG party bob
# alice and bob log out and back in so the new group takes effect
```
Host a party, then invite from inside it:
```sh
party host standup # spawn a dedicated party tmux server
party invite alice # add alice to the allowlist (read/write)
party invite bob -r # invite bob as a read-only watcher
```
Guests join:
```sh
party list # see what's running on this host
party join standup # attach (auto-attaches if only one party is running)
party leave # detach and clean up
```
The `party` group is the partyline: filesystem access to party sockets is shared among members, and `tmux server-access` decides who is actually let into a given session. No shared install directory is needed: each party keeps its own state under its own per-party private directory.
## How it works
`party host` spawns a *dedicated* tmux server for the party (separate from your personal tmux) so inviting someone exposes only the party, not your other sessions. Each party gets a private directory at `${PARTY_SOCKET_DIR}/party-${USER}:${PARTY_NAME}.d/`, holding the tmux socket and a join/leave notification helper. Guests never type the path; `party join` reads the roster and handles the attach.
Two gates protect every party, and both must pass:
- **Filesystem gate**: the per-party directory is `chgrp`'d to `TMUX_PARTY_GROUP` at mode `0750`; the socket is `g+rw`. Non-members can't traverse in or reach the socket.
- **Auth gate**: `tmux server-access` allowlist, editable only by the host. A user who clears the FS gate but isn't on the allowlist is rejected at the protocol layer.
Party metadata (host, server pid, group, creation time) lives in a `roster` file beside the socket. It's bookkeeping only, as security-relevant fields (host user, socket path) are re-derived from the directory on every read, never trusted from the file.
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `TMUX_PARTY_GROUP` | `party` | Shared group for socket access. Override to reuse an existing group (`wheel`, `users`, `staff`), or pass `--group <name>` to `party host`. |
| `PARTY_SOCKET_DIR` | `/tmp` | Where each party's private directory (socket + roster) is created. |
| `PARTY_TMUX` | `tmux` | tmux binary to use. Override if tmux ≥ 3.3 lives at a non-standard path. |
## Support
The mechanism is identical everywhere: group ownership and mode bits plus `tmux server-access`. No ACL syscalls involved, so the trust path is one piece of code, not a per-OS matrix.
| Platform | Status |
|---|---|
| Linux | supported, validated on Debian 13 & Alpine v3.21 |
| FreeBSD UFS or ZFS | supported, validated on FreeBSD 15.0 ZFS |
| illumos LX-branded zones | supported, validated on 4.4 BrandZ linux |
| illumos native, Solaris | supported, validated on omnios-r151058 |
| NetBSD, DragonFly | unconfirmed, should work, tests welcome |
| OpenBSD | supported, validated on OpenBSD 7.8 |
| macOS | supported, validated on Darwin 25.5.0 |
## Security
`party` assumes you already know and trust everyone you add to the group; it is not a public access-control system. Both gates above protect each party, and the **auth gate** (`tmux server-access`) is authoritative, with the filesystem gate as defense in depth.
Three honest caveats, with the full detail in `man party`:
- On ACL-enabled filesystems (ZFS, HFS+/APFS), inherited ACLs can override the mode bits, so the FS gate is best-effort. The auth gate still holds.
- A party's *existence* is not hidden the way *attaching* is. That confidentiality rides on the FS gate.
- Active guests share one tmux server, where any write-capable invitee is trusted by design. Invite read-only (`-r`) if you do not trust that far.
## Status
`party` is pre-release. The on-disk layout (per-party directory, roster format, socket path) may change between commits, with no upgrade migration, so close running parties before pulling. A stale roster pointing at an unreadable socket simply won't be discovered; old tmux servers left running must be killed by hand.
## Reference
| Subcommand | Effect |
|---|---|
| `party host [name]` | Spawn a dedicated party tmux server. Only the host is on the allowlist. |
| `party invite alice [-r]` | Add alice to the allowlist. `-r` / `--read-only` invites as a watcher. |
| `party voice alice` | Promote alice to read/write. |
| `party mute alice` | Demote alice to read-only. |
| `party kick alice` | Revoke alice's invite, disconnect her, kill her guest session. |
| `party detach alice` | Disconnect alice; keep her on the allowlist. |
| `party list` | List live parties on this host. |
| `party who [--short]` | Show invited and attached users for the current party. |
| `party status` | Show the caller's own state: hosting, attached, or idle. |
| `party close` | Tear down the party server and its roster entry. Host-only. |
| `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). |
| `party leave` | Detach and clean up the per-guest session. |
| `party role [active\|passive\|switch]` | Flip your clients between guest and host session. No arg prints the current role. |
| `party --help` | Help text. |
## Credits
Written by veg and kol3rby in the context of the [UNIX Social Club](https://club.unix.rocks/). Issues and contributions welcome.

49
deploy.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/sh
# Sync the deployable surface to a remote host for `make install`. Pins the
# include list to the seven items the project ships: Makefile, party, party.1,
# README.md, tools/post-install.sh, and tests/ (so the host can run `make
# check` as a target-OS smoke test). This is the dev-rsync variant. The
# release-tarball variant strips tests/ as well; not implemented here.
#
# Usage:
# ./deploy.sh <host>[:path] [extra rsync args]
#
# Path defaults to `tmux-party-src` (relative to the remote login user's
# home directory) when omitted.
#
# Examples:
# ./deploy.sh user@domain.tld # → :tmux-party-src
# ./deploy.sh user@domain.tld:dev/tmux-party-src # explicit path
# ./deploy.sh user@domain.tld -n # dry-run, default path
set -eu
dest="${1:-}"
[ -n "$dest" ] || {
echo "usage: $0 <host>[:path] [extra rsync args]" >&2
exit 2
}
shift
# Default path when only a host (no ":path") is given. Bare-host form
# is the common case for casual sync; an explicit path overrides it.
case "$dest" in
*:*) ;;
*) dest="$dest:tmux-party-src" ;;
esac
cd "$(dirname "$0")"
exec rsync -av --delete "$@" \
--include='/Makefile' \
--include='/party' \
--include='/party.1' \
--include='/README.md' \
--include='/tools/' \
--include='/tools/post-install.sh' \
--include='/tests/' \
--include='/tests/*.bats' \
--include='/tests/helpers.bash' \
--include='/tests/run-remote.sh' \
--exclude='*' \
./ "$dest"

1324
party Normal file

File diff suppressed because it is too large Load diff

435
party.1 Normal file
View file

@ -0,0 +1,435 @@
.\" party - share a tmux session with the people you already work with
.Dd April 30, 2026
.Dt PARTY 1
.Os
.Sh NAME
.Nm party
.Nd share a tmux session with the people you already work with
.Sh SYNOPSIS
.Nm
.Cm host
.Op Ar name
.Op Fl -group Ar group
.Nm
.Cm close
.Op Ar name
.Nm
.Cm join
.Op Ar name
.Op Fl -passive
.Nm
.Cm leave
.Nm
.Cm invite
.Ar user
.Op Fl r
.Nm
.Cm voice
.Ar user
.Nm
.Cm mute
.Ar user
.Nm
.Cm kick
.Ar user
.Nm
.Cm detach
.Ar user
.Nm
.Cm role
.Op Cm active | passive | switch
.Nm
.Cm list
.Nm
.Cm who
.Op Fl -short
.Nm
.Cm status
.Sh DESCRIPTION
.Nm
hosts a live
.Xr tmux 1
session for other users on the same UNIX host, or joins one
someone else is hosting, with the ergonomics of
.Ql screen -x
and none of the copy/paste friction.
Use it to pair-program, mentor, debug an outage together, or demo a
workflow live: same terminal, no screen-sharing software, no
intermediary servers.
.Pp
Each party runs on its own dedicated
.Xr tmux 1
server, distinct from the host's personal
.Xr tmux 1 .
This keeps
.Xr tmux 1
.Cm server-access
scoped: inviting someone to your party does not expose your other
sessions.
Two gates protect each party.
The
.Em filesystem gate
is universal across every supported platform: the per-party directory
is
.Xr chgrp 1 Ns 'd
to
.Ev TMUX_PARTY_GROUP
at mode 0750 so only group members can traverse into it; the socket
itself is
.Ql g+rw .
The
.Em auth gate
is
.Xr tmux 1
.Cm server-access :
a user who passes the FS gate but is not on the allowlist is rejected
at the protocol layer.
Both gates must pass.
.Pp
.Nm
is designed for small, mutually trusted groups: a hacklab, a tech
team, a circle of friends, not strangers across the internet.
The auth gate is authoritative; the FS gate is defense in depth.
.Sh SUBCOMMANDS
.Bl -tag -width Ds
.It Cm host Op Ar name Op Fl -group Ar group
Spawn a dedicated party
.Xr tmux 1
server.
With no
.Ar name ,
a random one is generated.
Only the host is on the allowlist; invite others explicitly with
.Cm invite .
Group precedence:
.Fl -group
flag >
.Ev TMUX_PARTY_GROUP
> the default
.Ql party .
.It Cm close Op Ar name
Tear down the party server and remove its per-party directory.
With no
.Ar name ,
closes the only party the caller hosts.
Host-only.
.It Cm join Op Ar name Op Fl -passive
Join a party.
With no
.Ar name
and exactly one party visible, auto-attaches; otherwise presents a
numbered picker.
By default joins in
.Em active
mode (your own session group with an independent window cursor).
.Fl -passive
attaches read-only to the host's session (mirrored view).
The read-only boundary is set at attach time and is not toggled by
later
.Cm role
changes.
.It Cm leave
Detach from the current party and clean up the per-guest session.
.It Cm invite Ar user Op Fl r
Add
.Ar user
to the allowlist as read/write.
.Fl r
or
.Fl -read-only
invites as a watcher.
Best-effort
.Xr write 1
ping is sent if available.
Host-only.
.It Cm voice Ar user
Promote
.Ar user
to read/write.
Alias:
.Cm rw .
Host-only.
.It Cm mute Ar user
Demote
.Ar user
to read-only.
Alias:
.Cm ro .
Host-only.
.It Cm kick Ar user
Revoke
.Ar user Ns 's
invite, disconnect their client, and kill their guest session.
Host-only.
.It Cm detach Ar user
Disconnect
.Ar user Ns 's
client and kill their guest session, but keep them on the allowlist.
Host-only.
.It Cm role Op Cm active | passive | switch
Flip your clients between the guest session and the host session.
With no argument, prints the current role for each of your clients.
Session-level only: the read-only boundary set at attach time
.Po see
.Cm join Fl -passive
.Pc
is not toggled by role changes
.Po
.Xr tmux 1
.Cm switch-client
has no read-only flag
.Pc .
.It Cm list
List live parties on this host from the roster.
.It Cm who Op Fl -short
Show invited and attached users for the current party.
.Fl -short
emits a compact form suitable for a status-line widget.
.It Cm status
Show the caller's own state: hosting, attached, or idle.
.El
.Sh ENVIRONMENT
.Bl -tag -width "PARTY_SOCKET_DIR"
.It Ev TMUX_PARTY_GROUP
Shared system group used for socket access.
Default
.Ql party .
Override to reuse an existing group like
.Ql wheel ,
.Ql users ,
or
.Ql staff ,
or pass
.Fl -group Ar name
to
.Cm host .
.It Ev PARTY_SOCKET_DIR
Where each party's per-party private directory (and its socket and
roster) is created.
Default
.Pa /tmp .
.It Ev PARTY_TMUX
.Xr tmux 1
binary to use.
Default
.Ql tmux .
Override if
.Xr tmux 1
\(>= 3.3 lives at a non-standard path.
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/
Per-party private directory, mode 0750, group
.Ev TMUX_PARTY_GROUP .
.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/sock
The
.Xr tmux 1
socket, mode
.Ql g+rw .
.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/roster
Bookkeeping:
.Va HOST_USER ,
.Va PARTY_NAME ,
.Va SOCKET ,
.Va SERVER_PID ,
.Va GROUP ,
.Va CREATED .
Security-relevant fields are re-derived from the directory itself on
every read, not trusted from the file.
.It Pa ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/.party-notify
Auto-generated helper that fans
.Cm display-message
out to every attached client on join/leave.
.El
.Sh EXIT STATUS
.Bl -tag -width Ds
.It 0
Success.
.It 1
Operational failure (no such party, not a member of the group, socket
gone, ambiguous match, etc.).
.It 2
Usage error (unknown subcommand or flag, missing argument, invalid
party name).
.It 13
.Cm join :
caller is not on the allowlist for the named party.
.El
.Sh EXAMPLES
One-time setup, as root:
.Bd -literal -offset indent
groupadd party
usermod -aG party alice
usermod -aG party bob
.Ed
.Pp
Have alice and bob log out and back in so the new group takes effect.
.Pp
Host a party named
.Ql debug-the-deploy
and invite alice as a watcher and bob as a co-driver:
.Bd -literal -offset indent
$ party host debug-the-deploy
$ party invite alice -r
$ party invite bob
.Ed
.Pp
On bob's side, join the only running party on this host:
.Bd -literal -offset indent
$ party join
.Ed
.Pp
On alice's side, join read-only:
.Bd -literal -offset indent
$ party join debug-the-deploy --passive
.Ed
.Pp
Reuse an existing group instead of creating a new one:
.Bd -literal -offset indent
$ party host debug-the-deploy --group staff
.Ed
.Sh SECURITY
The OS group is the perimeter,
.Xr tmux 1 Ns 's
.Cm server-access
allowlist is the per-user filter.
Both are universal across the supported platforms (Linux, FreeBSD,
NetBSD, OpenBSD, illumos, Solaris, macOS), so the mechanism is one
liner:
.Xr chgrp 1
+
.Xr chmod 1 .
No ACL syscalls are invoked.
.Pp
Trust is anchored in unforgeable primitives:
.Xr mkdir 2
and
.Xr bind 2
set the owner to the calling effective UID, and
.Xr chown 1
requires root.
A group member who plants a forged directory cannot fake its
ownership, cannot redirect the socket path, and cannot forge a socket
elsewhere.
.Pp
.Nm
is designed for small, mutually trusted groups: a hacklab, a tech
team, a circle of friends, not strangers across the internet.
It assumes you already know and trust the people you are adding to
the group; it does not try to be a public access-control system.
.Sh CAVEATS
On filesystems with NFSv4-style inherited ACLs (macOS HFS+/APFS,
FreeBSD/illumos ZFS), an ACE on
.Ev PARTY_SOCKET_DIR
can override the mode bits
.Nm
sets, widening access (e.g.\& an inherited
.Ql everyone@:rx
survives our
.Ql chmod 0750 )
or narrowing it (e.g.\& a
.Ql deny everyone:
blocks group members our
.Xr chgrp 1
tried to admit).
The script does not invoke
.Xr getfacl 1
or
.Xr getextattr 8
to verify effective access; re-introducing per-platform ACL plumbing
would unwind the simplification this design commits to.
The FS gate is therefore best-effort on those filesystems; the
auth gate
.Po
.Xr tmux 1
.Cm server-access
.Pc
remains authoritative regardless.
The default
.Ev PARTY_SOCKET_DIR Ns = Ns Pa /tmp
rarely has ACL trouble in practice; if you deploy on a custom dir
with restrictive inherited ACLs, expect to handle traversal at the
ACL layer for your intended group members.
Strangers stay out via
.Cm server-access
either way.
.Pp
.Cm server-access
decides who may
.Em attach ,
not who can see that a party
.Em exists .
Hiding a party's existence, meaning the host, its name, group, and
creation time recorded in the
.Pa roster ,
falls to the FS gate, not the auth gate: on an ACL-shadowed dir where the
mode bits do not hold, a non-member may read that metadata even though
.Cm server-access
still blocks the join.
The
.Cm list
subcommand re-checks group membership before printing, as defense in
depth, but treat discovery as confidential only insofar as the filesystem
enforces it.
.Pp
An active
.Cm join
lands you in your own
.Ql __party_guest_<you>
session on the shared server.
Any write-capable invitee can create sessions there, including one named
for someone else; if they pre-create yours, your next active
.Cm join
attaches to it.
This is inside the trust boundary by design, not a defended one;
invitees are people you already trust.
If you do not trust an invitee that far, invite them read-only
.Pq Fl r ,
which cannot create sessions, or not at all.
.Pp
.Nm
requires
.Xr tmux 1
\(>= 3.3 for
.Cm server-access .
Older
.Xr tmux 1
versions are refused at host time rather than degrading to the FS
gate alone.
.Pp
The
.Cm role
subcommand can flip a client between the guest session and the host
session, but it cannot change the read-only state of an attached
client;
.Xr tmux 1
.Cm switch-client
has no equivalent of
.Cm attach Fl r .
The watcher boundary is set at
.Cm join Fl -passive
time and is fixed for the life of that client.
.Sh SEE ALSO
.Xr tmux 1 ,
.Xr screen 1 ,
.Xr write 1 ,
.Xr chgrp 1 ,
.Xr chmod 1
.Sh HISTORY
.Nm
was written for the UNIX Social Club
.Pq Lk https://club.unix.rocks/ ,
inspired by many years of collective working sessions using
.Ql screen -x
across collectives.
.Sh AUTHORS
.Nm
was written by
.An veg
and
.An kol3rby .
See the
.Pa party
script header for additional credits.
Issues and contributions welcome!

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"
}

161
tools/post-install.sh Executable file
View file

@ -0,0 +1,161 @@
#!/bin/sh
# tools/post-install.sh — wire system PATH after `make install`, and
# reverse it on `make uninstall`. Idempotent on both sides.
#
# Usage: tools/post-install.sh <install|uninstall> <prefix>
#
# Behavior by host OS (uname -s):
#
# SunOS (illumos / Solaris) with prefix not in /usr or /usr/local:
# Add or remove $prefix/bin to/from the PATH= line of /etc/default/login
# (override with LOGIN_DEFAULTS=...). illumos man(1) derives the manpath
# from PATH by replacing trailing /bin with /share/man, so wiring PATH
# is enough for `man party` to work without -M.
#
# Linux / *BSD / macOS with prefix in {/usr, /usr/local}:
# Already on default PATH and MANPATH. No-op.
#
# Anything else (custom PREFIX on Linux/BSD/macOS, or unknown OS):
# Print the export lines the user needs to add to a shell rc.
# Do not mutate any system file.
#
# DESTDIR-aware: the Makefile skips this script entirely when DESTDIR is set
# (package staging context — mutating /etc on the build host is wrong).
set -eu
usage() {
echo "usage: $0 <install|uninstall> <prefix>" >&2
exit 2
}
[ $# -eq 2 ] || usage
action=$1
prefix=$2
case "$action" in install|uninstall) ;; *) usage ;; esac
[ -n "$prefix" ] || usage
bindir="$prefix/bin"
mandir="$prefix/share/man"
LOGIN_DEFAULTS=${LOGIN_DEFAULTS:-/etc/default/login}
uname_s=$(uname -s)
# Print install- or uninstall-side instructions for setting/clearing PATH and
# MANPATH in a shell rc. Used when we don't (or won't) edit any system file.
print_manual_instructions() {
verb=$1 bd=$2 md=$3 osname=$4
if [ "$osname" = "Linux" ] || [ "$osname" = "FreeBSD" ] || [ "$osname" = "NetBSD" ] \
|| [ "$osname" = "OpenBSD" ] || [ "$osname" = "DragonFly" ] || [ "$osname" = "Darwin" ]; then
header="PREFIX is non-standard on $osname; not modifying system files."
else
header="Unrecognized OS '$osname'; not modifying system files."
fi
if [ "$verb" = "install" ]; then
cat <<EOF
$header
For 'party' and 'man party' to work in fresh shells, add to your shell rc:
export PATH="$bd:\$PATH"
export MANPATH="$md:\$MANPATH"
EOF
else
cat <<EOF
$header
If you previously added these to your shell rc, remove them:
export PATH="$bd:\$PATH"
export MANPATH="$md:\$MANPATH"
EOF
fi
}
# Idempotently add ($verb=install) or remove ($verb=uninstall) $needle as a
# component of the PATH= line in $file. Returns 0 in all expected cases —
# only awk/IO failures bubble up as nonzero. Prints what it did.
edit_path_line() {
verb=$1 file=$2 needle=$3
if [ ! -f "$file" ]; then
echo "$file does not exist; skipping PATH wiring." >&2
return 0
fi
if [ ! -w "$file" ]; then
echo "$file is not writable (run as root); skipping PATH wiring." >&2
return 0
fi
tmpf="$file.party.tmp.$$"
# awk exits 0 if it modified the file, 1 if no change was needed.
if awk -v verb="$verb" -v needle="$needle" '
BEGIN { changed = 0; path_seen = 0 }
/^PATH=/ && !path_seen {
path_seen = 1
val = substr($0, 6)
n = split(val, parts, ":")
present = 0
for (i = 1; i <= n; i++) if (parts[i] == needle) present = 1
if (verb == "install") {
if (present) { print; next }
if (val == "") print "PATH=" needle
else print $0 ":" needle
changed = 1
next
}
if (verb == "uninstall") {
if (!present) { print; next }
out = ""
for (i = 1; i <= n; i++) {
if (parts[i] == needle) continue
out = (out == "" ? parts[i] : out ":" parts[i])
}
print "PATH=" out
changed = 1
next
}
}
{ print }
END { exit changed ? 0 : 1 }
' "$file" > "$tmpf"; then
ts=$(date +%Y%m%d%H%M%S)
cp -p "$file" "$file.before-party.$ts"
mv "$tmpf" "$file"
case "$verb" in
install) echo "Added $needle to PATH in $file (backup: $file.before-party.$ts)" ;;
uninstall) echo "Removed $needle from PATH in $file (backup: $file.before-party.$ts)" ;;
esac
else
rm -f "$tmpf"
case "$verb" in
install) echo "$needle already in PATH in $file; nothing to do." ;;
uninstall) echo "$needle not in PATH in $file; nothing to do." ;;
esac
fi
return 0
}
case "$uname_s" in
SunOS)
case "$prefix" in
/usr|/usr/local)
echo "$prefix is on the default illumos PATH; no system wiring needed."
;;
*)
edit_path_line "$action" "$LOGIN_DEFAULTS" "$bindir"
;;
esac
;;
Linux|FreeBSD|NetBSD|OpenBSD|DragonFly|Darwin)
case "$prefix" in
/usr|/usr/local)
: # already on PATH and MANPATH on these platforms
;;
*)
print_manual_instructions "$action" "$bindir" "$mandir" "$uname_s"
;;
esac
;;
*)
print_manual_instructions "$action" "$bindir" "$mandir" "$uname_s"
;;
esac