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`) - POSIX shell (`/bin/sh`)
- `tmux` ≥ 3.3 (for `server-access`) on `$PATH`, or named via `PARTY_TMUX` - `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. - `write(1)`, optional: used to ping invited guests. Silently skipped when absent.
## Install ## Install
@ -70,7 +70,8 @@ Party metadata (host, server pid, group, creation time) lives in a `roster` file
| Variable | Default | Purpose | | 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_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. | | `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 ## 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 - **Matrix:** live validation on NetBSD and DragonFly; one
interactive attach/role/switch pass per release (automated coverage interactive attach/role/switch pass per release (automated coverage
is all non-interactive). 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 # `env -i` invocations). Every cmd_* path references it, so guarantee it
# now via id -un rather than crashing later under set -eu. # now via id -un rather than crashing later under set -eu.
: "${USER:=$(id -un)}" : "${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_SOCKET_DIR:=/tmp}"
: "${PARTY_TMUX:=tmux}" : "${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 # Usage
# ===== # =====
@ -110,10 +126,28 @@ user_exists() {
id -u "$1" >/dev/null 2>&1 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? # Boolean: is the named user in TMUX_PARTY_GROUP?
user_in_party_group() { user_in_party_group() {
user="$1" user_in_group "$1" "$TMUX_PARTY_GROUP"
id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$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 # 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 OpenBSD: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
illumos: 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. Then log out and back in.
Or gate on any existing group you are already in:
party host --group <group>
EOF EOF
exit 1 exit 1
fi fi
@ -612,9 +649,10 @@ Usage: party host [name] [--group <name>]
name Party name (default: random). name Party name (default: random).
--group <g> Group used to gate this party. The host must be a --group <g> Group used to gate this party. The host must be a
member; only members can discover or join. Precedence: member; only members can discover or join. Precedence:
--group flag > $TMUX_PARTY_GROUP env > default 'party'. --group flag > $TMUX_PARTY_GROUP env > auto-selection:
Pick any existing group (e.g. 'staff', 'users', 'wheel'); the first of staff, users, wheel, party the host
no install-time setup needed. belongs to (candidate list overridable via
$PARTY_GROUP_CANDIDATES).
EOF EOF
exit 0 ;; exit 0 ;;
-*) -*)
@ -626,14 +664,24 @@ EOF
esac esac
done done
# Group precedence: --group flag > $TMUX_PARTY_GROUP env > default # Group precedence: --group flag > $TMUX_PARTY_GROUP env >
# 'party' (set by the env-defaults block at the top of the file). # auto-selection over PARTY_GROUP_CANDIDATES ('party' is the last
# The chosen group becomes the per-party perimeter: chgrp on the # 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 # private dir, on the socket, and on the roster. No shared install
# directory, pick any existing group the host is in. # directory, pick any existing group the host is in.
if [ -n "$group_arg" ]; then if [ -n "$group_arg" ]; then
TMUX_PARTY_GROUP="$group_arg" TMUX_PARTY_GROUP="$group_arg"
export TMUX_PARTY_GROUP 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 fi
require_caller_in_group 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 .\" party - share a tmux session with the people you already work with
.Dd April 30, 2026 .Dd July 11, 2026
.Dt PARTY 1 .Dt PARTY 1
.Os .Os
.Sh NAME .Sh NAME
@ -121,8 +121,12 @@ Group precedence:
.Fl -group .Fl -group
flag > flag >
.Ev TMUX_PARTY_GROUP .Ev TMUX_PARTY_GROUP
> the default > auto-selection, which walks
.Ql party . .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 .It Cm close Op Ar name
Tear down the party server and remove its per-party directory. Tear down the party server and remove its per-party directory.
With no With no
@ -232,9 +236,9 @@ Show the caller's own state: hosting, attached, or idle.
.Bl -tag -width "PARTY_SOCKET_DIR" .Bl -tag -width "PARTY_SOCKET_DIR"
.It Ev TMUX_PARTY_GROUP .It Ev TMUX_PARTY_GROUP
Shared system group used for socket access. Shared system group used for socket access.
Default When unset, the group is auto-selected at host time; see
.Ql party . .Cm host .
Override to reuse an existing group like Override to pin an existing group like
.Ql wheel , .Ql wheel ,
.Ql users , .Ql users ,
or or
@ -243,6 +247,10 @@ or pass
.Fl -group Ar name .Fl -group Ar name
to to
.Cm host . .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 .It Ev PARTY_SOCKET_DIR
Where each party's per-party private directory (and its socket and Where each party's per-party private directory (and its socket and
roster) is created. 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. # zsh with clean env). party uses $USER under set -eu, so guarantee it.
export USER="${USER:-$(id -un)}" 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_TMP="$BATS_TEST_TMPDIR/party"
export PARTY_SOCKET_DIR="$PARTY_TMP/sockets" export PARTY_SOCKET_DIR="$PARTY_TMP/sockets"
mkdir -p "$PARTY_SOCKET_DIR" mkdir -p "$PARTY_SOCKET_DIR"