diff --git a/skills/brainstorming/scripts/stop-server.sh b/skills/brainstorming/scripts/stop-server.sh index a6b94e6532..a268b02706 100755 --- a/skills/brainstorming/scripts/stop-server.sh +++ b/skills/brainstorming/scripts/stop-server.sh @@ -7,6 +7,8 @@ # kept so mockups can be reviewed later. SESSION_DIR="$1" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVER_SCRIPT="${SCRIPT_DIR}/server.cjs" if [[ -z "$SESSION_DIR" ]]; then echo '{"error": "Usage: stop-server.sh "}' @@ -16,9 +18,333 @@ fi STATE_DIR="${SESSION_DIR}/state" PID_FILE="${STATE_DIR}/server.pid" +canonical_dir() { + local dir="${1//\\//}" + if [[ ! -d "$dir" ]]; then + printf '%s' "$dir" + return + fi + + ( + cd "$dir" || exit 1 + pwd -W 2>/dev/null || pwd -P + ) +} + +physical_dir() { + local dir="${1//\\//}" + if [[ ! -d "$dir" ]]; then + printf '%s' "$dir" + return + fi + + ( + cd "$dir" || exit 1 + pwd -P + ) +} + +same_dir() { + local actual="$1" + local expected="$2" + local actual_canonical + local actual_physical + local expected_canonical + local expected_physical + + actual_canonical="$(canonical_dir "$actual")" + actual_physical="$(physical_dir "$actual")" + expected_canonical="$(canonical_dir "$expected")" + expected_physical="$(physical_dir "$expected")" + + [[ "$actual" == "$expected" || + "$actual" == "$expected_canonical" || + "$actual" == "$expected_physical" || + "$actual_canonical" == "$expected_canonical" || + "$actual_canonical" == "$expected_physical" || + "$actual_physical" == "$expected_canonical" || + "$actual_physical" == "$expected_physical" ]] +} + +canonical_path() { + local path="${1//\\//}" + local dir + local base + + dir="$(dirname "$path")" + base="$(basename "$path")" + if [[ ! -d "$dir" ]]; then + printf '%s' "$path" + return + fi + + printf '%s/%s' "$(canonical_dir "$dir")" "$base" +} + +physical_path() { + local path="${1//\\//}" + local dir + local base + + dir="$(dirname "$path")" + base="$(basename "$path")" + if [[ ! -d "$dir" ]]; then + printf '%s' "$path" + return + fi + + printf '%s/%s' "$(physical_dir "$dir")" "$base" +} + +strip_outer_quotes() { + local value="$1" + value="${value#\"}" + value="${value%\"}" + printf '%s' "$value" +} + +same_file() { + local actual="$1" + local expected="$2" + local actual_canonical + local actual_physical + local expected_canonical + local expected_physical + + actual_canonical="$(canonical_path "$actual")" + actual_physical="$(physical_path "$actual")" + expected_canonical="$(canonical_path "$expected")" + expected_physical="$(physical_path "$expected")" + + [[ "${actual//\\//}" == "${expected//\\//}" || + "${actual//\\//}" == "$expected_canonical" || + "${actual//\\//}" == "$expected_physical" || + "$actual_canonical" == "$expected_canonical" || + "$actual_canonical" == "$expected_physical" || + "$actual_physical" == "$expected_canonical" || + "$actual_physical" == "$expected_physical" ]] +} + +process_command() { + local pid="$1" + if [[ -r "/proc/$pid/cmdline" ]]; then + tr '\0' ' ' < "/proc/$pid/cmdline" + return + fi + if ps -p "$pid" -o args= >/dev/null 2>&1; then + ps -p "$pid" -o args= 2>/dev/null + return + fi + if ps -p "$pid" -o command= >/dev/null 2>&1; then + ps -p "$pid" -o command= 2>/dev/null + return + fi + ps -p "$pid" 2>/dev/null | sed -n '2p' +} + +process_environment() { + local pid="$1" + if [[ -r "/proc/$pid/environ" ]]; then + tr '\0' '\n' < "/proc/$pid/environ" + return + fi + if ps eww -p "$pid" >/dev/null 2>&1; then + ps eww -p "$pid" 2>/dev/null + return + fi + return 1 +} + +process_cwd() { + local pid="$1" + local cwd + if [[ -d "/proc/$pid/cwd" ]]; then + canonical_dir "/proc/$pid/cwd" + return + fi + if command -v lsof >/dev/null 2>&1; then + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)" + if [[ -n "$cwd" ]]; then + printf '%s' "$cwd" + return + fi + fi + return 1 +} + +brainstorm_dir_from_environment() { + local env_text="$1" + local value + + value="$(printf '%s\n' "$env_text" | sed -n 's/^BRAINSTORM_DIR=//p' | head -1)" + if [[ -n "$value" ]]; then + printf '%s' "$value" + return 0 + fi + + if [[ "$env_text" =~ (^|[[:space:]])BRAINSTORM_DIR=([^[:space:]]+)($|[[:space:]]) ]]; then + printf '%s' "${BASH_REMATCH[2]}" + return 0 + fi + + return 1 +} + +server_info_value() { + local key="$1" + local info_file="${STATE_DIR}/server-info" + + [[ -r "$info_file" ]] || return 1 + node -e ' + const fs = require("fs"); + const key = process.argv[1]; + const file = process.argv[2]; + const info = JSON.parse(fs.readFileSync(file, "utf8")); + if (info[key] === undefined || info[key] === null) process.exit(1); + process.stdout.write(String(info[key])); + ' "$key" "$info_file" 2>/dev/null +} + +server_info_owns_pid() { + local pid="$1" + local session_dir="$2" + local port + local state_dir + local listener_pid + local expected_state_dir="${session_dir}/state" + + command -v lsof >/dev/null 2>&1 || return 1 + + port="$(server_info_value port)" || return 1 + state_dir="$(server_info_value state_dir)" || return 1 + [[ "$port" =~ ^[0-9]+$ ]] || return 1 + same_dir "$state_dir" "$expected_state_dir" || return 1 + + while IFS= read -r listener_pid; do + [[ "$listener_pid" == "$pid" ]] && return 0 + done < <(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | sort -u) + + return 1 +} + +resolve_process_path() { + local arg="${1//\\//}" + local cwd="$2" + + if [[ "$arg" == /* || "$arg" =~ ^[A-Za-z]:/ ]]; then + printf '%s' "$arg" + else + printf '%s/%s' "$cwd" "$arg" + fi +} + +command_has_node_entrypoint() { + local command="${1//\\//}" + local entry="${2//\\//}" + local node_name + + for node_name in node node.exe; do + [[ "$command" == "$node_name $entry" || + "$command" == "$node_name $entry "* || + "$command" == "$node_name \"$entry\"" || + "$command" == "$node_name \"$entry\" "* || + "$command" == "$node_name '$entry'" || + "$command" == "$node_name '$entry' "* || + "$command" == *" $node_name $entry" || + "$command" == *" $node_name $entry "* || + "$command" == *" $node_name \"$entry\"" || + "$command" == *" $node_name \"$entry\" "* || + "$command" == *" $node_name '$entry'" || + "$command" == *" $node_name '$entry' "* || + "$command" == *"/$node_name $entry" || + "$command" == *"/$node_name $entry "* || + "$command" == *"/$node_name \"$entry\"" || + "$command" == *"/$node_name \"$entry\" "* || + "$command" == *"/$node_name '$entry'" || + "$command" == *"/$node_name '$entry' "* ]] && return 0 + done + + return 1 +} + +process_runs_server_script() { + local pid="$1" + local argv=() + local arg + local executable + local script_arg + local cwd + local command + local server_canonical + local server_physical + + if [[ -r "/proc/$pid/cmdline" ]]; then + while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do + argv+=("$(strip_outer_quotes "$arg")") + done < "/proc/$pid/cmdline" + + [[ ${#argv[@]} -ge 2 ]] || return 1 + executable="$(basename "${argv[0]//\\//}")" + executable="${executable%.exe}" + [[ "$executable" == "node" || "$executable" == "nodejs" ]] || return 1 + + script_arg="${argv[1]}" + [[ "$script_arg" == -* ]] && return 1 + + cwd="$(process_cwd "$pid")" || return 1 + same_file "$(resolve_process_path "$script_arg" "$cwd")" "$SERVER_SCRIPT" + return + fi + + command="$(process_command "$pid")" + server_canonical="$(canonical_path "$SERVER_SCRIPT")" + server_physical="$(physical_path "$SERVER_SCRIPT")" + + if command_has_node_entrypoint "$command" "$SERVER_SCRIPT" || + command_has_node_entrypoint "$command" "$server_canonical" || + command_has_node_entrypoint "$command" "$server_physical"; then + return 0 + fi + + command_has_node_entrypoint "$command" "server.cjs" || return 1 + cwd="$(process_cwd "$pid")" || return 1 + same_dir "$cwd" "$SCRIPT_DIR" +} + +owns_pid() { + local pid="$1" + local session_dir="$2" + local env + local actual_dir + + kill -0 "$pid" 2>/dev/null || return 1 + + process_runs_server_script "$pid" || return 1 + + if [[ -r "/proc/$pid/environ" ]]; then + env="$(tr '\0' '\n' < "/proc/$pid/environ")" || return 1 + actual_dir="$(brainstorm_dir_from_environment "$env")" || return 1 + same_dir "$actual_dir" "$session_dir" + return + fi + + if env="$(process_environment "$pid")" && + actual_dir="$(brainstorm_dir_from_environment "$env")"; then + same_dir "$actual_dir" "$session_dir" && return 0 + fi + + server_info_owns_pid "$pid" "$session_dir" +} + if [[ -f "$PID_FILE" ]]; then pid=$(cat "$PID_FILE") + if [[ ! "$pid" =~ ^[0-9]+$ ]] || ! owns_pid "$pid" "$SESSION_DIR"; then + rm -f "$PID_FILE" "${STATE_DIR}/server.log" + echo '{"status": "stale_pid"}' + exit 0 + fi + # Try to stop gracefully, fallback to force if still alive kill "$pid" 2>/dev/null || true @@ -32,6 +358,12 @@ if [[ -f "$PID_FILE" ]]; then # If still running, escalate to SIGKILL if kill -0 "$pid" 2>/dev/null; then + if ! owns_pid "$pid" "$SESSION_DIR"; then + rm -f "$PID_FILE" "${STATE_DIR}/server.log" + echo '{"status": "stale_pid"}' + exit 0 + fi + kill -9 "$pid" 2>/dev/null || true # Give SIGKILL a moment to take effect diff --git a/tests/brainstorm-server/stop-server.test.sh b/tests/brainstorm-server/stop-server.test.sh new file mode 100755 index 0000000000..4cc43b5275 --- /dev/null +++ b/tests/brainstorm-server/stop-server.test.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Tests for brainstorm stop-server.sh process ownership checks. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="${SUPERPOWERS_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}" +STOP_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/stop-server.sh" +SERVER_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/server.cjs" +TEST_DIR="${TMPDIR:-/tmp}/brainstorm stop test $$" + +passed=0 +failed=0 +UNRELATED_PID="" +FAKE_SERVER_PID="" +OTHER_SERVER_PID="" +SERVER_PID="" + +cleanup() { + for pid in "$UNRELATED_PID" "$FAKE_SERVER_PID" "$OTHER_SERVER_PID" "$SERVER_PID"; do + if [[ -n "${pid:-}" ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + done + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +pass() { + echo " PASS: $1" + passed=$((passed + 1)) +} + +fail() { + echo " FAIL: $1" + echo " $2" + failed=$((failed + 1)) +} + +wait_for_server_info() { + local dir="$1" + for _ in $(seq 1 50); do + if [[ -f "$dir/state/server-info" ]]; then + return 0 + fi + sleep 0.1 + done + return 1 +} + +echo "" +echo "=== Brainstorm stop-server.sh Tests ===" + +mkdir -p "$TEST_DIR" + +echo "--- Stale PID Safety ---" + +mkdir -p "$TEST_DIR/stale/state" +node -e "setTimeout(() => {}, 30000)" & +UNRELATED_PID=$! +echo "$UNRELATED_PID" > "$TEST_DIR/stale/state/server.pid" + +output="$(bash "$STOP_SCRIPT" "$TEST_DIR/stale" 2>&1)" + +if kill -0 "$UNRELATED_PID" 2>/dev/null; then + pass "stop-server.sh does not kill unrelated process from stale pid file" +else + fail "stop-server.sh does not kill unrelated process from stale pid file" \ + "Unrelated process $UNRELATED_PID was killed. Output: $output" + UNRELATED_PID="" +fi + +if [[ "$output" == '{"status": "stale_pid"}' ]]; then + pass "stop-server.sh reports stale pid files explicitly" +else + fail "stop-server.sh reports stale pid files explicitly" "Unexpected output: $output" +fi + +if [[ ! -f "$TEST_DIR/stale/state/server.pid" ]]; then + pass "stop-server.sh removes stale pid files" +else + fail "stop-server.sh removes stale pid files" "pid file still exists" +fi + +kill "$UNRELATED_PID" 2>/dev/null || true +wait "$UNRELATED_PID" 2>/dev/null || true +UNRELATED_PID="" + +echo "--- Fake server.cjs Argument Safety ---" + +mkdir -p "$TEST_DIR/fake/state" +BRAINSTORM_DIR="$TEST_DIR/fake" node -e "setTimeout(() => {}, 30000)" server.cjs & +FAKE_SERVER_PID=$! +echo "$FAKE_SERVER_PID" > "$TEST_DIR/fake/state/server.pid" + +output="$(bash "$STOP_SCRIPT" "$TEST_DIR/fake" 2>&1)" + +if kill -0 "$FAKE_SERVER_PID" 2>/dev/null; then + pass "stop-server.sh does not kill node processes that only mention server.cjs" +else + fail "stop-server.sh does not kill node processes that only mention server.cjs" \ + "Fake process $FAKE_SERVER_PID was killed. Output: $output" + FAKE_SERVER_PID="" +fi + +if [[ "$output" == '{"status": "stale_pid"}' ]]; then + pass "stop-server.sh treats fake server.cjs argv mentions as stale" +else + fail "stop-server.sh treats fake server.cjs argv mentions as stale" "Unexpected output: $output" +fi + +kill "$FAKE_SERVER_PID" 2>/dev/null || true +wait "$FAKE_SERVER_PID" 2>/dev/null || true +FAKE_SERVER_PID="" + +echo "--- Different Session Server Safety ---" + +mkdir -p "$TEST_DIR/target/state" "$TEST_DIR/target-other" +BRAINSTORM_DIR="$TEST_DIR/target-other" \ +BRAINSTORM_HOST="127.0.0.1" \ +BRAINSTORM_URL_HOST="localhost" \ +BRAINSTORM_OWNER_PID="" \ +BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \ + node "$SERVER_SCRIPT" > "$TEST_DIR/target-other/.server.log" 2>&1 & +OTHER_SERVER_PID=$! +echo "$OTHER_SERVER_PID" > "$TEST_DIR/target/state/server.pid" + +if ! wait_for_server_info "$TEST_DIR/target-other"; then + fail "different-session brainstorm server starts" "server-info was not written" +else + output="$(bash "$STOP_SCRIPT" "$TEST_DIR/target" 2>&1)" + sleep 0.3 + + if kill -0 "$OTHER_SERVER_PID" 2>/dev/null; then + pass "stop-server.sh does not stop a different-session brainstorm server" + else + fail "stop-server.sh does not stop a different-session brainstorm server" \ + "Different-session server $OTHER_SERVER_PID was killed. Output: $output" + OTHER_SERVER_PID="" + fi + + if [[ "$output" == '{"status": "stale_pid"}' ]]; then + pass "stop-server.sh treats different-session server pids as stale" + else + fail "stop-server.sh treats different-session server pids as stale" "Unexpected output: $output" + fi +fi + +kill "$OTHER_SERVER_PID" 2>/dev/null || true +wait "$OTHER_SERVER_PID" 2>/dev/null || true +OTHER_SERVER_PID="" + +echo "--- Real Server Shutdown ---" + +mkdir -p "$TEST_DIR/real" +BRAINSTORM_DIR="$TEST_DIR/real" \ +BRAINSTORM_HOST="127.0.0.1" \ +BRAINSTORM_URL_HOST="localhost" \ +BRAINSTORM_OWNER_PID="" \ +BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \ + node "$SERVER_SCRIPT" > "$TEST_DIR/real/.server.log" 2>&1 & +SERVER_PID=$! +mkdir -p "$TEST_DIR/real/state" +echo "$SERVER_PID" > "$TEST_DIR/real/state/server.pid" + +if ! wait_for_server_info "$TEST_DIR/real"; then + fail "real brainstorm server starts" "server-info was not written" +else + output="$(bash "$STOP_SCRIPT" "$TEST_DIR/real" 2>&1)" + sleep 0.3 + + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + pass "stop-server.sh still stops the real brainstorm server" + SERVER_PID="" + else + fail "stop-server.sh still stops the real brainstorm server" \ + "Server process $SERVER_PID is still alive. Output: $output" + fi + + if [[ "$output" == '{"status": "stopped"}' ]]; then + pass "stop-server.sh preserves stopped status for real servers" + else + fail "stop-server.sh preserves stopped status for real servers" "Unexpected output: $output" + fi +fi + +echo "" +echo "=== Results: $passed passed, $failed failed ===" + +if [[ $failed -gt 0 ]]; then + exit 1 +fi +exit 0