diff --git a/.gitignore b/.gitignore index bee8a64..1491be9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +.cf_session* +*.Chatfile +Chatfile diff --git a/SKILL.md b/AGENTS.md similarity index 77% rename from SKILL.md rename to AGENTS.md index 9a51307..b90eb1f 100644 --- a/SKILL.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Minimal agent collaboration via shared text files. No HTTP, no dependencies. ## The `cf` Tool -Single command for all chatfile operations. State stored in `.cf_session` (no env vars needed). +Single command for all chatfile operations. State is dynamically isolated per terminal or process (e.g., `.cf_session.pts_8` or `.cf_session.sid_1234`) to prevent agent overlap (no env vars needed). ### Room Management @@ -17,12 +17,17 @@ Single command for all chatfile operations. State stored in `.cf_session` (no en # Create a new room (append-only) cf create-room myproject # Creates myproject.Chatfile cf create-room # Creates Chatfile +cf create-room -g myproject # Creates global room in ~/.chatfiles/ -# List available rooms +# List available rooms (local and global) cf list-rooms -# Register with a room (get unique name) +# Delete a room (handles +a attr removal) +cf delete-room myproject.Chatfile + +# Register with a room (get unique name, searches local & global) cf register myproject.Chatfile +cf register myproject # Auto-resolves to myproject.Chatfile # Output: swift-raven-1234 # Join the room (announces entry) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 7ff5cbd..9a40c4c 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,22 @@ A bash tool for managing chatrooms via Chatfiles. ### Installation ```bash +# Install to ~/.local/bin +./install.sh + +# Or manually chmod +x cf -# Optionally add to PATH or create alias +# Add to PATH or create alias ``` ### Commands **Room Management** -- `cf create-room [name]` - Create a room (`name.Chatfile` or `Chatfile`), attempts to set append-only -- `cf list-rooms` - List available rooms in current directory -- `cf register ` - Register with a chatfile (generates a unique name like `swift-fox-1234`) +- `cf create-room [name]` - Create a local room (`name.Chatfile` or `Chatfile`), attempts to set append-only +- `cf create-room -g name` - Create a global room in `~/.chatfiles/` +- `cf list-rooms` - List local and global rooms +- `cf delete-room ` - Delete a room (handles `+a` attr removal via sudo) +- `cf register ` - Register with a chatfile (searches local & `~/.chatfiles/`, auto-resolves `.Chatfile` extension) - `cf join` - Join the room (announces entry) - `cf leave` - Leave the room (announces exit) @@ -58,4 +64,4 @@ cf await cf leave ``` -Session state is stored in `.cf_session` in the current directory. +Session state is dynamically isolated per terminal or process (e.g., `.cf_session.pts_8` or `.cf_session.sid_1234`) in the current directory to prevent agent overlap. Global rooms are stored in `~/.chatfiles/`. diff --git a/cf b/cf index fbe87ec..77dd6fd 100755 --- a/cf +++ b/cf @@ -20,9 +20,38 @@ GLOBAL_DIR="$HOME/.chatfiles" CMD="${1:-help}" shift 2>/dev/null || true +get_session_id() { + # 1. Use controlling TTY if available (stable across invocations) + local tty + tty=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' \n') + if [ -n "$tty" ] && [ "$tty" != "?" ]; then + echo "${tty//\//_}" + return + fi + # 2. Use PPID (parent shell PID) — stable for all commands from the same shell + if [ -n "$PPID" ] && [ "$PPID" != "1" ]; then + echo "ppid_${PPID}" + return + fi + # 3. Last resort: own SID (may vary per invocation in detached contexts) + local sid + sid=$(ps -o sid= -p $$ 2>/dev/null | tr -d ' \n') + if [ -n "$sid" ]; then + echo "sid_${sid}" + return + fi + echo "$$" +} + +SESSION_FILE="${CF_SESSION_FILE:-.cf_session.$(get_session_id)}" + find_session() { - if [ -f ".cf_session" ]; then + if [ -f "$SESSION_FILE" ]; then + echo "$SESSION_FILE" + elif [ -f ".cf_session" ]; then echo ".cf_session" + elif [ -f "$HOME/$SESSION_FILE" ]; then + echo "$HOME/$SESSION_FILE" elif [ -f "$HOME/.cf_session" ]; then echo "$HOME/.cf_session" else @@ -36,12 +65,14 @@ load_session() { read -r CHATFILE < "$sf" read -r MYNAME < <(sed -n '2p' "$sf") JOINED=$(sed -n '3p' "$sf") + LASTLINE=$(sed -n '4p' "$sf") + LASTLINE="${LASTLINE:-0}" } save_session() { local sf - sf=$(find_session) || sf=".cf_session" - printf '%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" > "$sf" + sf=$(find_session) || sf="$SESSION_FILE" + printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > "$sf" } case "$CMD" in @@ -183,12 +214,21 @@ case "$CMD" in noun="${NOUNS[RANDOM % ${#NOUNS[@]}]}" suffix=$((RANDOM % 9000 + 1000)) MYNAME="${adj}-${noun}-${suffix}" - grep -q "^${MYNAME}:" "$CHATFILE" 2>/dev/null || break - ((attempts++)) + # Check both message format (name:) and join/leave format ([name ...]) + if ! grep -q -e "^${MYNAME}:" -e "\\[${MYNAME} " "$CHATFILE" 2>/dev/null; then + break + fi + ((attempts++)) || true done + if [ $attempts -ge 100 ]; then + echo "Failed to generate unique name after 100 attempts" >&2 + exit 1 + fi + JOINED="" - printf '%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" > .cf_session + LASTLINE=0 + printf '%s\n%s\n%s\n%s\n' "$CHATFILE" "$MYNAME" "$JOINED" "$LASTLINE" > "$SESSION_FILE" echo "$MYNAME" ;; @@ -196,8 +236,9 @@ case "$CMD" in load_session [ -n "$JOINED" ] && { echo "Already joined as $MYNAME" >&2; exit 1; } JOINED="yes" - save_session printf '[%s joined]\n' "$MYNAME" >> "$CHATFILE" + LASTLINE=$(wc -l < "$CHATFILE") + save_session echo "Joined as $MYNAME" ;; @@ -214,26 +255,54 @@ case "$CMD" in [ -z "$1" ] && { echo "Usage: cf send \"message\"" >&2; exit 1; } load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - printf '%s: %s\n' "$MYNAME" "$1" >> "$CHATFILE" + # Strip newlines to preserve one-message-one-line invariant + msg="${1//$'\n'/ }" + printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" ;; await|a|wait|w) load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - LAST=$(tail -n 1 "$CHATFILE" | cut -d: -f1) - if [ "$LAST" = "$MYNAME" ]; then - tail -f -n 0 "$CHATFILE" | head -n 1 - else - tail -n 1 "$CHATFILE" - fi + exec 3< <(tail -n +$((LASTLINE + 1)) -f "$CHATFILE") + TAILPID=$! + trap 'kill $TAILPID 2>/dev/null' EXIT + while IFS= read -r line <&3; do + ((LASTLINE++)) + case "$line" in + \[*) save_session; continue ;; + "${MYNAME}:"*) save_session; continue ;; + esac + save_session + echo "$line" + break + done + kill $TAILPID 2>/dev/null + exec 3<&- + trap - EXIT ;; send-await|sa) [ -z "$1" ] && { echo "Usage: cf send-await \"message\"" >&2; exit 1; } load_session [ -z "$JOINED" ] && { echo "Must join first: cf join" >&2; exit 1; } - printf '%s: %s\n' "$MYNAME" "$1" >> "$CHATFILE" - tail -f -n 0 "$CHATFILE" | head -n 1 + msg="${1//$'\n'/ }" + printf '%s: %s\n' "$MYNAME" "$msg" >> "$CHATFILE" + exec 3< <(tail -n +$((LASTLINE + 1)) -f "$CHATFILE") + TAILPID=$! + trap 'kill $TAILPID 2>/dev/null' EXIT + while IFS= read -r line <&3; do + ((LASTLINE++)) + case "$line" in + \[*) save_session; continue ;; + "${MYNAME}:"*) save_session; continue ;; + esac + save_session + echo "$line" + break + done + kill $TAILPID 2>/dev/null + exec 3<&- + trap - EXIT ;; read|cat) @@ -277,7 +346,7 @@ Messaging: Info: cf status Show current session -State stored in .cf_session (current directory) +State stored in .cf_session. or .cf_session (current directory) Global rooms stored in ~/.chatfiles/ EOF ;; diff --git a/test_bugs.sh b/test_bugs.sh new file mode 100755 index 0000000..bc71fef --- /dev/null +++ b/test_bugs.sh @@ -0,0 +1,345 @@ +#!/bin/bash +# Bug reproduction tests for cf +# Each test isolates in a temp dir with a fake HOME + +set -u + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 +ORIGINAL_DIR="$(pwd)" +CF_PATH="$(realpath ./cf)" +TEST_DIR="" + +setup() { + TEST_DIR=$(mktemp -d) + cd "$TEST_DIR" + export HOME="$TEST_DIR/home" + export CF_SESSION_FILE=".cf_session" + mkdir -p "$HOME" +} + +teardown() { + cd "$ORIGINAL_DIR" + if [ -d "$TEST_DIR" ]; then + find "$TEST_DIR" -type f \( -name "*.Chatfile" -o -name "Chatfile" \) \ + -exec sudo chattr -a {} \; 2>/dev/null || true + rm -rf "$TEST_DIR" + fi +} + +run_test() { + TESTS_RUN=$((TESTS_RUN + 1)) + local name="$1" + shift + local output + if output=$("$@" 2>&1); then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e "${GREEN}BUG CONFIRMED${NC} $name" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e "${RED}NOT REPRODUCED${NC} $name" + [ -n "$output" ] && echo "$output" | sed 's/^/ /' + fi +} + +# ============================================================================ +# Bug 1: Register silently uses duplicate name when all 100 attempts exhaust +# ============================================================================ +# The loop tries 100 times, but if all collide it exits without error and +# uses the last (colliding) name. We simulate by pre-filling the chatfile +# with every possible adj-noun-suffix combination that RANDOM could produce +# during the test. +# +# Approach: patch the check — we can't control RANDOM, but we CAN show the +# failure mode by making grep always match (every name "exists"). + +bug1_collision_crash() { + setup + + # Create chatfile manually (no chattr +a) so we can fill it + printf '[system]: Chatroom. Format: Name: msg. Append only.\n' > Chatfile + + # The retry loop uses ((attempts++)) which with set -e crashes on first + # collision because ((0++)) evaluates to 0, which bash treats as exit + # code 1. Fill half the namespace to trigger a collision quickly. + ADJS=(swift bold calm keen sage wild bright dark quick slow) + NOUNS=(fox owl raven wolf bear hawk crane lynx deer hare) + for adj in "${ADJS[@]}"; do + for noun in "${NOUNS[@]}"; do + for suffix in $(seq 1000 5499); do + printf '%s-%s-%s: taken\n' "$adj" "$noun" "$suffix" + done + done + done >> Chatfile + + # Try registering repeatedly until we hit a collision + local crashed=false + for i in $(seq 1 30); do + rm -f .cf_session + "$CF_PATH" register Chatfile >/dev/null 2>&1 + if [ $? -ne 0 ]; then + crashed=true + break + fi + done + + if $crashed; then + teardown + return 0 # Bug confirmed: register crashes on name collision + fi + + teardown + echo "No collision in 30 attempts (unlikely but possible)" + return 1 +} + +# ============================================================================ +# Bug 2: await misparses join/leave/system lines +# ============================================================================ +# When the last line is [name joined], cut -d: -f1 returns the whole line +# (no colon), so it never equals MYNAME. await returns the join message +# immediately instead of waiting. + +bug2_await_returns_join_message() { + setup + "$CF_PATH" create-room 2>/dev/null || true + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + + # Last line in chatfile is now "[name joined]" + # await should wait for a NEW message, but instead... + local output + output=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + # Bug: await returned immediately with the join message + if echo "$output" | grep -q "joined"; then + teardown + return 0 # Bug confirmed + fi + + teardown + echo "await correctly waited (or timed out without returning join msg)" + return 1 +} + +# ============================================================================ +# Bug 3: await returns already-seen messages +# ============================================================================ +# If the last message is from another agent, await returns it immediately +# even if you've already seen it. There's no read cursor. + +bug3_await_returns_stale_message() { + setup + "$CF_PATH" create-room 2>/dev/null || true + + # Agent A + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + "$CF_PATH" send "I need help" + + # Simulate another agent's reply by appending directly + printf 'other-agent-1234: Here is help\n' >> Chatfile + + # Agent A calls await — gets the reply (first time, correct) + local first + first=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + # Agent A calls await AGAIN — should wait for a NEW message + # but bug: it returns the same stale message immediately + local second + second=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + if [ "$second" = "$first" ] && echo "$second" | grep -q "Here is help"; then + teardown + return 0 # Bug confirmed: same message returned twice + fi + + teardown + echo "await correctly waited on second call" + return 1 +} + +# ============================================================================ +# Bug 4: Newline injection breaks one-message-one-line invariant +# ============================================================================ +# printf '%s: %s\n' doesn't sanitize newlines in the message. + +bug4_newline_injection() { + setup + "$CF_PATH" create-room 2>/dev/null || true + local name + name=$("$CF_PATH" register Chatfile) + "$CF_PATH" join >/dev/null + + local before_count + before_count=$(wc -l < Chatfile) + + # Send a message with an embedded newline + "$CF_PATH" send $'Hello\nEvil-Agent: rm -rf /' + + local after_count + after_count=$(wc -l < Chatfile) + + # Should have added 1 line, but bug adds 2 + local added=$((after_count - before_count)) + + if [ "$added" -gt 1 ]; then + # Verify the injected line looks like it's from another sender + if tail -1 Chatfile | grep -q "Evil-Agent:"; then + teardown + return 0 # Bug confirmed: message injection via newline + fi + fi + + teardown + echo "Newlines were sanitized (added $added lines)" + return 1 +} + +# ============================================================================ +# Bug 5: Register uniqueness check ignores join/leave messages +# ============================================================================ +# grep -q "^${MYNAME}:" only matches "name: message" format. +# A name that only appears in [name joined] is not detected as taken. +# We demonstrate by forcing a known name into join format only. + +bug5_join_not_checked() { + setup + "$CF_PATH" create-room 2>/dev/null || true + + # Manually write a join message for a specific name + printf '[swift-fox-1234 joined]\n' >> Chatfile + + # Test the pattern used in the fixed cf register: + local name="swift-fox-1234" + if grep -q -e "^${name}:" -e "\\[${name} " Chatfile; then + teardown + echo "grep correctly detected the name in join format" + return 1 # Bug is fixed + fi + + # The name IS in the file but grep doesn't see it + teardown + return 0 # Bug confirmed +} + +# ============================================================================ +# Bug 6: await skips unread messages +# ============================================================================ +bug6_await_skips_messages() { + setup + "$CF_PATH" create-room 2>/dev/null || true + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + + echo "other: msg 1" >> Chatfile + echo "other: msg 2" >> Chatfile + + local first + first=$("$CF_PATH" await) + + local second + second=$(timeout 2 "$CF_PATH" await 2>/dev/null) || true + + if [ -z "$second" ] || ! echo "$second" | grep -q "msg 2"; then + echo "First was: $first" + echo "Second was: $second" + teardown + return 0 # Bug confirmed: skipped unread message + fi + + teardown + echo "await correctly read the next unread message" + return 1 +} + +# ============================================================================ +# Bug 7: tail -f orphaned process leak +# ============================================================================ +bug7_tail_f_leak() { + setup + "$CF_PATH" create-room 2>/dev/null || true + "$CF_PATH" register Chatfile >/dev/null + "$CF_PATH" join >/dev/null + + # Start await in background + "$CF_PATH" await >/dev/null & + local await_pid=$! + + sleep 0.5 + echo "other: msg 1" >> Chatfile + + # Wait for await to exit + wait "$await_pid" 2>/dev/null || true + sleep 0.5 + + # Check if tail -f is still running with our Chatfile + if pgrep -f "tail -f -n 0.*Chatfile" >/dev/null; then + pkill -f "tail -f -n 0.*Chatfile" + teardown + return 0 # Bug confirmed: tail -f leaked + fi + + teardown + echo "No orphaned tail -f processes found" + return 1 +} + +# ============================================================================ +# Bug 8: State isolation (overlapping session files) +# ============================================================================ +bug8_state_isolation() { + setup + unset CF_SESSION_FILE # Test the actual isolation logic + "$CF_PATH" create-room 2>/dev/null || true + + local name1 + name1=$("$CF_PATH" register Chatfile) + + # Run register in a new session (different SID/TTY) + if command -v setsid >/dev/null; then + local name2 + name2=$(setsid "$CF_PATH" register Chatfile) + + local status_name + status_name=$("$CF_PATH" status | grep Session | cut -d' ' -f2) + + # If the original session's status shows the second name, they overlapped + if [ "$name1" != "$status_name" ] && [ "$name2" = "$status_name" ]; then + teardown + return 0 # Bug confirmed: overlapping session state + fi + fi + + teardown + echo "Sessions properly isolated" + return 1 +} + +# ============================================================================ +# Run All Bug Reproduction Tests +# ============================================================================ + +echo "Bug Reproduction Tests for cf" +echo "==============================" +echo "" + +run_test "Bug 1: register crashes on first name collision (set -e + arithmetic)" bug1_collision_crash +run_test "Bug 2: await returns join/leave messages instead of waiting" bug2_await_returns_join_message +run_test "Bug 3: await returns already-seen messages (no read cursor)" bug3_await_returns_stale_message +run_test "Bug 4: Newline injection breaks message format" bug4_newline_injection +run_test "Bug 5: Uniqueness check ignores join/leave format" bug5_join_not_checked +run_test "Bug 6: await skips unread messages" bug6_await_skips_messages +run_test "Bug 7: tail -f orphaned process leak" bug7_tail_f_leak +run_test "Bug 8: State isolation (overlapping session files)" bug8_state_isolation + +echo "" +echo "==============================" +echo "Total: $TESTS_RUN" +echo -e "Confirmed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Not reproduced: ${RED}$TESTS_FAILED${NC}" diff --git a/test_cf.sh b/test_cf.sh index a694525..e6cafa6 100755 --- a/test_cf.sh +++ b/test_cf.sh @@ -26,6 +26,7 @@ setup() { cd "$TEST_DIR" # Create mock global dir to avoid touching real ~/.chatfiles export HOME="$TEST_DIR/home" + export CF_SESSION_FILE=".cf_session" mkdir -p "$HOME" }