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:
parent
22606ea55d
commit
7770289d85
6 changed files with 165 additions and 22 deletions
|
|
@ -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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
66
party
|
|
@ -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
20
party.1
|
|
@ -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.
|
||||||
|
|
|
||||||
85
tests/45-group-select.bats
Normal file
85
tests/45-group-select.bats
Normal 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"* ]]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue