feat: auto-select the gating group; 'party' becomes the fallback

Bare 'party host' now walks staff, users, wheel, party and gates on
the first group the caller belongs to, announcing the pick. Boxes
where people already share a system group need no setup; the curated
'party' group remains the fallback and the explicit --group /
TMUX_PARTY_GROUP paths are unchanged. Caller membership is the only
probe (id -nG, portable everywhere); whether a guest is a member stays
an invite-time warning, where it can actually be answered.
This commit is contained in:
veg 2026-07-11 12:14:50 +00:00
parent 22606ea55d
commit 7770289d85
6 changed files with 165 additions and 22 deletions

View file

@ -12,7 +12,7 @@ Built for small, mutually trusted groups: a hacklab, a tech team, a circle of fr
- POSIX shell (`/bin/sh`)
- `tmux` ≥ 3.3 (for `server-access`) on `$PATH`, or named via `PARTY_TMUX`
- A shared system group (default name `party`, override via `TMUX_PARTY_GROUP`). Every host and guest must be a member.
- A shared system group. A party is gated on the first of `staff`, `users`, `wheel`, `party` the host belongs to (`--group <name>` or `TMUX_PARTY_GROUP` always win). Boxes where people already share one of those groups need no setup; creating a dedicated `party` group is the fallback recipe. Every host and guest must be a member of whichever group ends up gating the party.
- `write(1)`, optional: used to ping invited guests. Silently skipped when absent.
## Install
@ -70,7 +70,8 @@ Party metadata (host, server pid, group, creation time) lives in a `roster` file
| 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`. |
| `TMUX_PARTY_GROUP` | auto-selected | Shared group for socket access. When unset, `party host` auto-selects (see `PARTY_GROUP_CANDIDATES`); pin an existing group here, or pass `--group <name>` to `party host`. |
| `PARTY_GROUP_CANDIDATES` | `staff users wheel party` | Space-separated candidate list `party host` walks when `TMUX_PARTY_GROUP` is unset; gates on the first the host belongs to. |
| `PARTY_SOCKET_DIR` | `/tmp` | Where each party's private directory (socket + roster) is created. No whitespace in the path. |
| `PARTY_TMUX` | `tmux` | tmux binary to use. Override if tmux ≥ 3.3 lives at a non-standard path. |

View file

@ -26,11 +26,6 @@ Direction, not promises. Two rules govern everything below (README
## Later
- **Group ergonomics:** the default `party` group needs root and a
relog before anyone's first party. On boxes where everyone already
shares a group, `host` could offer (or default to) the caller's
primary group when `party` doesn't exist. Fits the trust model;
costs a little existence-confidentiality.
- **Matrix:** live validation on NetBSD and DragonFly; one
interactive attach/role/switch pass per release (automated coverage
is all non-interactive).

66
party
View file

@ -16,9 +16,25 @@ PARTY_VERSION=0.1.0
# `env -i` invocations). Every cmd_* path references it, so guarantee it
# now via id -un rather than crashing later under set -eu.
: "${USER:=$(id -un)}"
: "${TMUX_PARTY_GROUP:=party}"
# Group default. Remember whether the caller supplied TMUX_PARTY_GROUP
# explicitly: cmd_host auto-selects a candidate group only when it was
# not (an empty value counts as unset; an empty group name gates
# nothing).
if [ -n "${TMUX_PARTY_GROUP:-}" ]; then
TMUX_PARTY_GROUP_EXPLICIT=1
else
TMUX_PARTY_GROUP_EXPLICIT=0
TMUX_PARTY_GROUP=party
fi
: "${PARTY_SOCKET_DIR:=/tmp}"
: "${PARTY_TMUX:=tmux}"
# Candidate groups for hosting when none was named: the admin
# convention ('staff', also every macOS user's primary group), the
# classic shared-users group, the BSD admin group, then the
# recommended custom group as the fallback. Caller membership is the
# only test; whether *guests* are members is checked where it can
# actually be answered, at invite time (cmd_invite warns).
: "${PARTY_GROUP_CANDIDATES:=staff users wheel party}"
# Usage
# =====
@ -110,10 +126,28 @@ user_exists() {
id -u "$1" >/dev/null 2>&1
}
# Boolean: is user $1 a member of group $2 (supplementary or primary)?
# id -nG is the one portable membership probe: no getent on macOS, no
# dscl anywhere else, and it answers primary-GID membership too.
user_in_group() {
id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"
}
# Boolean: is the named user in TMUX_PARTY_GROUP?
user_in_party_group() {
user="$1"
id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"
user_in_group "$1" "$TMUX_PARTY_GROUP"
}
# Print the first PARTY_GROUP_CANDIDATES entry the caller belongs to;
# print nothing and return 1 when the caller is in none of them.
pick_host_group() {
for _pg_g in $PARTY_GROUP_CANDIDATES; do
if user_in_group "$USER" "$_pg_g"; then
printf '%s\n' "$_pg_g"
return 0
fi
done
return 1
}
# Soft assertion: caller is in TMUX_PARTY_GROUP. Fails loudly with
@ -132,6 +166,9 @@ One-time setup (as root), your OS may differ:
OpenBSD: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
illumos: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
Then log out and back in.
Or gate on any existing group you are already in:
party host --group <group>
EOF
exit 1
fi
@ -612,9 +649,10 @@ Usage: party host [name] [--group <name>]
name Party name (default: random).
--group <g> Group used to gate this party. The host must be a
member; only members can discover or join. Precedence:
--group flag > $TMUX_PARTY_GROUP env > default 'party'.
Pick any existing group (e.g. 'staff', 'users', 'wheel');
no install-time setup needed.
--group flag > $TMUX_PARTY_GROUP env > auto-selection:
the first of staff, users, wheel, party the host
belongs to (candidate list overridable via
$PARTY_GROUP_CANDIDATES).
EOF
exit 0 ;;
-*)
@ -626,14 +664,24 @@ EOF
esac
done
# Group precedence: --group flag > $TMUX_PARTY_GROUP env > default
# 'party' (set by the env-defaults block at the top of the file).
# The chosen group becomes the per-party perimeter: chgrp on the
# Group precedence: --group flag > $TMUX_PARTY_GROUP env >
# auto-selection over PARTY_GROUP_CANDIDATES ('party' is the last
# candidate, a fallback rather than a hardcoded default). The
# chosen group becomes the per-party perimeter: chgrp on the
# private dir, on the socket, and on the roster. No shared install
# directory, pick any existing group the host is in.
if [ -n "$group_arg" ]; then
TMUX_PARTY_GROUP="$group_arg"
export TMUX_PARTY_GROUP
elif [ "$TMUX_PARTY_GROUP_EXPLICIT" = 0 ]; then
auto_group=$(pick_host_group || true)
if [ -n "$auto_group" ]; then
TMUX_PARTY_GROUP="$auto_group"
export TMUX_PARTY_GROUP
echo "party host: gating on group '$TMUX_PARTY_GROUP' (auto-selected; --group overrides)."
fi
# In none of the candidates: keep the 'party' default so
# require_caller_in_group prints its remediation message.
fi
require_caller_in_group

20
party.1
View file

@ -1,5 +1,5 @@
.\" party - share a tmux session with the people you already work with
.Dd April 30, 2026
.Dd July 11, 2026
.Dt PARTY 1
.Os
.Sh NAME
@ -121,8 +121,12 @@ Group precedence:
.Fl -group
flag >
.Ev TMUX_PARTY_GROUP
> the default
.Ql party .
> auto-selection, which walks
.Ql staff ,
.Ql users ,
.Ql wheel ,
.Ql party
and gates on the first the host belongs to, announcing the pick.
.It Cm close Op Ar name
Tear down the party server and remove its per-party directory.
With no
@ -232,9 +236,9 @@ Show the caller's own state: hosting, attached, or idle.
.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
When unset, the group is auto-selected at host time; see
.Cm host .
Override to pin an existing group like
.Ql wheel ,
.Ql users ,
or
@ -243,6 +247,10 @@ or pass
.Fl -group Ar name
to
.Cm host .
.It Ev PARTY_GROUP_CANDIDATES
Space-separated candidate list for host-time group auto-selection.
Default:
.Dq staff users wheel party .
.It Ev PARTY_SOCKET_DIR
Where each party's per-party private directory (and its socket and
roster) is created.

View file

@ -0,0 +1,85 @@
#!/usr/bin/env bats
#
# Group auto-selection. When neither --group nor TMUX_PARTY_GROUP names
# a group, cmd_host walks PARTY_GROUP_CANDIDATES and gates on the first
# group the caller belongs to; 'party' is the last candidate, a
# fallback rather than a hardcoded default.
load 'helpers'
setup() {
setup_party_sandbox
load_party_lib
}
teardown() { teardown_party_sandbox; }
# Put a stub id(1) on PATH whose -nG answer is $1; every other
# invocation falls through to the real id.
stub_id_groups() {
mkdir -p "$PARTY_TMP/bin"
real_id=$(command -v id)
{ printf '#!/bin/sh\n'
printf 'case "$1" in\n'
printf ' -nG) echo "%s" ;;\n' "$1"
printf ' *) exec %s "$@" ;;\n' "$real_id"
printf 'esac\n'
} > "$PARTY_TMP/bin/id"
chmod +x "$PARTY_TMP/bin/id"
PATH="$PARTY_TMP/bin:$PATH"
}
@test "pick_host_group prefers staff over the party fallback" {
stub_id_groups "$USER staff party"
[ "$(pick_host_group)" = staff ]
}
@test "pick_host_group walks candidates in order: users beats wheel" {
stub_id_groups "wheel users"
[ "$(pick_host_group)" = users ]
}
@test "pick_host_group falls back to party" {
stub_id_groups "$USER dialout party"
[ "$(pick_host_group)" = party ]
}
@test "pick_host_group fails when the caller is in no candidate" {
stub_id_groups "$USER dialout"
run pick_host_group
[ "$status" -ne 0 ]
[ -z "$output" ]
}
@test "PARTY_GROUP_CANDIDATES env overrides the candidate list" {
stub_id_groups "$USER randomgrp"
PARTY_GROUP_CANDIDATES="randomgrp party" \
run pick_host_group
[ "$output" = randomgrp ]
}
@test "host auto-selects the caller's group, announces it, records it" {
unset TMUX_PARTY_GROUP
export PARTY_GROUP_CANDIDATES="$(id -gn) party"
run "$PARTY_BIN" host autosel
[ "$status" -eq 0 ]
[[ "$output" == *"gating on group '$(id -gn)'"* ]]
grep -qx "GROUP=$(id -gn)" \
"$PARTY_SOCKET_DIR/party-$USER:autosel.d/roster"
}
@test "explicit TMUX_PARTY_GROUP disables auto-selection" {
export TMUX_PARTY_GROUP="$(id -gn)"
run "$PARTY_BIN" host explicit-env
[ "$status" -eq 0 ]
[[ "$output" != *"auto-selected"* ]]
}
@test "host in no candidate group gets the remediation message" {
unset TMUX_PARTY_GROUP
export PARTY_GROUP_CANDIDATES="no-such-group-xyzzy"
run "$PARTY_BIN" host nogroup
[ "$status" -eq 1 ]
[[ "$output" == *"not in the 'party' group"* ]]
[[ "$output" == *"--group"* ]]
}

View file

@ -19,6 +19,12 @@ setup_party_sandbox() {
# zsh with clean env). party uses $USER under set -eu, so guarantee it.
export USER="${USER:-$(id -un)}"
# Pin the gating group. cmd_host auto-selects a candidate group when
# TMUX_PARTY_GROUP is unset, which would make bare `party host` calls
# environment-dependent; tests keep the historical explicit default.
# Suites that test auto-selection unset this themselves.
export TMUX_PARTY_GROUP="${TMUX_PARTY_GROUP:-party}"
export PARTY_TMP="$BATS_TEST_TMPDIR/party"
export PARTY_SOCKET_DIR="$PARTY_TMP/sockets"
mkdir -p "$PARTY_SOCKET_DIR"