feat: party knock asks an invite-only party's host for an invite

party list already shows invite-only parties; knock closes the loop.
The knocker can't run any tmux command against the server, so the
channel is write(1), the same one invite uses toward guests, except
here delivery failure is surfaced: the ping is the whole feature.
This commit is contained in:
veg 2026-07-11 12:24:31 +00:00
parent 7770289d85
commit 6799a57405
5 changed files with 163 additions and 4 deletions

View file

@ -121,6 +121,7 @@ Three honest caveats, with the full detail in `man party`:
| `party close` | Tear down the party server and its roster entry. Host-only. | | `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). Read-only invitees always join passive. | | `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). Read-only invitees always join passive. |
| `party leave` | Detach and clean up the per-guest session. | | `party leave` | Detach and clean up the per-guest session. |
| `party knock <name>` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). |
| `party role [active\|passive\|switch]` | Flip your clients between guest and host session. No arg prints the current role. | | `party role [active\|passive\|switch]` | Flip your clients between guest and host session. No arg prints the current role. |
| `party --help` | Help text. | | `party --help` | Help text. |

View file

@ -12,10 +12,6 @@ Direction, not promises. Two rules govern everything below (README
## Next: small, high value ## Next: small, high value
- **`party knock <name>`:** an uninvited group member pings the host
via `write(1)`: "veg wants to join fiesta". `party list` already
shows invite-only parties; knock completes that loop. The feature
most likely to cause spontaneous pairing.
- **`party log`:** per-party transcript via `tmux pipe-pane` into the - **`party log`:** per-party transcript via `tmux pipe-pane` into the
per-party dir (group-readable so every attendee can grab a copy); per-party dir (group-readable so every attendee can grab a copy);
`party log --stop` ends it. "What did we do last night?" for `party log --stop` ends it. "What did we do last night?" for

65
party
View file

@ -55,6 +55,7 @@ Joining:
--passive lands in the host's session (mirrored view). --passive lands in the host's session (mirrored view).
Read-only invitees always join passive. Read-only invitees always join passive.
leave Detach from the party you joined. leave Detach from the party you joined.
knock [name] Ask an invite-only party's host for an invite.
role [active|passive|switch] role [active|passive|switch]
Flip your clients between guest session and host session. Flip your clients between guest session and host session.
@ -1099,6 +1100,69 @@ cmd_leave() {
[ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; } [ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; }
} }
# Send the knock ping. Factored out of cmd_knock so the write(1)
# interaction is testable: the CLI path to it needs a party hosted by
# a DIFFERENT user, and roster trust anchors on dir ownership, which a
# single-uid test run cannot fake.
knock_send() {
write "$1" 2>/dev/null <<EOF
$USER wants to join '$2'.
Run: party invite $USER --party $2
EOF
}
cmd_knock() {
name=
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
cat <<'EOF'
Usage: party knock [name]
Ask a party's host for an invite. For parties `party list` shows as
invite-only: you share the group, you're just not on the allowlist.
The host gets a write(1) ping naming you and the invite command.
EOF
exit 0 ;;
-*) echo "party knock: unknown flag '$1'" >&2; exit 2 ;;
*)
if [ -z "$name" ]; then name="$1"; shift
else echo "party knock: unexpected arg '$1'" >&2; exit 2; fi
;;
esac
done
if [ -n "$name" ]; then
find_party_by_name "$name" && rc=0 || rc=$?
case $rc in
0) ;;
1) echo "party: '$name' not found." >&2; exit 1 ;;
2) exit 1 ;; # find_party_by_name printed the ambiguity hint
esac
else
pick_live_party || exit 1
fi
if [ "$RR_HOST_USER" = "$USER" ]; then
echo "party knock: '$RR_PARTY_NAME' is your own party." >&2
exit 1
fi
if [ "$(party_conn_state "$RR_SOCKET")" = ok ]; then
echo "party knock: you are already invited. Run: party join $RR_PARTY_NAME"
exit 0
fi
# Unlike cmd_invite's courtesy ping, delivery failure is surfaced:
# the ping IS the feature. write(1) exits nonzero when the target
# has no tty or has messages disabled (mesg n).
if knock_send "$RR_HOST_USER" "$RR_PARTY_NAME"; then
echo "Knocked: $RR_HOST_USER was pinged about '$RR_PARTY_NAME'."
else
echo "party knock: could not reach $RR_HOST_USER (no tty, or messages disabled); ask them directly." >&2
exit 1
fi
}
# Resolve the party the caller hosts. If exactly one matches, sets RR_*; # Resolve the party the caller hosts. If exactly one matches, sets RR_*;
# if zero or multiple, error. Argument: optional party name to disambiguate. # if zero or multiple, error. Argument: optional party name to disambiguate.
resolve_authoritative_party() { resolve_authoritative_party() {
@ -1485,6 +1549,7 @@ dispatch() {
close) cmd_close "$@" ;; close) cmd_close "$@" ;;
join) cmd_join "$@" ;; join) cmd_join "$@" ;;
leave) cmd_leave "$@" ;; leave) cmd_leave "$@" ;;
knock) cmd_knock "$@" ;;
invite) cmd_invite "$@" ;; invite) cmd_invite "$@" ;;
voice|rw) cmd_voice "$@" ;; voice|rw) cmd_voice "$@" ;;
mute|ro) cmd_mute "$@" ;; mute|ro) cmd_mute "$@" ;;

16
party.1
View file

@ -20,6 +20,9 @@
.Nm .Nm
.Cm leave .Cm leave
.Nm .Nm
.Cm knock
.Op Ar name
.Nm
.Cm invite .Cm invite
.Ar user .Ar user
.Op Fl r .Op Fl r
@ -156,6 +159,19 @@ refuses session creation from read-only clients, so an active join is
not possible. not possible.
.It Cm leave .It Cm leave
Detach from the current party and clean up the per-guest session. Detach from the current party and clean up the per-guest session.
.It Cm knock Op Ar name
Ask the host of an invite-only party for an invite.
Sends a
.Xr write 1
message to the host naming the caller and the exact
.Cm invite
command.
Works for parties
.Cm list
shows as invite-only: the caller shares the party's group but is not
on its allowlist.
Fails when the host has no tty or has messages disabled with
.Xr mesg 1 .
.It Cm invite Ar user Op Fl r Op Fl -party Ar name .It Cm invite Ar user Op Fl r Op Fl -party Ar name
Add Add
.Ar user .Ar user

81
tests/65-knock.bats Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bats
#
# party knock: ask a party's host for an invite via write(1). The
# unauthorized-party resolution path needs a foreign-owned dir, which a
# single-uid run can't create (roster_read anchors on dir ownership),
# so the send is tested through knock_send with a stubbed write(1) and
# the CLI paths through parties we can fake: our own.
load 'helpers'
setup() {
setup_party_sandbox
load_party_lib
}
teardown() { teardown_party_sandbox; }
# Put a stub write(1) on PATH. $1 = exit status. Records its user
# argument and stdin under $PARTY_TMP.
stub_write() {
mkdir -p "$PARTY_TMP/bin"
cat > "$PARTY_TMP/bin/write" <<EOF
#!/bin/sh
printf '%s\n' "\$1" > "$PARTY_TMP/write.user"
cat > "$PARTY_TMP/write.msg"
exit $1
EOF
chmod +x "$PARTY_TMP/bin/write"
PATH="$PARTY_TMP/bin:$PATH"
}
@test "knock_send pings the host with the exact invite command" {
stub_write 0
knock_send alice fiesta
[ "$(cat "$PARTY_TMP/write.user")" = alice ]
grep -q "$USER wants to join 'fiesta'" "$PARTY_TMP/write.msg"
grep -q "party invite $USER --party fiesta" "$PARTY_TMP/write.msg"
}
@test "knock_send propagates write(1) failure" {
stub_write 1
run knock_send alice fiesta
[ "$status" -ne 0 ]
}
@test "knock on an unknown party fails with not-found" {
run "$PARTY_BIN" knock nowhere
[ "$status" -eq 1 ]
[[ "$output" == *"not found"* ]]
}
@test "knock rejects unknown flags and extra args" {
run "$PARTY_BIN" knock --frob
[ "$status" -eq 2 ]
run "$PARTY_BIN" knock a b
[ "$status" -eq 2 ]
}
@test "knock on your own party is refused" {
# Live-looking own party via an always-ok tmux stub (rc 0, silent).
cat > "$PARTY_TMP/tmux-ok" <<'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$PARTY_TMP/tmux-ok"
export PARTY_TMUX="$PARTY_TMP/tmux-ok"
ensure_party_dir "$USER" mine
d="$PARTY_SOCKET_DIR/party-$USER:mine.d"
cat > "$d/roster" <<EOF
HOST_USER=$USER
PARTY_NAME=mine
SOCKET=$d/sock
SERVER_PID=12345
GROUP=$TMUX_PARTY_GROUP
CREATED=2026-07-11T00:00:00Z
EOF
chmod 0640 "$d/roster"
run "$PARTY_BIN" knock mine
[ "$status" -eq 1 ]
[[ "$output" == *"your own party"* ]]
}