From 7770289d85b6a04116a688c870e35519954210e3 Mon Sep 17 00:00:00 2001 From: veg Date: Sat, 11 Jul 2026 12:14:50 +0000 Subject: [PATCH] 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. --- README.md | 5 ++- ROADMAP.md | 5 --- party | 66 +++++++++++++++++++++++++---- party.1 | 20 ++++++--- tests/45-group-select.bats | 85 ++++++++++++++++++++++++++++++++++++++ tests/helpers.bash | 6 +++ 6 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 tests/45-group-select.bats diff --git a/README.md b/README.md index 02ab8db..741ab15 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 ` 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 ` 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. | diff --git a/ROADMAP.md b/ROADMAP.md index 4dbe3ce..3b8d123 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/party b/party index d42e7aa..05b6519 100755 --- a/party +++ b/party @@ -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 EOF exit 1 fi @@ -612,9 +649,10 @@ Usage: party host [name] [--group ] name Party name (default: random). --group 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 diff --git a/party.1 b/party.1 index 0c02314..a14144e 100644 --- a/party.1 +++ b/party.1 @@ -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. diff --git a/tests/45-group-select.bats b/tests/45-group-select.bats new file mode 100644 index 0000000..880417e --- /dev/null +++ b/tests/45-group-select.bats @@ -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"* ]] +} diff --git a/tests/helpers.bash b/tests/helpers.bash index 4f17da5..e537751 100644 --- a/tests/helpers.bash +++ b/tests/helpers.bash @@ -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"