diff --git a/.devcontainer/client-slack-supervise.sh b/.devcontainer/client-slack-supervise.sh index 95d9fa17..7b943363 100755 --- a/.devcontainer/client-slack-supervise.sh +++ b/.devcontainer/client-slack-supervise.sh @@ -1,47 +1,21 @@ #!/usr/bin/env bash -# Self-healing supervisor for the gateway client sessions (client-slack- -# tmux sessions). +# Self-healing supervisor for client-slack- tmux sessions. # -# Launched by .devcontainer/entrypoint.sh (pi) or .oh/scripts/gateway.sh with -# HARNESS / LOG exported, plus: -# pi (default): PI_SLACK_* tokens, BRIDGE_ENTRY, RECOVERY_ENTRY. -# hermes: GATEWAY_BACKEND=hermes and SUPERVISE_CMD= — a GENERIC crash-restart loop with NONE of the -# pi-specific stale-ctx/lock/recovery logic below. +# Pi uses one isolated persistent session directory and always launches with +# --continue, so a post-compaction reconnect reopens the compacted active path +# instead of starting a bare session. The bridge package owns the authenticated +# Slack compact request, exact chat/thread acknowledgement, ctx.compact call, +# and Slack disconnect. This supervisor owns only process/session continuity. # -# Why this exists (pi): pi-messenger-bridge binds its long-lived Slack socket to a -# session-scoped pi ctx. When pi replaces the session (compaction, fork, switch, -# reload) that ctx goes stale and every subsequent Slack message throws -# "extension ctx is stale after session replacement or reload" — the package has -# no recovery hook, so the bridge silently stops responding while the process -# keeps running. This loop restarts pi on that signature (and on any crash), -# clearing the single-instance lock each time so the fresh process reconnects. +# Compact completion crosses that boundary through a per-launch Unix-domain +# socket. Its mode-0600 listener is ready before Pi starts and accepts the +# completion byte only from the exact supervised Pi PID, authenticated with +# Linux SO_PEERCRED plus direct-child/session/group identity. The pathname is +# rendezvous metadata, not a secret: tool children may discover it but have a +# different peer PID and are rejected. # -# pi runs INTERACTIVELY, attached to the tmux pane's real TTY (stdin + stdout are -# the pane pty), with NO `| tee` pipe and NO `--mode rpc`. On a TTY pi resolves to -# interactive mode, so the loaded UI extensions (prompt-suggester, pi-recap) -# RENDER in the TUI instead of serializing every setStatus/setWidget call to -# stdout as `extension_ui_request` JSON frames — that flood is an rpc-mode -# artifact. Interactive pi also stays alive at idle (it is a REPL), so the -# session no longer needs `--mode rpc` to avoid the idle exit. -# -# Logging is out-of-band (we lost `tee`): pi's stderr is redirected to $LOG, and -# the entrypoint additionally mirrors the visible pane into $LOG (ANSI-stripped) -# via `tmux pipe-pane`. Both feed the stale-ctx watchdog below. -# -# A 2nd --extension loads the standalone Codex retry-recovery extension -# (.pi/bridge-recovery/index.ts), which re-injects a failed Slack-originated turn -# once on `previous_response_not_found` — recovery the npm bridge lacks. -# -# Health/observability: each launch stamps a non-secret state file and a -# background ticker refreshes a heartbeat (proving the session is actively -# supervised, not merely "a tmux session exists") and caps $LOG in place so a -# long-lived session cannot grow it without bound. `gateway status` reads these. -# -# A clean exit (rc=0) stops the loop; a crash or watchdog-kill (rc!=0) restarts it. -# -# NOTE: intentionally no `set -e` — pkill/kill return non-zero when there is -# nothing to signal, which is normal control flow here, not an error. +# NOTE: intentionally no `set -e`; non-zero wait/kill results are normal control +# flow in a supervisor. set -u BACKEND="${GATEWAY_BACKEND:-pi}" @@ -52,36 +26,62 @@ RECOVERY_ENTRY="${RECOVERY_ENTRY:-$HARNESS/.pi/bridge-recovery/index.ts}" LOG="${LOG:-/tmp/client-slack-$BACKEND.log}" LOCK="$HOME/.pi/msg-bridge.lock" -# Non-secret runtime state consumed by `gateway status` (see gateway.sh). STATE_DIR="${GATEWAY_STATE_DIR:-$HOME/.pi/gateway}" STATE="$STATE_DIR/$BACKEND.state" HEARTBEAT_FILE="$STATE_DIR/$BACKEND.heartbeat" STALE_FILE="$STATE_DIR/$BACKEND.stale" +COMPACT_FILE="$STATE_DIR/$BACKEND.compact" +RESTART_TRIGGER_FILE="$STATE_DIR/$BACKEND.restart-trigger" +RESTART_CLAIM_DIR="$STATE_DIR/$BACKEND.restart-claim" +PI_PID_FILE="$STATE_DIR/$BACKEND.pid" +PI_GROUP_FILE="$STATE_DIR/$BACKEND.pgid" +SESSION_DIR="${GATEWAY_PI_SESSION_DIR:-$STATE_DIR/pi-sessions}" HEARTBEAT_INTERVAL="${GATEWAY_HEARTBEAT_INTERVAL:-20}" -LOG_MAX_BYTES="${GATEWAY_LOG_MAX_BYTES:-5242880}" # 5 MiB +RESTART_DELAY="${GATEWAY_RESTART_DELAY:-3}" +LOG_MAX_BYTES="${GATEWAY_LOG_MAX_BYTES:-5242880}" + mkdir -p "$STATE_DIR" 2>/dev/null || true -rm -f "$STALE_FILE" 2>/dev/null || true +chmod 700 "$STATE_DIR" 2>/dev/null || true +if [ "$BACKEND" = pi ]; then + mkdir -p "$SESSION_DIR" 2>/dev/null || true + chmod 700 "$SESSION_DIR" 2>/dev/null || true +fi +rm -f "$STALE_FILE" "$COMPACT_FILE" "$RESTART_TRIGGER_FILE" "$PI_PID_FILE" "$PI_GROUP_FILE" 2>/dev/null || true +rmdir "$RESTART_CLAIM_DIR" 2>/dev/null || true if [ "$BACKEND" = pi ] && [ -n "${PI_SLACK_BOT_TOKEN:-}" ]; then TOKEN_STATE=present elif [ "$BACKEND" = pi ]; then TOKEN_STATE=absent else TOKEN_STATE="n/a"; fi +if ! cd "$HARNESS"; then + echo "[bridge-supervisor] harness cwd unavailable: $HARNESS" >>"$LOG" + exit 1 +fi + STARTED_ISO="$(date -u +%FT%TZ)" +SUPERVISOR_PID=$$ LAUNCHES=0 +HB="" +COMPACT_WATCHER="" +STALE_WATCHER="" +PI_PID="" +PI_PGID="" +IPC_SOCKET="" +IPC_READY="" +IPC_SETTLED="" +STOPPING=0 -# Atomic single-line/kv writes (never carry secrets). Writers are disjoint per -# file: the main loop owns $STATE, the ticker owns $HEARTBEAT_FILE, the watchdog -# owns $STALE_FILE — so no locking is needed. write_state() { local tmp tmp=$(mktemp "$STATE_DIR/.state.XXXXXX" 2>/dev/null) || return 0 { - printf 'backend=%s\n' "$BACKEND" - printf 'session=%s\n' "client-slack-$BACKEND" + printf 'backend=%s\n' "$BACKEND" + printf 'session=%s\n' "client-slack-$BACKEND" printf 'bridge_token=%s\n' "$TOKEN_STATE" - printf 'started=%s\n' "$STARTED_ISO" - printf 'last_launch=%s\n' "$(date -u +%FT%TZ)" - printf 'launches=%s\n' "$LAUNCHES" + printf 'started=%s\n' "$STARTED_ISO" + printf 'last_launch=%s\n' "$(date -u +%FT%TZ)" + printf 'launches=%s\n' "$LAUNCHES" + if [ "$BACKEND" = pi ]; then printf 'session_dir=%s\n' "$SESSION_DIR"; fi } >"$tmp" 2>/dev/null && mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true } @@ -91,56 +91,366 @@ write_heartbeat() { date -u +%s >"$tmp" 2>/dev/null && mv -f "$tmp" "$HEARTBEAT_FILE" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true } -# Copytruncate cap: keep the last half, then rewrite the SAME inode in place so -# the pipe-pane / pi-stderr append fds and the stale-ctx `tail -F` stay valid -# (a rename/create would leave them writing to the rotated-away inode). cap_log() { [ -f "$LOG" ] || return 0 - local sz; sz=$(stat -c %s "$LOG" 2>/dev/null || echo 0) + local sz keep tmp + sz=$(stat -c %s "$LOG" 2>/dev/null || echo 0) case "$sz" in ''|*[!0-9]*) return 0 ;; esac [ "$sz" -gt "$LOG_MAX_BYTES" ] || return 0 - local keep=$((LOG_MAX_BYTES / 2)) tmp + keep=$((LOG_MAX_BYTES / 2)) tmp=$(mktemp "$STATE_DIR/.log.XXXXXX" 2>/dev/null) || return 0 tail -c "$keep" "$LOG" >"$tmp" 2>/dev/null && cat "$tmp" >"$LOG" 2>/dev/null rm -f "$tmp" 2>/dev/null || true } +# Kill only the recorded process and its exact /proc descendants. No name-based +# pkill is used, so sibling Pi/bridge sessions survive. +terminate_exact_tree() { + local pid="$1" child + case "$pid" in ''|*[!0-9]*) return 0 ;; esac + if [ -r "/proc/$pid/task/$pid/children" ]; then + # /proc children is a single whitespace-delimited PID record by contract. + # shellcheck disable=SC2013 + for child in $(cat "/proc/$pid/task/$pid/children" 2>/dev/null); do + terminate_exact_tree "$child" + done + fi + kill -TERM "$pid" 2>/dev/null || true +} + +terminate_exact_pid() { + local pid="$1" attempts=200 + case "$pid" in ''|*[!0-9]*) return 0 ;; esac + kill -TERM "$pid" 2>/dev/null || true + while kill -0 "$pid" 2>/dev/null && [ "$attempts" -gt 0 ]; do + attempts=$((attempts - 1)) + sleep 0.01 + done + if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null || true; fi +} + +# Pi launches as the supervisor's direct child in a fresh session/process group +# whose SID/PGID equal its PID. Revalidate that exact identity before signaling; +# PID/PGID state files are observability only and are never sufficient authority. +is_exact_supervised_leader() { + local leader="$1" identity ppid pgid sid + case "$leader" in ''|*[!0-9]*) return 1 ;; esac + identity=$(ps -o ppid=,pgid=,sid= -p "$leader" 2>/dev/null) || return 1 + read -r ppid pgid sid <<<"$identity" + [ "$ppid" = "$SUPERVISOR_PID" ] && [ "$pgid" = "$leader" ] && [ "$sid" = "$leader" ] +} + +# Signal only the revalidated group. TERM gets a bounded grace period, then KILL +# closes stubborn descendants without touching sibling Pi/Hermes jobs. +terminate_exact_group() { + local pgid="$1" leader="$2" attempts=200 + case "$pgid:$leader" in *[!0-9:]*) return 0 ;; esac + [ -n "$pgid" ] && [ "$pgid" = "$leader" ] || return 0 + is_exact_supervised_leader "$leader" || return 0 + kill -TERM -- "-$pgid" 2>/dev/null || true + while kill -0 -- "-$pgid" 2>/dev/null && [ "$attempts" -gt 0 ]; do + attempts=$((attempts - 1)) + sleep 0.01 + done + if kill -0 -- "-$pgid" 2>/dev/null; then + kill -KILL -- "-$pgid" 2>/dev/null || true + fi +} + +# A group reaching this helper was established while its leader was alive: +# either SO_PEERCRED plus direct-child SID/PGID authenticated the compact peer, +# or the supervisor itself observed its just-launched child at SID=PGID=PID. +# Keep that exact group identity usable after the leader exits so descendants +# cannot escape. +terminate_authenticated_group() { + local pgid="$1" attempts=50 + case "$pgid" in ''|*[!0-9]*) return 0 ;; esac + kill -TERM -- "-$pgid" 2>/dev/null || true + while kill -0 -- "-$pgid" 2>/dev/null && [ "$attempts" -gt 0 ]; do + attempts=$((attempts - 1)) + sleep 0.01 + done + if kill -0 -- "-$pgid" 2>/dev/null; then + kill -KILL -- "-$pgid" 2>/dev/null || true + fi +} + +wait_for_file() { + local file="$1" attempts="${2:-500}" + while [ ! -f "$file" ] && [ "$attempts" -gt 0 ]; do + attempts=$((attempts - 1)) + sleep 0.01 + done + [ -f "$file" ] +} + +claim_restart_and_signal() { + local kind="$1" authenticated_pid="${2:-}" pid pgid attempts=500 + mkdir "$RESTART_CLAIM_DIR" 2>/dev/null || return 0 + printf '%s\n' "$kind" >"$RESTART_TRIGGER_FILE" 2>/dev/null + case "$kind" in + compact) + date -u +%s >"$COMPACT_FILE" 2>/dev/null + echo "[bridge-supervisor] Slack compaction completed — restarting exact Pi process group ($(date -u +%FT%TZ))" >>"$LOG" + ;; + stale) + date -u +%s >"$STALE_FILE" 2>/dev/null + echo "[bridge-supervisor] stale-ctx detected — restarting exact Pi process group ($(date -u +%FT%TZ))" >>"$LOG" + ;; + esac + if [ "$kind" = compact ] && [ -n "$authenticated_pid" ]; then + terminate_authenticated_group "$authenticated_pid" + return 0 + else + while { [ ! -s "$PI_PID_FILE" ] || [ ! -s "$PI_GROUP_FILE" ]; } && [ "$attempts" -gt 0 ]; do + attempts=$((attempts - 1)) + sleep 0.01 + done + pid=$(cat "$PI_PID_FILE" 2>/dev/null || true) + pgid=$(cat "$PI_GROUP_FILE" 2>/dev/null || true) + fi + terminate_exact_group "$pgid" "$pid" +} + +stop_launch_helpers() { + if [ -n "$COMPACT_WATCHER" ]; then + terminate_exact_tree "$COMPACT_WATCHER" + wait "$COMPACT_WATCHER" 2>/dev/null || true + COMPACT_WATCHER="" + fi + if [ -n "$STALE_WATCHER" ]; then + terminate_exact_tree "$STALE_WATCHER" + wait "$STALE_WATCHER" 2>/dev/null || true + STALE_WATCHER="" + fi + if [ -n "$HB" ]; then + terminate_exact_tree "$HB" + wait "$HB" 2>/dev/null || true + HB="" + fi + unset PI_MSG_BRIDGE_COMPACT_SOCKET + rm -f "$PI_PID_FILE" "$PI_GROUP_FILE" "$IPC_SOCKET" "$IPC_READY" "$IPC_SETTLED" 2>/dev/null || true + IPC_SOCKET=""; IPC_READY=""; IPC_SETTLED="" +} + +cleanup_all() { + local live_pgid="" + [ "$STOPPING" -eq 0 ] || return 0 + STOPPING=1 + if [ -n "$PI_PGID" ] && [ "$PI_PGID" = "$PI_PID" ]; then + # PI_PGID is recorded only after this supervisor observed SID=PGID=PID for + # its direct child. Group existence, not continued leader existence, is the + # cleanup authority: the leader may already be gone while descendants live. + terminate_authenticated_group "$PI_PGID" + elif [ -n "$PI_PID" ]; then + live_pgid=$(ps -o pgid= -p "$PI_PID" 2>/dev/null | tr -d ' ' || true) + if [ "$live_pgid" = "$PI_PID" ]; then + terminate_exact_group "$live_pgid" "$PI_PID" + else + # Signal can arrive between fork and setsid/PGID observation. Kill the + # exact not-yet-isolated leader rather than leaving that launch orphaned. + terminate_exact_pid "$PI_PID" + fi + fi + stop_launch_helpers + rm -f "$STATE" "$HEARTBEAT_FILE" "$STALE_FILE" "$COMPACT_FILE" \ + "$PI_PID_FILE" "$PI_GROUP_FILE" "$RESTART_TRIGGER_FILE" 2>/dev/null || true + if [ "$BACKEND" = pi ]; then rm -f "$LOCK" 2>/dev/null || true; fi + rmdir "$RESTART_CLAIM_DIR" 2>/dev/null || true +} + +on_signal() { + cleanup_all + exit 0 +} +trap on_signal INT TERM HUP +trap cleanup_all EXIT + +prepare_compact_watcher() { + local base + base=$(mktemp "$STATE_DIR/.compact-ipc.XXXXXX" 2>/dev/null) || return 1 + rm -f "$base" + IPC_SOCKET="$base.sock" + IPC_READY="$base.ready" + IPC_SETTLED="$base.settled" + + ( + local authenticated_pid="" rc=1 + authenticated_pid=$(python3 - "$IPC_SOCKET" "$IPC_READY" "$SUPERVISOR_PID" <<'PY' +import os +import socket +import struct +import sys + +socket_path, ready_path, supervisor_pid_text = sys.argv[1:] +supervisor_pid = int(supervisor_pid_text) +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +try: + if not hasattr(socket, "SO_PEERCRED"): + raise RuntimeError("Linux SO_PEERCRED is required") + try: + os.unlink(socket_path) + except FileNotFoundError: + pass + server.bind(socket_path) + os.chmod(socket_path, 0o600) + server.listen(16) + with open(ready_path, "x", encoding="utf-8"): + pass + + while True: + connection, _ = server.accept() + try: + credentials = connection.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize("3i"), + ) + peer_pid, _peer_uid, _peer_gid = struct.unpack("3i", credentials) + with open(f"/proc/{peer_pid}/stat", encoding="utf-8") as stat_file: + stat = stat_file.read() + fields = stat[stat.rfind(")") + 2 :].split() + peer_ppid = int(fields[1]) + peer_pgid = int(fields[2]) + peer_sid = int(fields[3]) + if ( + peer_ppid != supervisor_pid + or peer_pgid != peer_pid + or peer_sid != peer_pid + ): + continue + if connection.recv(2) == b"C": + connection.sendall(b"A") + print(peer_pid, flush=True) + sys.exit(0) + except (FileNotFoundError, ProcessLookupError, ValueError): + continue + finally: + connection.close() +finally: + server.close() + try: + os.unlink(socket_path) + except FileNotFoundError: + pass +PY + ) + rc=$? + if [ "$rc" -eq 0 ] && [[ "$authenticated_pid" =~ ^[0-9]+$ ]]; then + claim_restart_and_signal compact "$authenticated_pid" + elif [ "$rc" -ne 143 ]; then + echo "[bridge-supervisor] compact peer listener exited without an authenticated Pi PID (rc=$rc)" >>"$LOG" + fi + : >"$IPC_SETTLED" + exit "$rc" + ) /dev/null 2>&1 & + COMPACT_WATCHER=$! + + wait_for_file "$IPC_READY" || return 1 + [ "$(stat -c %a "$IPC_SOCKET" 2>/dev/null || true)" = 600 ] || return 1 + export PI_MSG_BRIDGE_COMPACT_SOCKET="$IPC_SOCKET" + return 0 +} + +launch_pi_isolated() { + # A non-interactive supervisor backgrounds Pi, which would otherwise inherit + # /dev/null on stdin. Reopen the tmux controlling TTY before setsid, then exec + # so the recorded PID is the isolated Pi session/group leader. + if [ -r /dev/tty ]; then + exec setsid pi --session-dir "$SESSION_DIR" --continue \ + --extension "$BRIDGE_ENTRY" --extension "$RECOVERY_ENTRY" --approve /dev/null || echo 0) + ( + local current chunk carry="" + : >"$ready" + while true; do + current=$(stat -c %s "$LOG" 2>/dev/null || echo 0) + case "$current:$offset" in *[!0-9:]*) current=0; offset=0 ;; esac + if [ "$current" -lt "$offset" ]; then offset=0; carry=""; fi + if [ "$current" -gt "$offset" ]; then + chunk=$(dd if="$LOG" bs=1 skip="$offset" count="$((current - offset))" 2>/dev/null || true) + offset=$current + carry="$carry$chunk" + if [[ "$carry" == *"ctx is stale"* ]]; then + claim_restart_and_signal stale + break + fi + if [ "${#carry}" -gt 256 ]; then carry="${carry: -256}"; fi + fi + sleep 0.1 + done + rm -f "$ready" 2>/dev/null || true + ) /dev/null 2>&1 & + STALE_WATCHER=$! + wait_for_file "$ready" || return 1 + rm -f "$ready" 2>/dev/null || true +} + while true; do LAUNCHES=$((LAUNCHES + 1)) - [ "$BACKEND" = pi ] && { rm -f "$LOCK" 2>/dev/null || true; } + rm -f "$RESTART_TRIGGER_FILE" "$PI_PID_FILE" "$PI_GROUP_FILE" 2>/dev/null || true + rmdir "$RESTART_CLAIM_DIR" 2>/dev/null || true + if [ "$BACKEND" = pi ]; then rm -f "$LOCK" 2>/dev/null || true; fi + echo "[bridge-supervisor] launching $BACKEND bridge ($(date -u +%FT%TZ))" >>"$LOG" write_state - - # Heartbeat + in-place log cap ticker: refreshes while the process runs, torn - # down when it exits. Fully redirected so it never touches the pane pty. ( while true; do write_heartbeat; cap_log; sleep "$HEARTBEAT_INTERVAL"; done ) /dev/null 2>&1 & HB=$! - WD="" if [ "$BACKEND" = pi ]; then - # Watchdog: tail $LOG from end-of-file (old stale-ctx lines never re-trigger), - # strip ANSI/CR so a TUI-rendered error is still greppable, record the recovery - # for `gateway status`, and kill the bridge pi — matched by its unique - # --extension path — on the first stale-ctx line so the loop relaunches a - # fresh, non-stale process. Fully redirected (incl. stdin from /dev/null) so it - # never reads the pane pty or holds a stdout pipe open. - ( tail -Fn0 "$LOG" 2>/dev/null \ - | sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\r//g' \ - | grep -m1 'ctx is stale' >/dev/null 2>&1 \ - && { echo "[bridge-supervisor] stale-ctx detected — restarting pi ($(date -u +%FT%TZ))" >>"$LOG"; \ - date -u +%s >"$STALE_FILE" 2>/dev/null; \ - pkill -f 'pi-messenger-bridge/dist/index.js'; } ) /dev/null 2>&1 & - WD=$! - - # Interactive TTY launch: stdin+stdout = pane pty (-> interactive mode, no JSON - # flood, stays alive at idle), stderr -> $LOG. No pipe, no --mode rpc. - pi --extension "$BRIDGE_ENTRY" --extension "$RECOVERY_ENTRY" --approve 2>>"$LOG" - rc=$? + if ! prepare_compact_watcher || ! prepare_stale_watcher; then + echo "[bridge-supervisor] failed to prepare restart watchers — stopping" >>"$LOG" + break + fi + + # The watcher handshakes above complete before Pi starts. Every launch uses + # the same private directory and explicit continuation, including launch 1. + launch_pi_isolated 2>>"$LOG" & + PI_PID=$! + PI_PGID="" + attempts=200 + while [ "$attempts" -gt 0 ]; do + if is_exact_supervised_leader "$PI_PID"; then + PI_PGID="$PI_PID" + break + fi + attempts=$((attempts - 1)) + sleep 0.01 + done + if [ "$PI_PGID" != "$PI_PID" ]; then + echo "[bridge-supervisor] failed to isolate exact Pi process group" >>"$LOG" + terminate_exact_pid "$PI_PID" + wait "$PI_PID" 2>/dev/null || true + rc=1 + else + printf '%s\n' "$PI_PID" >"$PI_PID_FILE" + printf '%s\n' "$PI_PGID" >"$PI_GROUP_FILE" + wait "$PI_PID" + rc=$? + # The leader may exit before descendants. Always close the isolated group + # with bounded TERM→KILL before the next launch. + terminate_authenticated_group "$PI_PGID" + fi + + # Give an authenticated listener a bounded window to publish its restart + # claim after acknowledging the byte. A clean unrelated exit leaves the + # one-shot listener waiting, so stop it after that window. Settle either + # path before evaluating rc, including simultaneous completion + rc=0. + if ! wait_for_file "$IPC_SETTLED" 100; then + terminate_exact_tree "$COMPACT_WATCHER" + fi + wait "$COMPACT_WATCHER" 2>/dev/null || true + COMPACT_WATCHER="" + PI_PID="" + PI_PGID="" else - # Generic backend (hermes): crash-restart-with-backoff only. SUPERVISE_CMD - # ends in `exec gateway run`, so it replaces this subshell and returns - # the backend's own exit code. No stale-ctx/lock/recovery — those are - # pi-bridge-specific. if [ -z "$SUPERVISE_CMD" ]; then echo "[bridge-supervisor] no SUPERVISE_CMD for backend '$BACKEND' — exiting" >>"$LOG" break @@ -149,15 +459,19 @@ while true; do rc=$? fi - kill "$HB" 2>/dev/null || true - pkill -P "$HB" 2>/dev/null || true - if [ -n "$WD" ]; then kill "$WD" 2>/dev/null || true; pkill -P "$WD" 2>/dev/null || true; fi - - if [ "$rc" -eq 0 ]; then + stop_launch_helpers + restart_trigger="" + if [ "$BACKEND" = pi ] && [ -f "$RESTART_TRIGGER_FILE" ]; then + restart_trigger=$(cat "$RESTART_TRIGGER_FILE" 2>/dev/null || true) + rm -f "$RESTART_TRIGGER_FILE" 2>/dev/null || true + rmdir "$RESTART_CLAIM_DIR" 2>/dev/null || true + fi + if [ "$rc" -eq 0 ] && [ -z "$restart_trigger" ]; then echo "[bridge-supervisor] $BACKEND exited cleanly (rc=0) — stopping ($(date -u +%FT%TZ))" >>"$LOG" rm -f "$HEARTBEAT_FILE" 2>/dev/null || true + if [ "$BACKEND" = pi ]; then rm -f "$LOCK" 2>/dev/null || true; fi break fi - echo "[bridge-supervisor] $BACKEND exited rc=$rc — restarting in 3s ($(date -u +%FT%TZ))" >>"$LOG" - sleep 3 + echo "[bridge-supervisor] $BACKEND exited rc=$rc — restarting in ${RESTART_DELAY}s ($(date -u +%FT%TZ))" >>"$LOG" + sleep "$RESTART_DELAY" done diff --git a/.github/workflows/ci-harness.yml b/.github/workflows/ci-harness.yml index 571f46ae..96984661 100644 --- a/.github/workflows/ci-harness.yml +++ b/.github/workflows/ci-harness.yml @@ -106,6 +106,9 @@ jobs: - name: Test run: pnpm test:scripts + - name: Smoke exact Slack bridge artifact + run: bash .oh/scripts/smoke-slack-bridge-artifact.sh + boot-lint: name: Boot Path Lint (shellcheck + hadolint) runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} diff --git a/.oh/docs/integrations/slack.md b/.oh/docs/integrations/slack.md index 739007a6..cfedaa93 100644 --- a/.oh/docs/integrations/slack.md +++ b/.oh/docs/integrations/slack.md @@ -161,38 +161,60 @@ The sibling Hermes gateway client is the same command: `gateway hermes` (session `client-slack-hermes`). Under the hood `gateway pi` / the supervisor run: ```bash -pi --extension .pi/bridge/node_modules/pi-messenger-bridge/dist/index.js \ +pi --session-dir ~/.pi/gateway/pi-sessions --continue \ + --extension .pi/bridge/node_modules/pi-messenger-bridge/dist/index.js \ --extension .pi/bridge-recovery/index.ts \ --approve # interactive on the pane TTY — no --mode rpc, no | tee ``` -pi runs **interactive**, attached to the pane's real TTY, so the loaded UI -extensions render in the TUI instead of flooding stdout with -`extension_ui_request` JSON frames — and the REPL stays alive at idle (no -`--mode rpc`, no `| tee` pipe). Logs are captured out-of-band: pi's stderr goes -to `/tmp/client-slack-pi.log`, and `gateway.sh` mirrors the pane there -(ANSI-stripped) with `tmux pipe-pane`. `--approve` trusts the project-local -files so the extension loads. A second `--extension` -(`.pi/bridge-recovery/index.ts`) adds Codex retry-recovery (§ 4.5). The bridge -is loaded **only** here — it is not pinned in `.pi/settings.json`, so no other -`pi` session competes for the Slack connection. +Pi runs **interactive**, attached to the pane's real TTY, so loaded UI extensions +render in the TUI instead of flooding stdout with `extension_ui_request` JSON +frames, and the REPL stays alive at idle. The private mode-700 session directory +belongs only to this gateway. tmux and the supervisor both pin the process cwd to +`$HARNESS`; every launch explicitly uses `--continue`, so invocation-directory +variance cannot split session lookup and the second launch reopens the first +launch's active path, including its newly written compaction entry. Logs are captured out of band in `/tmp/client-slack-pi.log`. +The package owns Slack compaction (§ 4.6); the only local co-extension is +`.pi/bridge-recovery/index.ts` for Codex retry-recovery (§ 4.5). Neither is +globally pinned, so local TUI, cron, Hermes, and sibling Pi sessions remain +unaffected. ### 4.4 Self-healing supervisor -The `client-slack-pi` session does not run that `pi` command directly — it runs it -under a thin supervisor, `.devcontainer/client-slack-supervise.sh`, which -relaunches pi whenever the bridge dies. This exists because -pi-messenger-bridge binds its long-lived Slack socket to a **session-scoped pi -ctx**: when pi replaces the session (compaction, fork, model switch, reload), -that ctx goes stale and every subsequent Slack message throws -`extension ctx is stale after session replacement or reload`. The package has -no recovery hook, so the process keeps running while the bridge silently stops -responding. The supervisor tails the log for that stale-ctx signature (and -catches any non-zero crash), kills the bridge pi, clears the single-instance -lock (`~/.pi/msg-bridge.lock`), and relaunches a fresh process that reconnects -— look for the `[Slack] Bot user ID:` connect marker (§ 7) again after a -restart. A clean pi exit (`rc=0`) stops the loop. The manual relaunch below is -only needed to pick up config edits, not to recover from stale-ctx. +The `client-slack-pi` session runs under +`.devcontainer/client-slack-supervise.sh`. Crashes and the legacy +`extension ctx is stale after session replacement or reload` signature still +restart Pi, but every Pi launch runs as an isolated session/process group whose +PGID equals its recorded leader PID. Recovery sends bounded TERM then KILL only +to that verified group, so stubborn descendants die without name-based `pkill` +and unrelated Pi/Hermes sessions survive. The pane TTY descriptors remain +attached. EXIT, INT, TERM, and HUP cleanup uses the already-authenticated, +recorded PGID while that group exists, even if the Pi leader PID has exited; +it applies the same bounded TERM-to-KILL close, tears down watcher/ticker +children, and removes the bridge lock, supervisor state, heartbeat, PID/PGID, +socket, and transient restart state. + +Successful compaction uses no log marker, inherited descriptor, or environment +secret. Before launching Pi, the supervisor binds a Unix-domain listener inside +the mode-700 gateway state directory, chmods the socket itself to 0600, starts +listening, and publishes readiness before Pi can run. The path is not a secret: a real tool child can discover it from its parent environment and try +the one-byte protocol. The listener uses Linux peer credentials (`SO_PEERCRED`) +to require that the connecting PID is the supervisor's exact direct child and +that its SID and PGID both equal that PID. A tool child, pane process, or sibling +therefore connects under a different PID and is rejected even with the full +path and protocol. + +After confirmed Slack disconnect, the package connects from the Pi process +itself and writes one byte. The listener authenticates Pi, returns a one-byte +acknowledgement, records `compaction reconnected`, and terminates only that +authenticated process group with bounded TERM then KILL. This exact group close +still runs if the Pi leader exits immediately after acknowledgement, so stubborn +descendants cannot escape. The supervisor waits for the one-shot listener to +settle **before** evaluating rc, so immediate completion and simultaneous +completion + rc=0 cannot be lost. The socket, readiness file, PID/PGID +observability files, and restart state are removed on every normal or signal +exit. Launch two then uses the same `--session-dir --continue` path rather than +starting bare Pi. ### 4.5 Codex retry-recovery @@ -207,7 +229,63 @@ turn was Slack-originated (the bridge's `[📱 … via slack]:` stamp), it re-in that turn **once** — the failed request already cleared the stale id, so the retry chains fresh and succeeds. It does not patch the npm package. -### 4.6 Run and verify (read-only) +### 4.6 Compact the current Pi Slack session from Slack + +An **already-authorized** Slack user can send one of these complete, +case-insensitive ordinary messages: + +```text +compact session +compact current session +compact the current session +``` + +The exact pinned `pi-messenger-bridge` package recognizes the control only +**after** its normal user/channel trust check. It binds an immutable request to +the authenticated message metadata and posts the acknowledgement directly to +the originating Slack chat/thread: + +> Compaction requested. I’ll compact this session, then the gateway will restart +> and reconnect. + +A successful `chat.postMessage` is the commit point. If Slack delivery fails, +the package does not compact, disconnect, or signal. If another Pi turn is +active, the acknowledged request waits for `agent_settled`; it never attaches +to an arbitrary later text turn. Empty responses, tool turns, and provider +errors therefore cannot steal the request. Session/request generations establish +ownership even when Pi supplies distinct `ExtensionContext` wrappers; the +`agent_settled` event's current context performs the actual idle check and +`compact` call. Authenticated inbound callbacks are serialized, and +overlap/direct-next messages receive an in-progress response without entering +the context being replaced. Ordinary remote turns likewise hold their production +queue item through `agent_settled`, but do **not** assign a Slack destination when +`sendUserMessage` merely enqueues behind local/TUI work. Each request gets an +unpredictable internal correlation id appended to its queued text. Only the +matching user `message_start` activates that request's chat/thread; a supported +`message_end` replacement strips the marker from the finalized user message +before provider context and session persistence. Thus a local assistant/tool +`turn_end` before the remote start has no Slack destination, while identical +remote content in different chats/threads remains independently correlated in +FIFO order. + +The package then calls Pi's documented +`ctx.compact({ customInstructions, onComplete, onError })`. On success it closes +the logical intake gate immediately, disconnects the exact Slack transport, and +only then connects to the supervisor's exact-peer Unix socket. If Slack's stop call rejects, +the provider preserves the live app handle and connected state while the +controller retries; no restart byte is sent unless disconnect is confirmed and the listener acknowledges the authenticated Pi peer. +Session/request generations guard late completion/error callbacks after +replacement. On compact-provider error the gateway stays connected and re-arms +without a restart loop. + +The package also accepts ordinary text `/compact [instructions]` if Slack passes +it through. The exact **untrimmed** instruction capture is checked before +normalization: C0, DEL, and C1 controls are rejected, and the limit is 500 +Unicode code points. Open Harness does **not** register or claim a native Slack +`/compact` slash command; it is absent from the app manifest. Use the natural +message forms as the supported surface. + +### 4.7 Run and verify (read-only) Run and check the gateway **from inside the sandbox** — both `gateway ` and `make gateway ` require `pi`/`hermes` on `PATH`, so they only work in the @@ -222,7 +300,8 @@ gateway status # both sessions + HEALTH (not just existence), e.g. `status` reports the supervisor's live state, not merely "a tmux session exists": `healthy` (heartbeat fresh), `recovering` (in a restart/backoff — may add -`· N restart(s)` / `· recovered ago` after a stale-ctx heal), or +`· N restart(s)`, `· stale-ctx recovered ago`, or +`· compaction reconnected ago`), or `running · disconnected (no PI_SLACK token)` when the bridge loaded without tokens. A session with no state yet falls back to `running`. @@ -346,6 +425,12 @@ env (before attaching to tmux). should see the inbound event logged and the agent's reply posted back to Slack. +4. **Compaction round trip:** send `compact current session` as a complete Slack + message. Expect the acknowledgement in the same DM/thread, then a short + reconnect. Confirm with `gateway status` (`compaction reconnected … ago`) and + the fresh `[Slack] Bot user ID:` marker. Do not expect a second completion + message from the replaced process. + ## 8. Troubleshooting | Symptom | Cause | Fix | @@ -356,6 +441,9 @@ env (before attaching to tmux). | Bridge won't start after an unclean exit | Stale lock file `~/.pi/msg-bridge.lock` left behind | `rm ~/.pi/msg-bridge.lock`, then relaunch the `client-slack-pi` session | | Bot connected (`[Slack] Bot user ID:` logged) but never replies | `autoConnect` not set in `.pi/msg-bridge.json` — the bridge stays idle | Set `"autoConnect": true` (§ 4.2) and relaunch | | Bot is trusted but channel messages ignored | Bot is not a member of the channel | In Slack, type `/invite @OpenHarness` in the target channel | +| Text mentioning “compact” did not compact | Only the exact full-message grammar is accepted | Send `compact session`, `compact current session`, or `compact the current session` as the entire authorized Slack message | +| Slack says `/compact` is unknown | Open Harness does not register a native Slack `/compact` slash command | Use natural message text (`compact current session`); the optional stamped `/compact [instructions]` form is only handled if ordinary text reaches Pi | +| Acknowledgement arrived but reconnect is unclear | The acknowledgement precedes compaction and is not a completion confirmation | Run `gateway status`, then check `tmux capture-pane -t client-slack-pi -p | grep -F '[Slack] Bot user ID:'`; inspect `/tmp/client-slack-pi.log` for either the safe compaction failure or supervisor reconnect line | ## 9. Architecture Pointer @@ -364,12 +452,15 @@ installs it via npm into a gitignored `.pi/bridge/` directory and loads it via `--extension` only in the dedicated `client-slack-pi` tmux session (`.devcontainer/entrypoint.sh`) — it is not globally pinned in `.pi/settings.json`, so no other `pi` session competes for the Slack -connection. Replies post **in a thread** anchored to the triggering channel -message (`thread_ts`); DMs stay flat. The harness normally consumes the package -as published, but while that thread-reply patch is unreleased it temporarily -pins the entrypoint's `npm install` line to a fork branch -(`github:ryaneggz/pi-messenger-bridge#c8b96e9d0fb69611c4e67ae298d1d10d83792a26`), the exact fork commit containing thread replies and admin slash-command handlers; re-pin to -`pi-messenger-bridge@` once upstream publishes them. Source lives upstream at +connection. The harness co-loads only `.pi/bridge-recovery/` for Codex retry; +Slack compaction is package-owned, not patched or vendored in the harness. +Replies post **in a thread** anchored to the triggering channel message +(`thread_ts`); DMs stay flat. While these changes are unreleased, the harness +pins the exact reviewed fork commit +`git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b` +from [ryaneggz/pi-messenger-bridge#2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2), +which includes thread replies, admin handlers, and supervised compact control. +Re-pin to `pi-messenger-bridge@` once upstream publishes them. Source lives upstream at [tintinweb/pi-messenger-bridge](https://github.com/tintinweb/pi-messenger-bridge). For upstream lineage, the version-pin model, the quarterly review cadence, and diff --git a/.oh/evals/RESULTS.md b/.oh/evals/RESULTS.md index 7fb1acbd..258e7533 100644 --- a/.oh/evals/RESULTS.md +++ b/.oh/evals/RESULTS.md @@ -6,100 +6,103 @@ probe id; git history is the time series.** Schema and exit-code semantics are i | probe | tier | last-run (UTC) | status | source | |-------|------|----------------|--------|--------| -| ablate-state-machine | A | 2026-08-03 03:38 | PASS | issue #645 — one locked versioned ablation recovery owner | -| advisor-monitored-loop | A | 2026-08-03 03:38 | PASS | conversation 2026-06-19 (advisor-monitored ralph loop pattern, issue #257) | -| agent-browser-cli | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-07 (agent-browser 0.8.5 CLI) | -| artifact-contract-audit | A | 2026-08-03 03:38 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | -| audit-context-shared-memory | A | 2026-08-03 03:38 | PASS | issue #645 — shared-memory context ablation routing | -| audit-dispatcher-contract | A | 2026-08-03 03:38 | PASS | issue #645 — audit consolidation public taxonomy | -| audit-implementation-behavior | A | 2026-08-03 03:38 | PASS | issue #645 — implementation root/repo/browser behavior | -| audit-pr-acquire | A | 2026-08-03 03:38 | PASS | issue #645 — production PR acquisition behavior | -| audit-pr-classifier | A | 2026-08-03 03:38 | PASS | issue #645 — deterministic focused and queue PR classifier | -| audit-run-root-contract | A | 2026-08-03 03:38 | PASS | issue #645 — executable immutable audit root/run/log correlation | -| audit-shellcheck-coverage | A | 2026-08-03 03:38 | PASS | issue #645 — private audit scripts require release and CI lint coverage | -| audit-stale-references | A | 2026-08-03 03:38 | PASS | issue #645 — clean-breaking audit migration | -| autopilot-executor-toggle | A | 2026-08-03 03:38 | PASS | conversation 2026-06-13 (autopilot executor); 2026-06-27 (ralph-default flip) | -| autopilot-merged-pr-reference-dedupe | A | 2026-08-03 03:38 | PASS | issue #468 — autopilot must not rebuild open tickets whose development PRs already merged | -| autopilot-no-pr-session-close | A | 2026-08-03 03:38 | PASS | issue #209 (autopilot no-PR tmux session closure) 2026-06-16 | -| autopilot-open-pr-reference-dedupe | A | 2026-08-03 03:38 | PASS | issue #437 — autopilot must not start duplicate work when open PRs reference the same issue without linked-PR metadata | -| autopilot-pi-agent | A | 2026-08-03 03:38 | PASS | issue #116 (autopilot Pi tmux alignment) 2026-06-14; issue #118 (attachable Pi TUI tmux) 2026-06-14; issue #126 (kept Pi overlap lock release) 2026-06-14; issue #142 (worktree-by-default, skip→worktree) 2026-06-14 | -| autopilot-preflight-gate | A | 2026-08-03 03:38 | SKIPPED | issue #194 (deterministic autopilot caps preflight gate) 2026-06-15 | -| autopilot-upstream-default | A | 2026-08-03 03:38 | PASS | issue #420 — future autopilots must target canonical repo, not personal fork | -| autopilot-worktree-log-root | A | 2026-08-03 03:38 | PASS | issue #152 (persist autopilot worktree logs) 2026-06-15 | -| boot-lint-glob | A | 2026-08-03 03:38 | PASS | issue #90, issue #120 | -| builder-skill-consolidation | A | 2026-08-03 03:38 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | -| capability-benchmark-schema | A | 2026-08-03 03:38 | PASS | issue #167 — capability benchmark instrument | -| cc-safety-net-wiring | A | 2026-08-03 03:38 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | -| clean-restore | A | 2026-08-03 03:38 | PASS | issue #63 (autopilot-stray-wip-guard) 2026-06-12; issue #81 (owned-paths-zsh-split) 2026-06-13 | -| cleanup-tasks-scoped-guard | A | 2026-08-03 03:38 | PASS | issue #85 | -| cleanup-tasks-worktree-grooming | A | 2026-08-03 03:38 | PASS | issue #168; issue #327 | -| codex-stale-response-retry | A | 2026-08-03 03:38 | PASS | issue #506 — Codex previous_response_not_found RCA | -| cron-claude-codex-fallback | A | 2026-08-03 03:38 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | -| cron-watchdog | A | 2026-08-03 03:38 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | -| curl-bash-safe-alternatives | A | 2026-08-03 03:38 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | -| datasets-schema | A | 2026-08-03 03:38 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | -| debugmcp-availability | A | 2026-08-03 03:38 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | -| delegate-model-effort-policy | A | 2026-08-03 03:38 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | -| devtcp-hook | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-10 (zsh /dev/tcp) | -| docs-build-fast-path | A | 2026-08-03 03:38 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to .oh/docs/ | -| drift-check-cron-staleness-glob | A | 2026-08-03 03:38 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | -| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-03 03:38 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | -| eval-ci-gate | A | 2026-08-03 03:38 | PASS | #103 — eval probe suite gated in CI | -| eval-gate | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-11 (eval-gate) | -| eval-results-atomic | A | 2026-08-03 03:38 | PASS | issue #83 (eval-results-atomic-write) | -| eval-runner-exit | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-11 (eval-runner-exit) #29 | -| first-mate-charter | A | 2026-08-03 03:38 | PASS | .oh/tasks/first-mate-charter/ (issue #660) — First Mate role charter + advisor prompt pack must stay present, tracked, resolvable, and effort-vocabulary-aligned with /delegate | -| get-oh-bootstrap | A | 2026-08-03 03:38 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | -| git-skill | A | 2026-08-03 03:38 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | -| harness-audit-empty-output-gate | A | 2026-08-03 03:38 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | -| harness-audit-memory-path | A | 2026-08-03 03:38 | PASS | issue #183 — /audit harness must inspect the active worktree, not a hardcoded root | -| harness-audit-shared-memory | A | 2026-08-03 03:38 | PASS | issue #432 — /audit harness must load durable memory from shared log root in cron worktrees | -| harness-ci-core-paths | A | 2026-08-03 03:38 | PASS | #165 — core sandbox config files must trigger harness CI | -| harness-ci-hooks-paths | A | 2026-08-03 03:38 | PASS | issue #202 — credential/security hook changes must trigger harness CI | -| health-check-docker-stats | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-10 (docker stats vs ps Size) | -| heartbeat-logging-contract | A | 2026-08-03 03:38 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | -| locked-append-critical-path | A | 2026-08-03 03:38 | PASS | issue #204 (lock shared runtime log appends) 2026-06-15 | -| markitdown-wiki-ingest | A | 2026-08-03 03:38 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | -| memory-gitignore-claim | A | 2026-08-03 03:38 | PASS | issue #101 | -| memory-log-locked-append | A | 2026-08-03 03:38 | PASS | issue #476 and #645 — memory appends are locked and audit has one log owner | -| next-dev-prod | A | 2026-08-03 03:38 | REGRESSION | .oh/memory/MEMORY.md 2026-06-04 | -| oh-devcontainer-restructure | A | 2026-08-03 03:38 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | -| oh-image-only-deploy | A | 2026-08-03 03:38 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | -| oh-init-scaffold | A | 2026-08-03 03:38 | PASS | issue #531 Phase 2 | -| oh-npm-package | A | 2026-08-03 03:38 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | -| oh-payload-manifest | A | 2026-08-03 03:38 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | -| oh-sandbox-image-mode | A | 2026-08-03 03:38 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | -| oh-shipped-repo-overridable | A | 2026-08-03 03:38 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | -| oh-standalone-lifecycle | A | 2026-08-03 03:38 | PASS | issue #564 | -| oh-update | A | 2026-08-03 03:38 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | -| owned-surface-guard | A | 2026-08-03 03:38 | PASS | issue #63 (autopilot-stray-wip-guard) 2026-06-12; issue #81 (owned-paths-zsh-split) 2026-06-13 | -| pnpm-audit-ci-gate | A | 2026-08-03 03:38 | PASS | issue #171 — pnpm security audits must run in CI | -| post-bridge-publish-confirmation | A | 2026-08-03 03:38 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | -| prd-output-path-contract | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-19 | -| project-root-seam | A | 2026-08-03 03:38 | PASS | issue #531 Phase 1 (OH_PROJECT_ROOT project-root seam) 2026-06-26 | -| prompt-miner-log-root-worktree | A | 2026-08-03 03:38 | PASS | .oh/skills/prompt-miner/scripts/render-log-entry.sh | -| prompt-miner-schema-compat | A | 2026-08-03 03:38 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | -| prompt-miner-symlink-entrypoint | A | 2026-08-03 03:38 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | -| prompt-miner-weakness-record | A | 2026-08-03 03:38 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | -| ralph-fallback-order | A | 2026-08-03 03:38 | PASS | conversation 2026-06-12 (Ralph default fallback order) | -| repo-map-contract | A | 2026-08-03 03:38 | PASS | issue #464 — repo map must optimize orientation without adding a tree dependency or unmeasured performance claims | -| retro-deterministic-contract | A | 2026-08-03 03:38 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | -| rl-delegation-write-worker | A | 2026-08-03 03:38 | PASS | .oh/memory/MEMORY.md 2026-06-10 (rl-delegation) #57 | -| rlm-context-budget | A | 2026-08-03 03:38 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | -| sandbox-boot-guard-ci | A | 2026-08-03 03:38 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19 | -| ship-spec-ready-finalization | A | 2026-08-03 03:38 | PASS | issue #134 — /ship-spec must finalize ready PRs after gates, not stop at draft scaffold | -| skill-paths | A | 2026-08-03 03:38 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard | -| skills-dir-clean | A | 2026-08-03 03:38 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | -| skills-vendored | A | 2026-08-03 03:38 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | -| slack-admin-command-surface | A | 2026-08-03 03:38 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | -| spec-family-contract | A | 2026-08-03 03:38 | PASS | conversation 2026-06-19 (spec-* family split, issue #265); consolidated into /spec dispatcher 2026-06-23 (one skill, args) | -| submitted-by-trailers | A | 2026-08-03 03:38 | PASS | conversation 2026-06-12 (commit attribution trailers) | -| sync-skill-contract | A | 2026-08-03 03:38 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | -| watchdog-completed-session-reap | A | 2026-08-03 03:38 | PASS | issue #235 (completed autopilot PR session reaping) | -| watchdog-draft-prs | A | 2026-08-03 03:38 | PASS | conversation 2026-06-15 (generic watchdog + stale draft PR recovery) | -| watchdog-stuck-sessions | A | 2026-08-03 03:38 | PASS | issue #240 (Codex zero-credit stuck autopilot sessions) 2026-06-17 | -| weigh-scorer-contract | A | 2026-08-03 03:38 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | -| wiki-readme-index | A | 2026-08-03 03:38 | PASS | issue #132 — wiki README index drift guard | -| workflow-boundaries | A | 2026-08-03 03:38 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259) | +| ablate-state-machine | A | 2026-08-11 11:04 | PASS | issue #645 — one locked versioned ablation recovery owner | +| advisor-monitored-loop | A | 2026-08-11 11:04 | PASS | conversation 2026-06-19 (advisor-monitored ralph loop pattern, issue #257) | +| agent-browser-cli | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-07 (agent-browser 0.8.5 CLI) | +| artifact-contract-audit | A | 2026-08-11 11:04 | PASS | issue #583/#645 — production /audit implementation Gate 1 behavior | +| audit-context-shared-memory | A | 2026-08-11 11:04 | PASS | issue #645 — shared-memory context ablation routing | +| audit-dispatcher-contract | A | 2026-08-11 11:04 | PASS | issue #645 — audit consolidation public taxonomy | +| audit-implementation-behavior | A | 2026-08-11 11:04 | PASS | issue #645 — implementation root/repo/browser behavior | +| audit-pr-acquire | A | 2026-08-11 11:04 | PASS | issue #645 — production PR acquisition behavior | +| audit-pr-classifier | A | 2026-08-11 11:04 | PASS | issue #645 — deterministic focused and queue PR classifier | +| audit-run-root-contract | A | 2026-08-11 11:04 | PASS | issue #645 — executable immutable audit root/run/log correlation | +| audit-shellcheck-coverage | A | 2026-08-11 11:04 | PASS | issue #645 — private audit scripts require release and CI lint coverage | +| audit-stale-references | A | 2026-08-11 11:04 | PASS | issue #645 — clean-breaking audit migration | +| autopilot-executor-toggle | A | 2026-08-11 11:04 | PASS | conversation 2026-06-13 (autopilot executor); 2026-06-27 (ralph-default flip) | +| autopilot-merged-pr-reference-dedupe | A | 2026-08-11 11:04 | PASS | issue #468 — autopilot must not rebuild open tickets whose development PRs already merged | +| autopilot-no-pr-session-close | A | 2026-08-11 11:04 | PASS | issue #209 (autopilot no-PR tmux session closure) 2026-06-16 | +| autopilot-open-pr-reference-dedupe | A | 2026-08-11 11:04 | PASS | issue #437 — autopilot must not start duplicate work when open PRs reference the same issue without linked-PR metadata | +| autopilot-pi-agent | A | 2026-08-11 11:04 | PASS | issue #116 (autopilot Pi tmux alignment) 2026-06-14; issue #118 (attachable Pi TUI tmux) 2026-06-14; issue #126 (kept Pi overlap lock release) 2026-06-14; issue #142 (worktree-by-default, skip→worktree) 2026-06-14 | +| autopilot-preflight-gate | A | 2026-08-11 11:04 | SKIPPED | issue #194 (deterministic autopilot caps preflight gate) 2026-06-15 | +| autopilot-upstream-default | A | 2026-08-11 11:04 | PASS | issue #420 — future autopilots must target canonical repo, not personal fork | +| autopilot-worktree-log-root | A | 2026-08-11 11:04 | PASS | issue #152 (persist autopilot worktree logs) 2026-06-15 | +| boot-lint-glob | A | 2026-08-11 11:04 | PASS | issue #90, issue #120 | +| builder-skill-consolidation | A | 2026-08-11 11:04 | PASS | issue #643 — consolidate artifact builders behind one /builder dispatcher | +| capability-benchmark-schema | A | 2026-08-11 11:04 | PASS | issue #167 — capability benchmark instrument | +| cc-safety-net-wiring | A | 2026-08-11 11:04 | SKIPPED | .oh/tasks/cc-safety-net/prd.json US-007 2026-07-19 | +| clean-restore | A | 2026-08-11 11:04 | PASS | issue #63 (autopilot-stray-wip-guard) 2026-06-12; issue #81 (owned-paths-zsh-split) 2026-06-13 | +| cleanup-tasks-scoped-guard | A | 2026-08-11 11:04 | PASS | issue #85 | +| cleanup-tasks-worktree-grooming | A | 2026-08-11 11:04 | PASS | issue #168; issue #327 | +| codex-stale-response-retry | A | 2026-08-11 11:04 | PASS | issue #506 — Codex previous_response_not_found RCA | +| cron-claude-codex-fallback | A | 2026-08-11 11:04 | PASS | conversation 2026-06-12 (default Codex fallback for crons) | +| cron-watchdog | A | 2026-08-11 11:04 | PASS | issues #130/#453 (cron runtime watchdog + legacy system-cron reaping) 2026-06-19 | +| curl-bash-safe-alternatives | A | 2026-08-11 11:04 | PASS | vet-run/vet integration — public curl|bash examples need review-first alternatives | +| datasets-schema | A | 2026-08-11 11:04 | PASS | issue #196 — .oh/evals/datasets verifiable trajectory corpus (Repo2RLEnv-inspired) | +| debugmcp-availability | A | 2026-08-11 11:04 | SKIPPED | issue #297 — DebugMCP MCP debug-server availability | +| delegate-model-effort-policy | A | 2026-08-11 11:04 | PASS | conversation 2026-07-11 (delegate model inheritance and thinking policy) | +| devtcp-hook | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-10 (zsh /dev/tcp) | +| docker-inspect-env-guard | A | 2026-08-11 11:04 | PASS | operator directive 2026-08-08 (agents keep the docker socket, but must | +| docs-build-fast-path | A | 2026-08-11 11:04 | PASS | #455 — docs builds must stay out of fast harness/eval/release gates; #536 — docs site externalized to openharness-web; docs markdown relocated to .oh/docs/ | +| drift-check-cron-staleness-glob | A | 2026-08-11 11:04 | PASS | issue #98; issue #225 (restart-required cron frontmatter/config drift) | +| entrypoint-pnpm-manifest-fingerprint | A | 2026-08-11 11:04 | PASS | issue #521 (manifest-aware sandbox installs) 2026-07-01 | +| eval-ci-gate | A | 2026-08-11 11:04 | PASS | #103 — eval probe suite gated in CI | +| eval-gate | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-11 (eval-gate) | +| eval-results-atomic | A | 2026-08-11 11:04 | PASS | issue #83 (eval-results-atomic-write) | +| eval-runner-exit | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-11 (eval-runner-exit) #29 | +| first-mate-charter | A | 2026-08-11 11:04 | PASS | .oh/tasks/first-mate-charter/ (issue #660) — First Mate role charter + advisor prompt pack must stay present, tracked, resolvable, and effort-vocabulary-aligned with /delegate | +| get-oh-bootstrap | A | 2026-08-11 11:04 | PASS | get-oh.sh bootstrap — the Node-bootstrapping host-side path to the standalone `oh` CLI (also on npm as @mifune/openharness; see oh-npm-package.sh) | +| git-skill | A | 2026-08-11 11:04 | PASS | conversation 2026-06-15 — rules are not always supported; git workflow must be the /git skill | +| harness-audit-empty-output-gate | A | 2026-08-11 11:04 | PASS | issue #246 — /audit harness must fail closed on empty auditor outputs | +| harness-audit-memory-path | A | 2026-08-11 11:04 | PASS | issue #183 — /audit harness must inspect the active worktree, not a hardcoded root | +| harness-audit-shared-memory | A | 2026-08-11 11:04 | PASS | issue #432 — /audit harness must load durable memory from shared log root in cron worktrees | +| harness-ci-core-paths | A | 2026-08-11 11:04 | PASS | #165 — core sandbox config files must trigger harness CI | +| harness-ci-hooks-paths | A | 2026-08-11 11:04 | PASS | issue #202 — credential/security hook changes must trigger harness CI | +| health-check-docker-stats | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-10 (docker stats vs ps Size) | +| heartbeat-logging-contract | A | 2026-08-11 11:04 | PASS | issue #447 (heartbeat log append hardening) 2026-06-18 | +| locked-append-critical-path | A | 2026-08-11 11:04 | PASS | issue #204 (lock shared runtime log appends) 2026-06-15 | +| markitdown-wiki-ingest | A | 2026-08-11 11:04 | PASS | issue #649 — pinned local-document normalization contract for /wiki ingest | +| memory-gitignore-claim | A | 2026-08-11 11:04 | PASS | issue #101 | +| memory-log-locked-append | A | 2026-08-11 11:04 | PASS | issue #476 and #645 — memory appends are locked and audit has one log owner | +| next-dev-prod | A | 2026-08-11 11:04 | SKIPPED | .oh/memory/MEMORY.md 2026-06-04 | +| oh-devcontainer-restructure | A | 2026-08-11 11:04 | PASS | consolidate devcontainer — .oh/devcontainer/ folded back into .devcontainer/ | +| oh-image-only-deploy | A | 2026-08-11 11:04 | PASS | .oh/tasks/image-only-deploy/prd.json US-004 (issue #609, Flavor B image-only deploy) | +| oh-init-scaffold | A | 2026-08-11 11:04 | PASS | issue #531 Phase 2 | +| oh-npm-package | A | 2026-08-11 11:04 | PASS | npm publish path for the standalone `oh` CLI (@mifune/openharness) — alternative to get-oh.sh | +| oh-payload-manifest | A | 2026-08-11 11:04 | PASS | issue #531 follow-on (.oh payload manifest — oh update ships a declared allowlist) | +| oh-sandbox-image-mode | A | 2026-08-11 11:04 | PASS | conversation 2026-07-05 (basic Docker deployment — prebuilt-image mode) | +| oh-shipped-repo-overridable | A | 2026-08-11 11:04 | PASS | issue #531 follow-on (de-hardcode residual — shipped .oh shell scripts keep the upstream repo overridable) | +| oh-standalone-lifecycle | A | 2026-08-11 11:04 | PASS | issue #564 | +| oh-update | A | 2026-08-11 11:04 | PASS | issue #531 Phase 3 (oh update — upgrade only the .oh control plane) | +| operator-config-guard | A | 2026-08-11 11:04 | PASS | operator directives 2026-08-06 (.config/ and settings.local.json are operator-only) | +| owned-surface-guard | A | 2026-08-11 11:04 | PASS | issue #63 (autopilot-stray-wip-guard) 2026-06-12; issue #81 (owned-paths-zsh-split) 2026-06-13 | +| pnpm-audit-ci-gate | A | 2026-08-11 11:04 | PASS | issue #171 — pnpm security audits must run in CI | +| post-bridge-publish-confirmation | A | 2026-08-11 11:04 | PASS | #523 — post-bridge live publishing requires an explicit final confirmation gate | +| prd-output-path-contract | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-19 | +| project-root-seam | A | 2026-08-11 11:04 | PASS | issue #531 Phase 1 (OH_PROJECT_ROOT project-root seam) 2026-06-26 | +| prompt-miner-log-root-worktree | A | 2026-08-11 11:04 | PASS | .oh/skills/prompt-miner/scripts/render-log-entry.sh | +| prompt-miner-schema-compat | A | 2026-08-11 11:04 | PASS | issue #253 — prompt-miner JSONL schema-drift guard | +| prompt-miner-symlink-entrypoint | A | 2026-08-11 11:04 | PASS | issue #663 — prompt-miner engine no-ops via the documented .claude/skills symlink | +| prompt-miner-weakness-record | A | 2026-08-11 11:04 | PASS | issue #580 — prompt-miner weakness-record (WH-xxx) cluster output | +| ralph-fallback-order | A | 2026-08-11 11:04 | PASS | conversation 2026-06-12 (Ralph default fallback order) | +| repo-map-contract | A | 2026-08-11 11:04 | PASS | issue #464 — repo map must optimize orientation without adding a tree dependency or unmeasured performance claims | +| retro-deterministic-contract | A | 2026-08-11 11:04 | PASS | issue #443 — /retro deterministic output and self-contained helper contract | +| rl-delegation-write-worker | A | 2026-08-11 11:04 | PASS | .oh/memory/MEMORY.md 2026-06-10 (rl-delegation) #57 | +| rlm-context-budget | A | 2026-08-11 11:04 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-006 | +| sandbox-boot-guard-ci | A | 2026-08-11 11:04 | PASS | issue #449 (sandbox image build CI guard) 2026-06-19 | +| ship-spec-ready-finalization | A | 2026-08-11 11:04 | PASS | issue #134 — /ship-spec must finalize ready PRs after gates, not stop at draft scaffold | +| skill-paths | A | 2026-08-11 11:04 | PASS | issue #43 — stale path references; extended by issue #69 — apps/->packages/ rename guard | +| skills-dir-clean | A | 2026-08-11 11:04 | PASS | conversation 2026-06-29 — Pi parses every top-level `.md` in the skills | +| skills-vendored | A | 2026-08-11 11:04 | PASS | absorb .mifune submodule into .oh — the skills/agents/hooks pack is vendored | +| slack-admin-command-surface | A | 2026-08-11 11:04 | PASS | issue #354 — Slack bridge docs must distinguish Pi /msg-bridge commands from Slack DM admin text handlers | +| slack-compact-gateway | A | 2026-08-11 11:04 | PASS | issue #739 / independent FAIL — authenticated package-owned Slack compaction must preserve session continuity and use private exact-PID recovery | +| spec-family-contract | A | 2026-08-11 11:04 | PASS | conversation 2026-06-19 (spec-* family split, issue #265); consolidated into /spec dispatcher 2026-06-23 (one skill, args) | +| submitted-by-trailers | A | 2026-08-11 11:04 | PASS | conversation 2026-06-12 (commit attribution trailers) | +| sync-skill-contract | A | 2026-08-11 11:04 | PASS | issue #331 — /sync dispatcher skill (bidirectional origin↔upstream sync) | +| watchdog-completed-session-reap | A | 2026-08-11 11:04 | PASS | issue #235 (completed autopilot PR session reaping) | +| watchdog-draft-prs | A | 2026-08-11 11:04 | PASS | conversation 2026-06-15 (generic watchdog + stale draft PR recovery) | +| watchdog-stuck-sessions | A | 2026-08-11 11:04 | PASS | issue #240 (Codex zero-credit stuck autopilot sessions) 2026-06-17 | +| weigh-scorer-contract | A | 2026-08-11 11:04 | PASS | .oh/tasks/rlm-weighted-trajectories/prd.json US-003 (2026-06-27) | +| wiki-readme-index | A | 2026-08-11 11:04 | PASS | issue #132 — wiki README index drift guard | +| workflow-boundaries | A | 2026-08-11 11:04 | PASS | conversation 2026-06-19 (workflow consolidation, issue #259) | diff --git a/.oh/evals/probes/slack-admin-command-surface.sh b/.oh/evals/probes/slack-admin-command-surface.sh index aada3cea..aaf6fda1 100755 --- a/.oh/evals/probes/slack-admin-command-surface.sh +++ b/.oh/evals/probes/slack-admin-command-surface.sh @@ -71,7 +71,7 @@ if [ -z "$trusted_line" ] || [ -z "$heading_line" ] || [ "$trusted_line" -le "$h fi # Runtime pin must select the fork branch containing Slack slash handlers. -need_literal "$ROOT/.oh/scripts/gateway.sh" "bridge slash-command handler pin" 'c8b96e9d0fb69611c4e67ae298d1d10d83792a26' +need_literal "$ROOT/.oh/scripts/gateway.sh" "bridge slash-command handler pin" '4056384d7e3901809019e006185a68987fcc8c0b' need_literal "$ROOT/.oh/scripts/gateway.sh" "bridge pin reconciliation marker" '.openharness-pin' need_literal "$ROOT/.oh/scripts/gateway.sh" "bridge pin reconciliation check" 'installed_pin" != "$FORK_PIN' diff --git a/.oh/evals/probes/slack-compact-gateway.sh b/.oh/evals/probes/slack-compact-gateway.sh new file mode 100755 index 00000000..9fc7ff3c --- /dev/null +++ b/.oh/evals/probes/slack-compact-gateway.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# tier: A +# source: issue #739 / independent FAIL — authenticated package-owned Slack compaction must preserve session continuity and use private exact-PID recovery +# desc: Exact bridge pin owns turn-boundary correlation while supervisor owns isolated --continue session and exact-peer Unix IPC +# shellcheck disable=SC2016 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +GATEWAY="$ROOT/.oh/scripts/gateway.sh" +SUPERVISOR="$ROOT/.devcontainer/client-slack-supervise.sh" +DOC="$ROOT/.oh/docs/integrations/slack.md" +ARTIFACT_SMOKE="$ROOT/.oh/scripts/smoke-slack-bridge-artifact.sh" +PIN="4056384d7e3901809019e006185a68987fcc8c0b" + +fail() { echo "REGRESSION: $*" >&2; exit 1; } +need() { + local file="$1" literal="$2" label="$3" + grep -Fq -- "$literal" "$file" || fail "$label" +} +reject() { + local file="$1" pattern="$2" label="$3" + if grep -Eq -- "$pattern" "$file"; then fail "$label"; fi +} + +# The reviewed package commit is the only compact implementation artifact. +need "$GATEWAY" "git+https://github.com/ryaneggz/pi-messenger-bridge.git#$PIN" "gateway is not pinned to reviewed compact-control commit" +[ ! -e "$ROOT/.pi/slack-compact" ] || fail "in-tree Slack compact implementation must not coexist with package control" +[ ! -e "$ROOT/.oh/templates/full/.pi/slack-compact" ] || fail "template vendors a duplicate Slack compact implementation" +reject "$GATEWAY" 'COMPACT_ENTRY|slack-compact/index\.ts' "gateway still loads removed local compact extension" +need "$ARTIFACT_SMOKE" "$PIN" "installed-artifact lifecycle smoke is not bound to reviewed bridge commit" +need "$ARTIFACT_SMOKE" 'session_start' "installed-artifact smoke does not execute the extension lifecycle" + +# Launch two must reopen launch one's compacted active path. +need "$SUPERVISOR" 'SESSION_DIR="${GATEWAY_PI_SESSION_DIR:-$STATE_DIR/pi-sessions}"' "isolated persistent gateway session directory missing" +need "$SUPERVISOR" '--session-dir "$SESSION_DIR" --continue' "Pi does not explicitly continue the isolated gateway session" +need "$SUPERVISOR" 'chmod 700 "$SESSION_DIR"' "gateway session directory is not private" + +# Private one-shot IPC: mode-0600 Unix listener is ready before launch, accepts +# only the exact direct-child Pi SID/PGID through Linux peer credentials, and the +# path is explicitly not treated as a secret. +need "$SUPERVISOR" 'socket.SO_PEERCRED' "Linux peer-credential authentication missing" +need "$SUPERVISOR" 'os.chmod(socket_path, 0o600)' "compact socket is not mode 0600" +need "$SUPERVISOR" 'wait_for_file "$IPC_READY"' "listener-ready synchronization missing" +need "$SUPERVISOR" 'export PI_MSG_BRIDGE_COMPACT_SOCKET="$IPC_SOCKET"' "compact socket path not passed to Pi" +need "$SUPERVISOR" 'peer_ppid != supervisor_pid' "peer is not bound to supervisor direct child" +need "$SUPERVISOR" 'peer_pgid != peer_pid' "peer is not bound to isolated Pi process group" +need "$SUPERVISOR" 'peer_sid != peer_pid' "peer is not bound to isolated Pi session" +need "$SUPERVISOR" 'connection.recv(2) == b"C"' "one-shot completion read missing" +need "$SUPERVISOR" 'connection.sendall(b"A")' "authenticated completion acknowledgement missing" +need "$SUPERVISOR" 'rendezvous metadata, not a secret' "socket path is falsely presented as a secret" +reject "$SUPERVISOR" 'PI_MSG_BRIDGE_COMPACT_FD|mkfifo|SLACK_COMPACT_NONCE|openharness-slack-compact-complete' "forgeable legacy IPC remains" + +# Exact isolated group, bounded TERM→KILL, completion-vs-rc synchronization, and cleanup. +need "$SUPERVISOR" 'setsid pi --session-dir "$SESSION_DIR"' "Pi is not launched in an isolated session/process group" +need "$SUPERVISOR" 'kill -TERM -- "-$pgid"' "watcher does not TERM the recorded exact Pi process group" +need "$SUPERVISOR" 'kill -KILL -- "-$pgid"' "bounded escalation does not KILL stubborn descendants" +need "$SUPERVISOR" 'cd "$HARNESS"' "supervisor does not pin Pi continuation cwd to the harness" +reject "$SUPERVISOR" 'pkill[[:space:]]+-[fP]' "broad pkill remains in supervisor recovery" +need "$SUPERVISOR" 'wait "$COMPACT_WATCHER"' "main loop does not settle completion watcher before rc gate" +need "$SUPERVISOR" 'terminate_authenticated_group "$authenticated_pid"' "authenticated peer group is not restarted exactly" +need "$SUPERVISOR" 'trap on_signal INT TERM HUP' "signal cleanup trap missing" +need "$SUPERVISOR" 'trap cleanup_all EXIT' "EXIT cleanup trap missing" +need "$SUPERVISOR" 'terminate_authenticated_group "$PI_PGID"' "signal cleanup depends on the exited Pi leader instead of its authenticated PGID" +need "$SUPERVISOR" 'rm -f "$STATE" "$HEARTBEAT_FILE"' "supervisor state and heartbeat cleanup missing" +need "$SUPERVISOR" 'if [ "$BACKEND" = pi ]; then rm -f "$LOCK"' "Pi lock cleanup missing" +need "$SUPERVISOR" 'date -u +%s >"$COMPACT_FILE"' "compaction recovery status missing" + +# Operator contract reflects direct delivery and package ownership without a +# false native Slack slash-command claim. +need "$DOC" 'posts the acknowledgement directly to' "direct acknowledgement undocumented" +need "$DOC" 'originating Slack chat/thread' "origin chat/thread correlation undocumented" +need "$DOC" 'Linux peer credentials' "exact-peer IPC contract undocumented" +need "$DOC" 'path is not a secret' "socket-path threat model undocumented" +need "$DOC" '`message_start`' "late remote destination activation undocumented" +need "$DOC" '`message_end`' "internal correlation marker removal undocumented" +need "$DOC" '--session-dir' "continued isolated session contract undocumented" +need "$DOC" 'register or claim a native Slack' "native slash caveat missing" +need "$DOC" '`/compact` slash command' "native slash command caveat missing" +if grep -Fq '"command": "/compact"' "$ROOT/.pi/install/slack-manifest.json"; then + fail "Slack manifest falsely registers native /compact" +fi + +echo "PASS: package-owned Slack compact is turn-correlated, continued, exact-peer IPC, and exact-group supervised" >&2 diff --git a/.oh/scripts/README.md b/.oh/scripts/README.md index 84d8cece..8645063c 100644 --- a/.oh/scripts/README.md +++ b/.oh/scripts/README.md @@ -12,6 +12,7 @@ Provisioning, Ralph execution, and the cron runtime live here. | `release-reservation.mjs` | Computes UTC CalVer candidates and collision progression for releases | | `reserve-github-release.mjs` | Atomically reserves a CalVer tag and recovers its same-SHA GitHub draft | | `promote-release-latest.sh` | Fresh-checks canonical `main`-else-`master` and promotes its image to `latest` by digest | +| `smoke-slack-bridge-artifact.sh` | Installs the exact reviewed bridge git pin and runs its built extension lifecycle | | `cron-runtime.ts` | Croner runtime — scans `.oh/crons/*.md`, schedules, fires each job | | `prompt-miner-caps.sh` | Origin-scoped PR-cap preflight for `.oh/crons/prompt-miner.md` — execs `autopilot-caps.sh` with `AUTOPILOT_REPO=mifunedev/openharness` + `AUTOPILOT_LABEL=prompt-miner` | | `__tests__/` | Vitest unit tests (`vitest.config.ts` at repo root targets this) | diff --git a/.oh/scripts/__tests__/entrypoint.test.ts b/.oh/scripts/__tests__/entrypoint.test.ts index bed2895e..03dded99 100644 --- a/.oh/scripts/__tests__/entrypoint.test.ts +++ b/.oh/scripts/__tests__/entrypoint.test.ts @@ -1,12 +1,32 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; const ROOT = join(import.meta.dirname, "../../.."); const ENTRYPOINT = join(ROOT, ".devcontainer/entrypoint.sh"); +function realPiInstallation(): { cli: string; module: string } | undefined { + const located = spawnSync("bash", ["-lc", "command -v pi"], { encoding: "utf8" }); + const command = located.status === 0 ? located.stdout.trim() : ""; + if (!command || !existsSync(command)) return undefined; + const cli = realpathSync(command); + const module = join(dirname(cli), "index.js"); + return existsSync(module) ? { cli: command, module } : undefined; +} + +const REAL_PI = realPiInstallation(); + function entrypoint(): string { return readFileSync(ENTRYPOINT, "utf8"); } @@ -116,30 +136,571 @@ describe("client-slack bridge supervisor", () => { execFileSync("bash", ["-n", SUPERVISOR]); }); - it("restarts pi on stale-ctx and crash, clears the lock, stops on a clean exit", () => { + it("uses an isolated continued session and exact-PID recovery without broad pkill", () => { const text = readFileSync(SUPERVISOR, "utf8"); - // Detects the pi "extension ctx is stale" failure and kills the bridge pi - // (matched by its unique --extension path) so the loop relaunches it fresh. expect(text).toContain("ctx is stale"); - expect(text).toContain("pkill -f 'pi-messenger-bridge/dist/index.js'"); - // pi runs interactive on the pane TTY: no `| tee` pipe and no --mode rpc, so - // the loaded UI extensions render instead of flooding stdout with JSON. A 2nd - // --extension co-loads the Codex retry-recovery extension. Assert on the pi - // command line itself so comment wording can't satisfy the negatives. - const piLine = text.split("\n").find((l) => /^\s*pi --extension/.test(l)) ?? ""; - expect(piLine).toContain("--approve"); - expect(piLine).toContain('--extension "$RECOVERY_ENTRY"'); - expect(piLine).not.toContain("--mode rpc"); - expect(piLine).not.toContain("tee"); - expect(piLine).toContain('2>>"$LOG"'); - expect(text).toContain("bridge-recovery"); - expect(text).toContain("rc=$?"); - // Clears the single-instance lock before each (re)launch. + expect(text).toContain('SESSION_DIR="${GATEWAY_PI_SESSION_DIR:-$STATE_DIR/pi-sessions}"'); + expect(text).toContain('--session-dir "$SESSION_DIR" --continue'); + expect(text).toContain('--extension "$BRIDGE_ENTRY" --extension "$RECOVERY_ENTRY" --approve'); + expect(text).not.toContain("COMPACT_ENTRY"); + expect(text).not.toMatch(/pkill\s+-f/); + expect(text).not.toMatch(/pkill\s+-P/); + expect(text).toContain('setsid pi --session-dir "$SESSION_DIR"'); + expect(text).toContain('kill -TERM -- "-$pgid"'); + expect(text).toContain('kill -KILL -- "-$pgid"'); + expect(text).toContain("terminate_exact_group"); + expect(text).not.toContain("--mode rpc"); + expect(text).not.toContain("| tee"); expect(text).toContain('rm -f "$LOCK"'); - // A clean pi exit (rc=0) breaks the loop; anything else restarts. - expect(text).toMatch(/\$rc"?\s+-eq\s+0/); - expect(text).toContain("break"); - expect(text).toContain("restarting in 3s"); + expect(text).toContain('RESTART_DELAY="${GATEWAY_RESTART_DELAY:-3}"'); + }); + + it.skipIf(!REAL_PI)( + "uses real Pi CLI/SessionManager continuation across launches and caller cwd variance", + async () => { + const temp = mkdtempSync(join(tmpdir(), "real-pi-continuation-")); + const harness = join(temp, "harness"); + const sessionDir = join(temp, "sessions"); + const firstCaller = join(temp, "caller-one"); + const secondCaller = join(temp, "caller-two"); + const probe = join(temp, "session-probe.ts"); + const observed = join(temp, "observed.jsonl"); + mkdirSync(harness); + mkdirSync(firstCaller); + mkdirSync(secondCaller); + writeFileSync( + probe, + [ + 'import { appendFileSync } from "node:fs";', + "export default function (pi) {", + ' pi.on("session_start", (_event, ctx) => {', + " appendFileSync(process.env.PI_SESSION_PROBE_OUT, `${JSON.stringify({ cwd: ctx.cwd, file: ctx.sessionManager.getSessionFile() })}\\n`);", + " ctx.shutdown();", + " });", + "}", + "", + ].join("\n"), + ); + + const launch = (caller: string) => + spawnSync( + "bash", + [ + "-c", + 'cd "$1" && exec "$2" --mode rpc --session-dir "$3" --continue --extension "$4" --approve', + "_", + harness, + REAL_PI!.cli, + sessionDir, + probe, + ], + { + cwd: caller, + encoding: "utf8", + timeout: 10_000, + env: { ...process.env, PI_SESSION_PROBE_OUT: observed, PI_OFFLINE: "1" }, + }, + ); + + const { SessionManager } = (await import(pathToFileURL(REAL_PI!.module).href)) as any; + const seeded = SessionManager.create(harness, sessionDir); + const kept = seeded.appendMessage({ role: "user", content: "gateway request", timestamp: Date.now() }); + seeded.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "gateway response" }], + api: "test", + provider: "test", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + const seededFile = seeded.getSessionFile(); + expect(seededFile).toBeTruthy(); + + const first = launch(firstCaller); + expect(first.status, first.stderr).toBe(0); + const firstObservation = JSON.parse(readFileSync(observed, "utf8").trim()); + expect(firstObservation).toEqual({ cwd: harness, file: seededFile }); + + const firstManager = SessionManager.open(firstObservation.file, sessionDir); + firstManager.appendCompaction("real compacted gateway state", kept, 42); + + const second = launch(secondCaller); + expect(second.status, second.stderr).toBe(0); + const observations = readFileSync(observed, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(observations).toHaveLength(2); + expect(observations[1]).toEqual({ cwd: harness, file: firstObservation.file }); + + const continued = SessionManager.open(observations[1].file, sessionDir); + expect( + continued.getEntries().some( + (entry: any) => entry.type === "compaction" && entry.summary === "real compacted gateway state", + ), + ).toBe(true); + }, + 30_000, + ); + + it( + "pins Pi to the harness cwd while preserving inherited TTY descriptors in its isolated group", + () => { + const temp = mkdtempSync(join(tmpdir(), "pi-supervisor-tty-")); + const harness = join(temp, "harness"); + const caller = join(temp, "other-cwd"); + const bin = join(temp, "bin"); + const state = join(temp, "state"); + const observed = join(temp, "observed"); + const log = join(temp, "gateway.log"); + mkdirSync(harness); + mkdirSync(caller); + mkdirSync(bin); + mkdirSync(state); + writeFileSync(log, ""); + writeFileSync( + join(bin, "pi"), + [ + "#!/usr/bin/env bash", + 'stdin_tty=no; stdout_tty=no; [ -t 0 ] && stdin_tty=yes; [ -t 1 ] && stdout_tty=yes', + 'printf "cwd=%s\\nstdin_tty=%s\\nstdout_tty=%s\\npid=%s\\npgid=%s\\n" "$PWD" "$stdin_tty" "$stdout_tty" "$$" "$(ps -o pgid= -p $$ | tr -d " ")" > "$OBSERVED"', + "exit 0", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + execFileSync("script", ["-qefc", `bash ${JSON.stringify(SUPERVISOR)}`, "/dev/null"], { + cwd: caller, + timeout: 30_000, + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + HOME: temp, + HARNESS: harness, + LOG: log, + GATEWAY_STATE_DIR: state, + GATEWAY_HEARTBEAT_INTERVAL: "1", + BRIDGE_ENTRY: "/fixture/pi-messenger-bridge/dist/index.js", + RECOVERY_ENTRY: "/fixture/bridge-recovery/index.ts", + OBSERVED: observed, + }, + }); + + const values = Object.fromEntries( + readFileSync(observed, "utf8") + .trim() + .split("\n") + .map((line) => line.split("=", 2)), + ); + expect(values.cwd).toBe(harness); + expect(values.stdin_tty).toBe("yes"); + expect(values.stdout_tty).toBe("yes"); + expect(values.pgid).toBe(values.pid); + }, + 40_000, + ); + + it("prepares an exact-peer one-shot socket before launch and settles it before rc", () => { + const text = readFileSync(SUPERVISOR, "utf8"); + const prepareAt = text.indexOf("prepare_compact_watcher"); + const readyAt = text.indexOf('wait_for_file "$IPC_READY"', prepareAt); + const launchAt = text.indexOf('setsid pi --session-dir "$SESSION_DIR"'); + const waitAt = text.indexOf('wait "$COMPACT_WATCHER"', launchAt); + const rcGateAt = text.indexOf('if [ "$rc" -eq 0 ]', waitAt); + expect(prepareAt).toBeGreaterThan(-1); + expect(readyAt).toBeGreaterThan(prepareAt); + expect(launchAt).toBeGreaterThan(readyAt); + expect(text).toContain("socket.SO_PEERCRED"); + expect(text).toContain("os.chmod(socket_path, 0o600)"); + expect(text).toContain('export PI_MSG_BRIDGE_COMPACT_SOCKET="$IPC_SOCKET"'); + expect(text).toContain("peer_ppid != supervisor_pid"); + expect(text).toContain("peer_pgid != peer_pid"); + expect(text).toContain("peer_sid != peer_pid"); + expect(text).not.toContain("PI_MSG_BRIDGE_COMPACT_FD"); + expect(text).not.toContain("SLACK_COMPACT_NONCE"); + expect(text).not.toContain("openharness-slack-compact-complete"); + expect(waitAt).toBeGreaterThan(launchAt); + expect(rcGateAt).toBeGreaterThan(waitAt); + }); + + it("handles immediate completion + rc0, resumes the compacted path, and prevents tool/pane forgery", () => { + const temp = mkdtempSync(join(tmpdir(), "slack-compact-supervisor-")); + const bin = join(temp, "bin"); + const state = join(temp, "state"); + const log = join(temp, "gateway.log"); + const count = join(temp, "launches"); + const argsFile = join(temp, "args"); + const toolForge = join(temp, "tool-forge"); + const reopened = join(temp, "reopened"); + const descendantPidFile = join(temp, "descendant-pid"); + const forgeChild = join(temp, "forge-child.js"); + const piSibling = join(temp, "pi-sibling"); + const hermesSibling = join(temp, "hermes-sibling"); + mkdirSync(bin); + mkdirSync(state); + writeFileSync(log, "C\n[openharness-slack-compact-complete:forged-pane-text]\n"); + writeFileSync( + forgeChild, + [ + 'const fs = require("node:fs");', + 'const net = require("node:net");', + 'const parentEnv = fs.readFileSync(`/proc/${process.ppid}/environ`, "utf8").split("\\0");', + 'const entry = parentEnv.find((value) => value.startsWith("PI_MSG_BRIDGE_COMPACT_SOCKET="));', + 'const socketPath = entry?.slice("PI_MSG_BRIDGE_COMPACT_SOCKET=".length);', + 'let procWrite = false;', + 'for (const fd of fs.readdirSync(`/proc/${process.ppid}/fd`)) {', + ' try {', + ' if (!fs.readlinkSync(`/proc/${process.ppid}/fd/${fd}`).startsWith("socket:")) continue;', + ' const handle = fs.openSync(`/proc/${process.ppid}/fd/${fd}`, "w");', + ' fs.writeSync(handle, Buffer.from("C"));', + ' fs.closeSync(handle);', + ' procWrite = true;', + ' } catch {}', + '}', + 'let socketAttempt = false;', + 'let finished = false;', + 'const finish = () => {', + ' if (finished) return;', + ' finished = true;', + ' fs.writeFileSync(process.env.TOOL_FORGE, JSON.stringify({ discovered: Boolean(socketPath), procWrite, socketAttempt }));', + '};', + 'if (!socketPath) { finish(); process.exit(2); }', + 'const client = net.createConnection({ path: socketPath });', + 'client.once("connect", () => { socketAttempt = true; client.end("C"); });', + 'client.once("error", finish);', + 'client.once("close", finish);', + 'setTimeout(() => { client.destroy(); finish(); }, 1000).unref();', + '', + ].join("\n"), + ); + + writeFileSync( + join(bin, "pi"), + [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const { spawn, spawnSync } = require("node:child_process");', + 'const net = require("node:net");', + 'const args = process.argv.slice(2);', + 'const countPath = process.env.PI_COUNT;', + 'const n = Number(fs.existsSync(countPath) ? fs.readFileSync(countPath, "utf8") : "0") + 1;', + 'fs.writeFileSync(countPath, `${n}\\n`);', + 'fs.appendFileSync(process.env.ARGS_FILE, `${n}:${args.join(" ")}\\n`);', + 'const dirAt = args.indexOf("--session-dir");', + 'if (dirAt < 0 || args[dirAt + 1] !== process.env.EXPECTED_SESSION_DIR || !args.includes("--continue")) process.exit(41);', + 'fs.mkdirSync(process.env.EXPECTED_SESSION_DIR, { recursive: true });', + 'const session = `${process.env.EXPECTED_SESSION_DIR}/active.jsonl`;', + 'if (n === 1) {', + ' fs.writeFileSync(session, "{\\"type\\":\\"compaction\\",\\"summary\\":\\"active-path\\"}\\n");', + ' const socketPath = process.env.PI_MSG_BRIDGE_COMPACT_SOCKET;', + ' if ((fs.statSync(socketPath).mode & 0o777) !== 0o600) process.exit(42);', + ' const forged = spawnSync(process.execPath, [process.env.FORGE_CHILD], { env: { TOOL_FORGE: process.env.TOOL_FORGE } });', + ' if (forged.status !== 0) process.exit(43);', + ' Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);', + ' const forgedTrigger = fs.existsSync(process.env.EXPECTED_RESTART_TRIGGER);', + ' const forgeResult = JSON.parse(fs.readFileSync(process.env.TOOL_FORGE, "utf8"));', + ' fs.writeFileSync(process.env.TOOL_FORGE, JSON.stringify({ ...forgeResult, forgedTrigger }));', + ' const descendant = spawn("bash", ["-c", "trap \\\'\\\' TERM; while true; do sleep 1; done"], { stdio: "ignore" });', + ' descendant.unref();', + ' fs.writeFileSync(process.env.DESCENDANT_PID_FILE, `${descendant.pid}\\n`);', + ' const client = net.createConnection({ path: socketPath });', + ' client.once("connect", () => client.write("C"));', + ' client.once("data", (reply) => process.exit(reply.toString() === "A" ? 0 : 45));', + ' client.once("error", () => process.exit(44));', + '} else {', + ' if (fs.readFileSync(session, "utf8").includes("active-path")) fs.writeFileSync(process.env.REOPENED, session);', + ' process.exit(0);', + '}', + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const piSiblingProcess = spawn("bash", [ + "-c", + `exec -a pi bash -c 'trap "exit 0" TERM; printf alive > ${JSON.stringify(piSibling)}; while true; do sleep 1; done'`, + ]); + const hermesSiblingProcess = spawn("bash", [ + "-c", + `exec -a hermes bash -c 'trap "exit 0" TERM; printf alive > ${JSON.stringify(hermesSibling)}; while true; do sleep 1; done'`, + ]); + try { + execFileSync("bash", [SUPERVISOR], { + timeout: 15_000, + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + HOME: temp, + HARNESS: temp, + LOG: log, + GATEWAY_STATE_DIR: state, + GATEWAY_HEARTBEAT_INTERVAL: "1", + GATEWAY_RESTART_DELAY: "0", + BRIDGE_ENTRY: "/fixture/pi-messenger-bridge/dist/index.js", + RECOVERY_ENTRY: "/fixture/bridge-recovery/index.ts", + PI_COUNT: count, + ARGS_FILE: argsFile, + TOOL_FORGE: toolForge, + FORGE_CHILD: forgeChild, + EXPECTED_RESTART_TRIGGER: join(state, "pi.restart-trigger"), + REOPENED: reopened, + DESCENDANT_PID_FILE: descendantPidFile, + EXPECTED_SESSION_DIR: join(state, "pi-sessions"), + }, + }); + + expect(readFileSync(count, "utf8").trim()).toBe("2"); + expect(JSON.parse(readFileSync(toolForge, "utf8"))).toEqual({ + discovered: true, + procWrite: false, + socketAttempt: true, + forgedTrigger: false, + }); + expect(readFileSync(reopened, "utf8").trim()).toBe(join(state, "pi-sessions/active.jsonl")); + expect(readFileSync(argsFile, "utf8").match(/--continue/g)).toHaveLength(2); + expect(existsSync(join(state, "pi.compact"))).toBe(false); + expect(existsSync(join(state, "pi.state"))).toBe(false); + expect(readFileSync(log, "utf8").match(/Slack compaction completed/g)).toHaveLength(1); + const descendantPid = readFileSync(descendantPidFile, "utf8").trim(); + const descendantStat = `/proc/${descendantPid}/stat`; + if (existsSync(descendantStat)) { + expect(readFileSync(descendantStat, "utf8").split(" ")[2]).toBe("Z"); + } + expect(piSiblingProcess.exitCode).toBeNull(); + expect(hermesSiblingProcess.exitCode).toBeNull(); + expect(readFileSync(piSibling, "utf8")).toBe("alive"); + expect(readFileSync(hermesSibling, "utf8")).toBe("alive"); + } finally { + piSiblingProcess.kill("SIGTERM"); + hermesSiblingProcess.kill("SIGTERM"); + } + }); + + it.each(["SIGTERM", "SIGHUP"] as const)("cleans exact runtime state on %s while preserving sessions", async (signal) => { + const temp = mkdtempSync(join(tmpdir(), "slack-supervisor-cleanup-")); + const bin = join(temp, "bin"); + const state = join(temp, "state"); + const log = join(temp, "gateway.log"); + mkdirSync(bin); + mkdirSync(state); + writeFileSync(log, ""); + writeFileSync( + join(bin, "pi"), + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$$" > "$FAKE_PI_STARTED"', + 'printf "locked\\n" > "$HOME/.pi/msg-bridge.lock"', + "trap 'exit 0' TERM HUP INT", + "while true; do sleep 1; done", + "", + ].join("\n"), + { mode: 0o755 }, + ); + mkdirSync(join(temp, ".pi"), { recursive: true }); + const started = join(temp, "pi-started"); + const child = spawn("bash", [SUPERVISOR], { + stdio: "ignore", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + HOME: temp, + HARNESS: temp, + LOG: log, + GATEWAY_STATE_DIR: state, + GATEWAY_HEARTBEAT_INTERVAL: "0.05", + BRIDGE_ENTRY: "/fixture/pi-messenger-bridge/dist/index.js", + RECOVERY_ENTRY: "/fixture/bridge-recovery/index.ts", + FAKE_PI_STARTED: started, + }, + }); + + for (let i = 0; i < 200 && (!existsSync(started) || !existsSync(join(state, "pi.heartbeat"))); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(existsSync(started)).toBe(true); + expect(existsSync(join(state, "pi.heartbeat"))).toBe(true); + child.kill(signal); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("supervisor did not stop")), 5000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + expect(existsSync(join(temp, ".pi/msg-bridge.lock"))).toBe(false); + expect(existsSync(join(state, "pi.state"))).toBe(false); + expect(existsSync(join(state, "pi.heartbeat"))).toBe(false); + expect(existsSync(join(state, "pi.pid"))).toBe(false); + expect(readdirSync(state).some((name) => name.includes("compact-ipc") || name.includes("restart-"))).toBe(false); + expect(existsSync(join(state, "pi-sessions"))).toBe(true); + }); + + it( + "kills the authenticated group on signal after its leader exits while preserving a sibling", + async () => { + const temp = mkdtempSync(join(tmpdir(), "slack-supervisor-leader-gone-")); + const bin = join(temp, "bin"); + const state = join(temp, "state"); + const log = join(temp, "gateway.log"); + const leaderFile = join(temp, "leader-pid"); + const descendantFile = join(temp, "descendant-pid"); + const siblingReady = join(temp, "sibling-ready"); + mkdirSync(bin); + mkdirSync(state); + mkdirSync(join(temp, ".pi"), { recursive: true }); + writeFileSync(log, ""); + writeFileSync( + join(bin, "pi"), + [ + "#!/usr/bin/env bash", + 'printf "locked\\n" > "$HOME/.pi/msg-bridge.lock"', + "bash -c 'trap \"\" TERM HUP INT; while true; do sleep 1; done' /dev/null 2>&1 &", + 'printf "%s\\n" "$!" > "$DESCENDANT_PID_FILE"', + 'printf "%s\\n" "$$" > "$LEADER_PID_FILE"', + 'attempts=200; while [ ! -s "$EXPECTED_PGID_FILE" ] && [ "$attempts" -gt 0 ]; do attempts=$((attempts - 1)); sleep 0.01; done', + "exit 0", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const sibling = spawn("bash", [ + "-c", + `trap 'exit 0' TERM; printf alive > ${JSON.stringify(siblingReady)}; while true; do sleep 1; done`, + ]); + const supervisor = spawn("bash", [SUPERVISOR], { + stdio: "ignore", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + HOME: temp, + HARNESS: temp, + LOG: log, + GATEWAY_STATE_DIR: state, + GATEWAY_HEARTBEAT_INTERVAL: "0.05", + BRIDGE_ENTRY: "/fixture/pi-messenger-bridge/dist/index.js", + RECOVERY_ENTRY: "/fixture/bridge-recovery/index.ts", + LEADER_PID_FILE: leaderFile, + DESCENDANT_PID_FILE: descendantFile, + EXPECTED_PGID_FILE: join(state, "pi.pgid"), + }, + }); + + let descendantPid = ""; + const processState = (pid: string): string | undefined => { + if (!/^\d+$/.test(pid)) return undefined; + const statPath = `/proc/${pid}/stat`; + if (!existsSync(statPath)) return undefined; + const stat = readFileSync(statPath, "utf8"); + return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0]; + }; + try { + for ( + let i = 0; + i < 300 && + (!existsSync(leaderFile) || + !existsSync(descendantFile) || + !existsSync(join(state, "pi.heartbeat")) || + !existsSync(siblingReady)); + i += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(existsSync(leaderFile)).toBe(true); + expect(existsSync(descendantFile)).toBe(true); + const leaderPid = readFileSync(leaderFile, "utf8").trim(); + descendantPid = readFileSync(descendantFile, "utf8").trim(); + + for (let i = 0; i < 200 && ![undefined, "Z"].includes(processState(leaderPid)); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect([undefined, "Z"]).toContain(processState(leaderPid)); + expect(processState(descendantPid)).not.toBeUndefined(); + expect(processState(descendantPid)).not.toBe("Z"); + expect(supervisor.exitCode).toBeNull(); + + // Preempt the normal post-wait group close while only the stubborn + // descendant remains. Signal cleanup must trust the recorded PGID, + // rather than returning early because leader validation now fails. + supervisor.kill("SIGTERM"); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("supervisor did not stop")), 5000); + supervisor.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + const descendantState = processState(descendantPid); + expect([undefined, "Z"]).toContain(descendantState); + expect(sibling.exitCode).toBeNull(); + expect(readFileSync(siblingReady, "utf8")).toBe("alive"); + expect(existsSync(join(temp, ".pi/msg-bridge.lock"))).toBe(false); + for (const name of [ + "pi.state", + "pi.heartbeat", + "pi.pid", + "pi.pgid", + "pi.stale", + "pi.compact", + "pi.restart-trigger", + ]) { + expect(existsSync(join(state, name)), name).toBe(false); + } + expect( + readdirSync(state).some( + (name) => name.includes("compact-ipc") || name.includes("restart-claim"), + ), + ).toBe(false); + } finally { + if (supervisor.exitCode === null) supervisor.kill("SIGKILL"); + if (descendantPid && ![undefined, "Z"].includes(processState(descendantPid))) { + process.kill(Number(descendantPid), "SIGKILL"); + } + sibling.kill("SIGTERM"); + } + }, + 15_000, + ); + + it("keeps generic Hermes supervision out of Pi lock/session/IPC state", () => { + const temp = mkdtempSync(join(tmpdir(), "hermes-supervisor-")); + const state = join(temp, "state"); + const log = join(temp, "gateway.log"); + const lock = join(temp, ".pi/msg-bridge.lock"); + mkdirSync(join(temp, ".pi"), { recursive: true }); + mkdirSync(state); + writeFileSync(log, ""); + writeFileSync(lock, "sibling-pi-lock\n"); + + execFileSync("bash", [SUPERVISOR], { + timeout: 5000, + env: { + ...process.env, + HOME: temp, + HARNESS: temp, + LOG: log, + GATEWAY_STATE_DIR: state, + GATEWAY_BACKEND: "hermes", + GATEWAY_HEARTBEAT_INTERVAL: "0.05", + SUPERVISE_CMD: "exit 0", + }, + }); + + expect(readFileSync(lock, "utf8")).toBe("sibling-pi-lock\n"); + expect(existsSync(join(state, "pi-sessions"))).toBe(false); + expect(readdirSync(state).some((name) => name.includes("compact-ipc"))).toBe(false); }); it("is referenced by gateway.sh, which the entrypoint delegates to", () => { diff --git a/.oh/scripts/__tests__/gateway.test.ts b/.oh/scripts/__tests__/gateway.test.ts index 7ac0d35e..d72cd4e8 100644 --- a/.oh/scripts/__tests__/gateway.test.ts +++ b/.oh/scripts/__tests__/gateway.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; const ROOT = join(import.meta.dirname, "../../.."); const GATEWAY = join(ROOT, ".oh/scripts/gateway.sh"); +const BRIDGE_ARTIFACT_SMOKE = join(ROOT, ".oh/scripts/smoke-slack-bridge-artifact.sh"); function gateway(): string { return readFileSync(GATEWAY, "utf8"); @@ -16,8 +17,12 @@ describe("gateway client-session launcher", () => { execFileSync("bash", ["-n", GATEWAY]); }); - it("runs the pi backend under the self-healing supervisor", () => { + it("runs Pi under the self-healing supervisor with package-owned compact control", () => { expect(gateway()).toContain(".devcontainer/client-slack-supervise.sh"); + expect(gateway()).toContain('tmux new-session -d -c "$HARNESS" -s "$session"'); + expect(gateway()).toContain('cd \\"$HARNESS\\" || exit 1'); + expect(gateway()).not.toContain(".pi/slack-compact/index.ts"); + expect(gateway()).not.toContain("COMPACT_ENTRY"); }); it("runs the hermes backend via `hermes gateway run`", () => { @@ -50,11 +55,62 @@ describe("gateway client-session launcher", () => { }); it("reconciles the installed bridge when the reviewed fork pin changes", () => { - expect(gateway()).toContain("c8b96e9d0fb69611c4e67ae298d1d10d83792a26"); + expect(gateway()).toContain("4056384d7e3901809019e006185a68987fcc8c0b"); expect(gateway()).toContain(".openharness-pin"); expect(gateway()).toContain('installed_pin" != "$FORK_PIN'); expect(gateway()).toContain('printf \'%s\\n\' "$FORK_PIN" >"$bridge_pin_file"'); }); + + it( + "installs the exact bridge artifact and runs its real extension lifecycle", + async () => { + await new Promise((resolve, reject) => { + const child = spawn("bash", [BRIDGE_ARTIFACT_SMOKE], { stdio: "pipe" }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`artifact smoke exited ${code ?? signal}: ${stderr}`)); + }); + }); + }, + 310_000, + ); + + it("reports a recent compaction reconnect without exposing recovery nonce data", () => { + const temp = mkdtempSync(join(tmpdir(), "gateway-status-")); + const bin = join(temp, "bin"); + const state = join(temp, "state"); + mkdirSync(bin); + mkdirSync(state); + writeFileSync( + join(bin, "tmux"), + '#!/usr/bin/env bash\n[ "$1" = ls ] && printf "client-slack-pi\\n"\n', + { mode: 0o755 }, + ); + writeFileSync( + join(state, "pi.state"), + "backend=pi\nsession=client-slack-pi\nbridge_token=present\nlaunches=2\n", + ); + const now = Math.floor(Date.now() / 1000).toString(); + writeFileSync(join(state, "pi.heartbeat"), `${now}\n`); + writeFileSync(join(state, "pi.compact"), `${now}\n`); + + const output = execFileSync("bash", [GATEWAY, "status"], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + GATEWAY_STATE_DIR: state, + }, + }); + expect(output).toContain("client-slack-pi healthy"); + expect(output).toContain("compaction reconnected"); + expect(output).not.toMatch(/[a-f0-9]{48}/); + }); }); describe("gateway pi: launches client-slack-pi handling tokens as data", () => { @@ -67,7 +123,8 @@ describe("gateway pi: launches client-slack-pi handling tokens as data", () => { const piEnv = join(temp, "pi-env.txt"); const pwned = join(temp, "pwned"); mkdirSync(join(harness, ".devcontainer"), { recursive: true }); - mkdirSync(join(harness, ".pi"), { recursive: true }); + mkdirSync(join(harness, ".pi/bridge-recovery"), { recursive: true }); + writeFileSync(join(harness, ".pi/bridge-recovery/index.ts"), "// recovery fixture\n"); mkdirSync(home, { recursive: true }); mkdirSync(bin); @@ -103,10 +160,15 @@ describe("gateway pi: launches client-slack-pi handling tokens as data", () => { ].join("\n"), { mode: 0o755 }, ); - // pi stub: records the PI_SLACK_* values it actually received in its env. + // pi stub: records token values and package/recovery extension order. writeFileSync( join(bin, "pi"), - `#!/usr/bin/env bash\nprintf 'PI_SLACK_APP_TOKEN=%s\nPI_SLACK_BOT_TOKEN=%s\n' "$PI_SLACK_APP_TOKEN" "$PI_SLACK_BOT_TOKEN" > "$PI_ENV_FILE"\n`, + [ + "#!/usr/bin/env bash", + "printf 'PI_SLACK_APP_TOKEN=%s\\nPI_SLACK_BOT_TOKEN=%s\\nARGS=%s\\n' \\", + " \"$PI_SLACK_APP_TOKEN\" \"$PI_SLACK_BOT_TOKEN\" \"$*\" > \"$PI_ENV_FILE\"", + "", + ].join("\n"), { mode: 0o755 }, ); // npm stub: gateway.sh npm-installs the bridge when missing; no-op here. @@ -115,7 +177,11 @@ describe("gateway pi: launches client-slack-pi handling tokens as data", () => { // here it just exec's the pi stub once so we can inspect the env it got. writeFileSync( join(harness, ".devcontainer", "client-slack-supervise.sh"), - '#!/usr/bin/env bash\nexec pi --extension "${BRIDGE_ENTRY:-x}" --extension "${RECOVERY_ENTRY:-y}" --approve\n', + [ + "#!/usr/bin/env bash", + 'exec pi --session-dir "${GATEWAY_STATE_DIR:-$HOME/.pi/gateway}/pi-sessions" --continue --extension "${BRIDGE_ENTRY:-x}" --extension "${RECOVERY_ENTRY:-y}" --approve', + "", + ].join("\n"), { mode: 0o755 }, ); @@ -156,9 +222,14 @@ describe("gateway pi: launches client-slack-pi handling tokens as data", () => { }); // Tokens round-trip to pi verbatim as data, and the injection never fired. - expect(readFileSync(piEnv, "utf8")).toBe( - ["PI_SLACK_APP_TOKEN=xapp token; touch $PWNED", "PI_SLACK_BOT_TOKEN=xoxb'quoted", ""].join("\n"), + const recorded = readFileSync(piEnv, "utf8"); + expect(recorded).toContain("PI_SLACK_APP_TOKEN=xapp token; touch $PWNED\n"); + expect(recorded).toContain("PI_SLACK_BOT_TOKEN=xoxb'quoted\n"); + expect(recorded).toContain( + `ARGS=--session-dir ${join(harness, ".pi/gateway/pi-sessions")} --continue --extension ${join(harness, ".pi/bridge/node_modules/pi-messenger-bridge/dist/index.js")} --extension ${join(harness, ".pi/bridge-recovery/index.ts")} --approve`, ); + expect(recorded).not.toContain("slack-compact"); + expect(recorded).not.toContain("NONCE"); expect(existsSync(pwned)).toBe(false); // The non-secret config was seeded into ~/.pi (tokens stay out of it). diff --git a/.oh/scripts/gateway.sh b/.oh/scripts/gateway.sh index e668150f..225f4009 100755 --- a/.oh/scripts/gateway.sh +++ b/.oh/scripts/gateway.sh @@ -32,9 +32,9 @@ set -u HARNESS="${HARNESS:-${OH_PROJECT_ROOT:-/home/sandbox/harness}}" SLACK_ENV="$HARNESS/.devcontainer/.env" -# TEMPORARY fork pin — carries thread replies + Slack admin slash handlers; -# revert once upstream merges and publishes them (see .pi/UPSTREAM.md). -FORK_PIN="github:ryaneggz/pi-messenger-bridge#c8b96e9d0fb69611c4e67ae298d1d10d83792a26" +# TEMPORARY exact fork pin — thread replies, Slack admin handlers, and the +# reviewed supervised-compaction control from ryaneggz/pi-messenger-bridge#2. +FORK_PIN="git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b" usage() { echo "Usage:" @@ -60,7 +60,7 @@ session_live() { tmux ls -F '#{session_name}' 2>/dev/null | grep -Fxq "$1"; } ANSI_STRIP="sed -u 's/\\x1b\\[[0-9;?]*[A-Za-z]//g; s/\\r//g'" # Non-secret runtime state the supervisor writes (see client-slack-supervise.sh): -# $STATE_DIR/.{state,heartbeat,stale}. Used to report health, not just +# $STATE_DIR/.{state,heartbeat,stale,compact}. Used to report health, not just # session existence, in `gateway status`. STATE_DIR="${GATEWAY_STATE_DIR:-$HOME/.pi/gateway}" STALE_AFTER="${GATEWAY_STALE_AFTER:-60}" # seconds without a heartbeat => not healthy @@ -85,10 +85,12 @@ _state_age() { backend_health() { local b="$1" local state="$STATE_DIR/$b.state" hb="$STATE_DIR/$b.heartbeat" stale="$STATE_DIR/$b.stale" - local token launches hbage staleage extra="" + local compact="$STATE_DIR/$b.compact" + local token launches hbage staleage compactage extra="" launches=$(_state_kv "$state" launches) || launches="" if [ -n "$launches" ] && [ "$launches" -gt 1 ] 2>/dev/null; then extra=" · $((launches - 1)) restart(s)"; fi - if staleage=$(_state_age "$stale"); then extra="$extra · recovered ${staleage}s ago"; fi + if staleage=$(_state_age "$stale"); then extra="$extra · stale-ctx recovered ${staleage}s ago"; fi + if compactage=$(_state_age "$compact"); then extra="$extra · compaction reconnected ${compactage}s ago"; fi token=$(_state_kv "$state" bridge_token) || token="" if [ "$token" = absent ]; then printf 'running · disconnected (no PI_SLACK token)%s' "$extra"; return 0; fi if hbage=$(_state_age "$hb") && [ "$hbage" -le "$STALE_AFTER" ] 2>/dev/null; then @@ -165,6 +167,8 @@ start_pi() { command -v pi >/dev/null 2>&1 \ || { echo "[gateway] 'pi' not found on PATH — run inside the sandbox" >&2; return 1; } + [ -f "$recovery_entry" ] \ + || { echo "[gateway] missing Pi recovery extension: $recovery_entry" >&2; return 1; } # Tokens (optional): source from the Compose env file if not already exported. # Extract only the two keys as DATA (never eval the file), never echo values. @@ -209,8 +213,8 @@ start_pi() { [ -n "${PI_SLACK_BOT_TOKEN:-}" ] && printf 'export PI_SLACK_BOT_TOKEN=%q\n' "$PI_SLACK_BOT_TOKEN" } >>"$envf" - if tmux new-session -d -s "$session" \ - "bash -c '. \"$envf\"; rm -f \"$envf\"; exec bash \"$HARNESS/.devcontainer/client-slack-supervise.sh\"'"; then + if tmux new-session -d -c "$HARNESS" -s "$session" \ + "bash -c '. \"$envf\"; rm -f \"$envf\"; cd \"$HARNESS\" || exit 1; exec bash \"$HARNESS/.devcontainer/client-slack-supervise.sh\"'"; then # pi runs interactive (no `| tee`), so mirror the pane into the log, # ANSI-stripped, for the stale-ctx watchdog and humans. tmux pipe-pane -o -t "$session" "$ANSI_STRIP >> $log" 2>/dev/null || true @@ -382,8 +386,8 @@ start_hermes() { printf 'export SUPERVISE_CMD=%q\n' "$run_cmd" } >>"$envf" - if tmux new-session -d -s "$session" \ - "bash -c '. \"$envf\"; rm -f \"$envf\"; exec bash \"$HARNESS/.devcontainer/client-slack-supervise.sh\"'"; then + if tmux new-session -d -c "$HARNESS" -s "$session" \ + "bash -c '. \"$envf\"; rm -f \"$envf\"; cd \"$HARNESS\" || exit 1; exec bash \"$HARNESS/.devcontainer/client-slack-supervise.sh\"'"; then tmux pipe-pane -o -t "$session" "$ANSI_STRIP >> $log" 2>/dev/null || true else rm -f "$envf" diff --git a/.oh/scripts/smoke-slack-bridge-artifact.sh b/.oh/scripts/smoke-slack-bridge-artifact.sh new file mode 100755 index 00000000..10933852 --- /dev/null +++ b/.oh/scripts/smoke-slack-bridge-artifact.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Install the exact reviewed bridge git artifact, then exercise its built dist +# through the real extension session_start/session_shutdown lifecycle. +set -euo pipefail + +PIN="git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b" +TMP=$(mktemp -d "${TMPDIR:-/tmp}/slack-bridge-artifact.XXXXXX") +trap 'rm -rf "$TMP"' EXIT INT TERM HUP + +mkdir -p "$TMP/home" "$TMP/install" +GIT_SSH_COMMAND=false npm install --prefix "$TMP/install" --no-fund --no-audit "$PIN" >/dev/null + +ENTRY="$TMP/install/node_modules/pi-messenger-bridge/dist/index.js" +CONTROL="$TMP/install/node_modules/pi-messenger-bridge/dist/compact-control.js" +[ -f "$ENTRY" ] || { echo "installed bridge entry missing: $ENTRY" >&2; exit 1; } +[ -f "$CONTROL" ] || { echo "installed compact controller missing: $CONTROL" >&2; exit 1; } +grep -Fq '4056384d7e3901809019e006185a68987fcc8c0b' "$TMP/install/package-lock.json" \ + || { echo "installed bridge lock is not bound to the reviewed commit" >&2; exit 1; } +grep -Fq 'PI_MSG_BRIDGE_COMPACT_SOCKET' "$CONTROL" \ + || { echo "installed bridge lacks Unix compact endpoint" >&2; exit 1; } +if grep -Fq 'PI_MSG_BRIDGE_COMPACT_FD' "$CONTROL"; then + echo "installed bridge still exposes forgeable compact fd" >&2 + exit 1 +fi + +env -u PI_SLACK_APP_TOKEN -u PI_SLACK_BOT_TOKEN \ + -u PI_TELEGRAM_TOKEN -u PI_WHATSAPP_AUTH_PATH -u PI_DISCORD_TOKEN \ + -u PI_MATRIX_HOMESERVER -u PI_MATRIX_ACCESS_TOKEN \ + HOME="$TMP/home" PI_OFFLINE=1 node --input-type=module - "$ENTRY" <<'NODE' +import { pathToFileURL } from "node:url"; + +const entry = process.argv[2]; +const extension = (await import(pathToFileURL(entry).href)).default; +if (typeof extension !== "function") throw new Error("installed bridge has no extension factory"); + +const handlers = new Map(); +let commandRegistered = false; +const pi = { + on(name, handler) { + const list = handlers.get(name) ?? []; + list.push(handler); + handlers.set(name, list); + }, + registerCommand(name) { + if (name === "msg-bridge") commandRegistered = true; + }, + sendUserMessage() { + throw new Error("empty-config lifecycle unexpectedly entered an agent turn"); + }, +}; +extension(pi); + +const context = { + cwd: process.cwd(), + compact() {}, + isIdle: () => true, + ui: { notify() {}, setWidget() {} }, +}; +for (const handler of handlers.get("session_start") ?? []) { + await handler({ reason: "startup" }, context); +} +await new Promise((resolve) => setTimeout(resolve, 0)); +for (const handler of handlers.get("session_shutdown") ?? []) { + await handler({ reason: "quit" }, context); +} + +if (!commandRegistered) throw new Error("installed bridge did not register /msg-bridge"); +if (!(handlers.get("agent_settled")?.length > 0)) { + throw new Error("installed bridge did not register agent_settled control"); +} +if (!(handlers.get("message_start")?.length > 0)) { + throw new Error("installed bridge did not register remote-turn start correlation"); +} +if (!(handlers.get("message_end")?.length > 0)) { + throw new Error("installed bridge did not register finalized marker removal"); +} +NODE + +echo "PASS: exact installed bridge artifact built and completed extension lifecycle" >&2 diff --git a/.oh/tasks/slack-compact/audit.md b/.oh/tasks/slack-compact/audit.md new file mode 100644 index 00000000..826f8046 --- /dev/null +++ b/.oh/tasks/slack-compact/audit.md @@ -0,0 +1,30 @@ +# Final implementation audit + +## Scope + +Final review findings against harness PR #740 and bridge PR #2: + +1. **MEDIUM — active remote destination survives local steering:** closed by recording the source of every active user turn. A correlated remote `message_start` activates that exact destination; any unmarked local/TUI steer or follow-up `message_start` switches delivery back to local without discarding the unresolved remote queue owner. Behavioral bridge tests prove both pre-settlement local steering and a local follow-up after a remote response never post to Slack, the remote queue settles at `agent_settled`, a later remote request still works, and identical cross-chat/thread FIFO remains unchanged. +2. **MEDIUM — signal cleanup skips a group after its Pi leader exits:** closed by treating the already-authenticated recorded PGID as cleanup authority while the group exists, rather than revalidating the departed leader. Signal/EXIT cleanup applies bounded TERM→KILL to that group and removes supervisor state, heartbeat, PID/PGID, socket/restart files, and lock. The behavioral supervisor test preempts cleanup after the leader is gone while a descendant ignores TERM; the descendant dies, an unrelated sibling survives, and all transient state is removed. + +## Compatibility review + +- Authorization still precedes compact recognition. +- Originating Slack chat/thread acknowledgement and reply routing remain intact. +- `agent_settled` still owns queue release and active-work compaction deferral. +- Compact provider errors, Slack post/disconnect failures, direct-next blocking, generation guards, and untrimmed control validation remain covered. +- The harness still uses isolated `--session-dir --continue`, exact process-group restart, Codex recovery, Hermes generic supervision, private runtime state, and no broad `pkill`. +- No native Slack `/compact` claim, local package patch, vendor copy, or duplicate compact extension was introduced. + +## Verification evidence + +- Bridge head `4056384d7e3901809019e006185a68987fcc8c0b`: `npm test` (122 tests), `npm run lint`, `npm run typecheck`, and `npm run build` — passed. +- Harness focused supervisor/gateway suites: 36 tests passed, including the exact installed artifact lifecycle and leader-gone signal preemption; the new signal test also passed three repeated focused runs. +- Harness full suite under a clean test environment: 39 files / 491 tests passed. +- Harness lint, format check, typecheck, build, Bash syntax, Dockerized ShellCheck across 36 boot scripts, JSON parse, template parity, exact installed-artifact smoke, and `git diff --check` — passed. +- Eval: 98 probes ran; 94 PASS, 4 environment-appropriate SKIPPED, 0 REGRESSION. Both Slack probes pass at the exact bridge pin. +- Harness CI runs `.oh/scripts/smoke-slack-bridge-artifact.sh`, so the consumer gate installs and exercises the exact reviewed bridge head rather than relying only on source-repository checks. + +## Verdict + +**AUDIT-PASS for implementation and local gates.** The two reported findings are closed by behavioral controls rather than secret-path or inherited-descriptor assumptions. GitHub promotion remains separately gated on both PR check surfaces; neither PR may be merged. diff --git a/.oh/tasks/slack-compact/critique.md b/.oh/tasks/slack-compact/critique.md new file mode 100644 index 00000000..32ff9356 --- /dev/null +++ b/.oh/tasks/slack-compact/critique.md @@ -0,0 +1,35 @@ +# Adversarial implementation critique + +## Independent verdict at head `4fb07864` + +**FAIL.** The first implementation transformed an authenticated-looking stamp +inside a separate local extension, waited for a later arbitrary assistant text +turn, emitted a forgeable environment-nonce log sentinel, broadly killed by +process-name pattern, and relaunched bare Pi. Passing tests proved those +mechanics rather than the required security/session semantics. + +## Mandatory findings and resolutions + +1. **Session continuity — high, fixed:** bare relaunch lost the compacted active path. The supervisor now owns a private persistent session directory and uses `--session-dir ... --continue` on every launch. Integration launch two reads launch one's compaction entry from the same active file. +2. **Correlation/delivery — high, fixed:** the bridge package now handles only an already-authorized exact request, posts directly to the originating chat/thread, treats successful Slack delivery as the commit point, waits for active work to settle, and serializes overlaps. Tests cover active work, simultaneous/direct-next traffic, provider failure/empty/tool independence, and Slack post failure. +3. **IPC — high, fixed after second redesign:** the inherited fd was still forgeable through `/proc/$PPID/fd/$FD`. It is removed. A mode-0600 Unix listener is ready before launch and authenticates `SO_PEERCRED` PID plus supervisor-direct-child SID/PGID identity. The socket path is not a secret. A real child discovers it from parent env, scans `/proc`, connects with the right protocol, and cannot trigger; the exact Pi PID succeeds. +4. **No stale window — high, fixed:** listener bind/chmod/listen readiness completes before Pi launch. Package completion closes the logical intake gate immediately, disconnects Slack, then signals. The supervisor settles the watcher before rc evaluation. Immediate completion and direct-next races are tested. +5. **Generation guard — high, fixed:** request id, session generation, and context identity guard late completion/error callbacks. Replacement tests prove old callbacks cannot disconnect or signal the new request. +6. **Exact target/cleanup — high, fixed:** no compact-path `pkill`; the authenticated peer PID identifies the exact isolated process group. Bounded TERM→KILL closes descendants even if the leader exits immediately after IPC acknowledgement. Completion+rc0 is synchronized. EXIT/INT/TERM/HUP cleanup unlinks socket/readiness/PID/restart state, lock, and heartbeat while leaving the persistent session. Sibling Pi/Hermes processes survive. +7. **Untrimmed controls — medium, fixed:** package validates raw optional instructions before trimming, rejects all C0/DEL/C1 classes, and counts Unicode code points. +8. **Local-turn destination leak — medium, fixed after final review:** `sendUserMessage(..., followUp)` no longer assigns Slack metadata at enqueue time. A cryptographically unpredictable request id is appended, metadata activates only on matching user `message_start`, and Pi's supported `message_end` replacement strips the marker before provider/session use. Behavioral tests prove a local assistant/tool response stays local and two chats/threads with identical text each receive exactly their own response. + +## Compatibility review + +- Thread replies and originating thread anchors remain package-owned; FIFO serialization now binds them at the actual user-message boundary. +- Existing admin slash handlers and challenge trust checks run before compact recognition. +- Codex recovery remains the sole harness-local co-extension. +- Hermes stays on the generic backend path without Pi session/IPC logic. +- Tokens remain in the source-delete mode-600 runtime environment file. +- No Slack manifest or documentation claims a native `/compact` command. +- Harness remains exact-package-pin based with no `node_modules` patch or vendored package source. + +## Gate + +**APPROVED after redesign.** No unmitigated high finding remains; final promotion +still requires green fork+harness CI and independent deterministic PR audit. diff --git a/.oh/tasks/slack-compact/prd.json b/.oh/tasks/slack-compact/prd.json new file mode 100644 index 00000000..8e542103 --- /dev/null +++ b/.oh/tasks/slack-compact/prd.json @@ -0,0 +1,61 @@ +{ + "project": "slack-compact", + "issue": 739, + "source": "mifunedev/openharness#739", + "branch": "feat/739-slack-compact", + "description": "Authenticated package-owned Slack compaction with continued session and private exact-PID supervision", + "userStories": [ + { + "id": "US-001", + "title": "Package-owned authenticated compact control", + "description": "Extend the exact-pinned bridge fork where authorization, immutable message metadata, direct transport send, and disconnect are available.", + "acceptanceCriteria": [ + "Exact originating chat/thread acknowledgement succeeds before compaction", + "Active prior work settles before compaction and no arbitrary turn_end is used", + "Overlaps serialize; local turn_end before remote message_start stays local; identical cross-chat content correlates independently", + "Unpredictable internal request ids are removed by finalized message replacement before provider/session use", + "Untrimmed instruction controls and generation-late callbacks are tested" + ], + "priority": 1, + "passes": true + }, + { + "id": "US-002", + "title": "Private one-shot exact-peer supervisor", + "description": "Replace nonce/log/fd signaling with listener-ready-before-launch mode-0600 Unix IPC, Linux peer-credential authentication, and exact target cleanup.", + "acceptanceCriteria": [ + "A real child can discover parent env/path and attempt /proc/socket forgery but cannot trigger", + "The exact Pi PID succeeds through SO_PEERCRED and Slack disconnect precedes the one-shot signal", + "Immediate completion plus rc0 restarts exactly the authenticated supervised process group", + "EXIT, INT, TERM, and HUP cleanup unlinks transient socket/state, lock, and heartbeat while siblings survive" + ], + "priority": 2, + "passes": true + }, + { + "id": "US-003", + "title": "Continue the compacted active session", + "description": "Use an isolated private session directory and explicit continuation on every launch.", + "acceptanceCriteria": [ + "Launch command contains --session-dir and --continue", + "Launch two reopens launch one's compaction entry", + "Persistent session data survives supervisor cleanup" + ], + "priority": 3, + "passes": true + }, + { + "id": "US-004", + "title": "Preserve integration compatibility and evidence", + "description": "Keep thread/admin/trust/Codex/Hermes/token paths intact and update all provenance, docs, eval, and test surfaces.", + "acceptanceCriteria": [ + "Harness pins the exact tested fork commit and has no package patch/vendor/local duplicate", + "UPSTREAM root/template are byte-identical and changelog/doc/task artifacts describe the final architecture", + "Focused/full package and harness gates plus CI and deterministic audit pass", + "Neither PR is merged" + ], + "priority": 4, + "passes": true + } + ] +} diff --git a/.oh/tasks/slack-compact/prd.md b/.oh/tasks/slack-compact/prd.md new file mode 100644 index 00000000..16fcd6d7 --- /dev/null +++ b/.oh/tasks/slack-compact/prd.md @@ -0,0 +1,53 @@ +# PRD: authenticated Slack-requested Pi session compaction + +Issue: [#739](https://github.com/mifunedev/openharness/issues/739) +Bridge dependency: [ryaneggz/pi-messenger-bridge#2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2) + +## Goal + +Let an already-authorized Slack user compact the active dedicated +`client-slack-pi` session without losing delivery correlation, allowing forged +restart signals, exposing a replaced context to later Slack messages, or +relaunching into a new bare session. + +## Architecture + +The exact-pinned bridge package owns behavior available only where authenticated +message metadata and transport delivery/disconnect are available: strict compact +parsing, originating chat/thread acknowledgement, active-turn settling, +deterministic inbound serialization, `ctx.compact`, generation guards, Slack +intake shutdown, and one-shot completion notification. + +The harness owns process/session continuity only: an isolated mode-700 session +directory launched with `--continue`, a listener-ready-before-launch mode-0600 +Unix socket that authenticates the exact Pi PID with Linux peer credentials, +exact process-group restart, and complete listener/heartbeat/lock cleanup. The +socket path is rendezvous metadata, not an environment secret. No local duplicate +compact extension, `node_modules` patch, vendor copy, log sentinel, inherited fd, +or broad `pkill` is permitted. + +## Requirements + +1. Pin `github:ryaneggz/pi-messenger-bridge` to the exact tested commit from its focused unmerged PR; preserve thread replies, Slack admin commands, challenge trust, and all sibling transports. +2. Authorize before compact recognition. Bind one immutable request to its exact Slack chat, thread, message, session generation, and request generation. +3. Post the acknowledgement directly through Slack to the originating chat/thread. Failed delivery must not compact, disconnect, or notify the supervisor. +4. Wait for an active prior turn to settle; never compact because of the next arbitrary `turn_end`. Provider errors, empty output, and tool turns cannot steal the request. +5. Serialize overlapping authenticated inbound callbacks. Do not assign a remote destination until the exact queued user message reaches `message_start`; correlate with a per-request unpredictable internal id and remove it through finalized `message_end` replacement before provider/session use. Once a compact request is committed, block direct-next messages from entering the context being replaced. +6. Validate the untrimmed optional custom-instruction capture. Reject C0, DEL, and C1 controls; bound to 500 Unicode code points. +7. Generation-guard completion/error callbacks after session or request replacement. +8. On successful compaction, synchronously close the logical intake gate, disconnect Slack, then connect from Pi itself to a supervisor-created private Unix socket and write the one-byte protocol. +9. Prepare and synchronize the mode-0600 listener before launch. Authenticate the exact supervised Pi PID with `SO_PEERCRED` plus direct-child SID/PGID identity; reject tool children even when they discover the parent environment, path, and protocol. Settle the listener after Pi exits and before rc evaluation, including immediate completion and completion+rc0 races, and unlink the path on every exit. +10. Signal only the authenticated exact supervised Pi process group with bounded TERM→KILL, including descendants after an immediate leader exit; never broad-name-kill the compact path. Preserve sibling Pi processes. +11. Store gateway sessions under an isolated mode-700 directory and launch every generation with explicit `--continue`, proving launch two reopens launch one's compacted active path. +12. Clean up exact Pi/listener/ticker processes, socket/ready/PID/restart files, bridge lock, and heartbeat on normal exit, INT, TERM, and HUP. Preserve the persistent session directory. +13. Preserve Codex stale-response recovery, stale-ctx fallback recovery, Hermes generic supervision, tokens-as-data, interactive TTY behavior, status/heartbeat, and thread replies. +14. Update package/harness tests, task artifacts, Slack docs, UPSTREAM provenance/template parity, changelog, and Tier-A eval probes. Do not claim a native Slack `/compact` command. + +## Acceptance criteria + +- Bridge tests cover active prior turn, simultaneous/overlapping messages, exact chat/thread ack, provider error, empty/tool independence, Slack post failure, untrimmed controls, direct-next race, disconnect-before-signal, and late generation callbacks. +- Supervisor integration proves listener readiness/mode, immediate completion+rc0, a real child discovering parent env and attempting `/proc`/socket forgery without triggering, exact Pi peer success, exact sibling survival, launch-two continuation of the compacted path, and cleanup contracts. +- Bridge integration proves Slack arriving during a local assistant/tool turn leaves the local response local, then delivers the remote response exactly once; two chats/threads with identical content remain independently correlated and internal markers are absent from finalized user messages. +- Harness consumes only an exact commit pin and contains no package patch/vendor or local compact implementation. +- Focused and full package/harness test, eval, lint, format, typecheck, build, shellcheck, and template/provider parity gates pass. +- Both PRs remain unmerged and all required CI is green. diff --git a/.oh/tasks/slack-compact/prompt.md b/.oh/tasks/slack-compact/prompt.md new file mode 100644 index 00000000..173ad023 --- /dev/null +++ b/.oh/tasks/slack-compact/prompt.md @@ -0,0 +1,10 @@ +# Execution prompt + +Fix independent FAIL at harness head `4fb07864`: move authenticated compact +correlation/delivery/disconnect into an exact-pinned focused bridge-fork PR; +replace nonce/log/fd signaling with listener-ready mode-0600 Unix IPC authenticated +to the exact Pi peer PID and exact process-group cleanup; bind remote destinations +only at matching user message_start and strip internal ids at message_end; relaunch through an isolated persistent session +directory with explicit continuation; close every mandatory finding in +`prd.md`; preserve thread/admin/trust/Codex/Hermes/token behavior; run exhaustive +fork+harness verification and audits; push both PRs without merging. diff --git a/.oh/templates/full/.pi/UPSTREAM.md b/.oh/templates/full/.pi/UPSTREAM.md index e23efcde..ffe5e07f 100644 --- a/.oh/templates/full/.pi/UPSTREAM.md +++ b/.oh/templates/full/.pi/UPSTREAM.md @@ -7,7 +7,7 @@ | **Capability** | Messenger bridge (Telegram / WhatsApp / Slack / Discord / Matrix) | | **Package** | `pi-messenger-bridge` ([tintinweb/pi-messenger-bridge](https://github.com/tintinweb/pi-messenger-bridge)) | | **License** | MIT | -| **Install / load** | `npm install "github:ryaneggz/pi-messenger-bridge#c8b96e9d0fb69611c4e67ae298d1d10d83792a26"` into gitignored `.pi/bridge/` (TEMPORARY exact-commit fork pin carrying the unreleased Slack thread-reply patch plus Slack admin slash-command handlers; the fork's `prepare` script builds `dist/` on install); loaded via `--extension` only in the `client-slack` tmux session (`.devcontainer/entrypoint.sh`), interactive on the pane TTY (no `--mode rpc`), under the self-healing supervisor `.devcontainer/client-slack-supervise.sh` (restart-on-stale-ctx); a sibling in-tree `.pi/bridge-recovery/` extension is co-loaded for Codex retry-recovery | +| **Install / load** | `npm install "git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b"` into gitignored `.pi/bridge/` (TEMPORARY exact-commit fork pin from [ryaneggz/pi-messenger-bridge#2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2), carrying thread replies, admin handlers, and authenticated supervised compact control; `prepare` builds `dist/`); loaded via `--extension` only in `client-slack-pi`, with local `.pi/bridge-recovery/` second for Codex retry, under `.devcontainer/client-slack-supervise.sh` using an isolated continued session and private one-shot IPC | | **Vendored** | No — npm package dependency, not a port | ## Relationship Model @@ -16,10 +16,10 @@ The Slack (and other transport) capability is now provided by the community npm package **pi-messenger-bridge**. This is a **package dependency**, not a vendored or hand-ported in-tree extension: -- The package is installed via npm into a gitignored `.pi/bridge/` directory and loaded via `--extension` only in the dedicated `client-slack` tmux session (`.devcontainer/entrypoint.sh`), wrapped by the self-healing supervisor `.devcontainer/client-slack-supervise.sh` that restarts pi on the stale-ctx error and on crashes. pi runs interactive on the pane TTY (no `--mode rpc`), and a sibling in-tree `.pi/bridge-recovery/` extension — NOT part of the npm package — is co-loaded as a second `--extension` for Codex `previous_response_not_found` retry-recovery. It is **not** pinned in `.pi/settings.json` `packages[]`, so other Pi sessions do not load it — only the `client-slack` bridge session holds the Slack Socket-Mode connection. -- The pin in `.oh/scripts/gateway.sh`'s `FORK_PIN` **is** the review/bump artifact. It currently points at exact merged commit `github:ryaneggz/pi-messenger-bridge#c8b96e9d0fb69611c4e67ae298d1d10d83792a26` (Slack thread-reply patch plus admin slash-command handlers from [ryaneggz/pi-messenger-bridge#1](https://github.com/ryaneggz/pi-messenger-bridge/pull/1)); once upstream publishes a release, re-pin it to `pi-messenger-bridge@`. -- Source lives upstream at `tintinweb/pi-messenger-bridge`; the harness consumes it as published, never edits it locally. -- Track the package by version pin only. The one current exception is the **temporary fork pin** above, authorized to ship the Slack thread-reply fix and admin slash-command handlers ahead of an upstream release; it reverts to a published `pi-messenger-bridge@` as soon as the upstream PR lands. Do **not** vendor the package source into the tree. +- The package is installed via npm into a gitignored `.pi/bridge/` directory and loaded only in the dedicated `client-slack-pi` tmux session. It owns authorization, settled-turn cross-chat/thread correlation, exact compact-request correlation, originating-thread acknowledgement, overlap serialization, current-event-context `ctx.compact`, generation guards, confirmed Slack disconnect with retry, late-bound remote-turn correlation, finalized marker removal, and one-shot completion notification. The harness neither patches `node_modules` nor carries a duplicate compact extension. +- `.devcontainer/client-slack-supervise.sh` owns process/session continuity: mode-700 isolated session storage with explicit `--continue`, listener-before-launch mode-0600 Unix socket IPC authenticated to the exact Pi PID with Linux peer credentials, an isolated exact process group with bounded TERM-to-KILL restart, and lifecycle cleanup. `.pi/bridge-recovery/` remains the sole local co-extension for Codex `previous_response_not_found` retry. +- The pin in `.oh/scripts/gateway.sh`'s `FORK_PIN` **is** the review/bump artifact. It points at exact tested commit `git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b` from [ryaneggz/pi-messenger-bridge#2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2); once upstream publishes a release containing these changes, re-pin to `pi-messenger-bridge@`. +- Track the package by exact version/commit pin only. Do **not** vendor package source or patch `node_modules`. This model keeps the integration thin: upstream maintains the multi-transport bridge, the harness just pins which release it runs. @@ -38,9 +38,9 @@ table and customization log retired with it. **Owner**: `@ryaneggz` **Schedule**: Quarterly (check for a newer `pi-messenger-bridge` release) -**Last reviewed**: 2026-06-21 +**Last reviewed**: 2026-08-11 On each review: 1. Check whether `tintinweb/pi-messenger-bridge` has published a newer release. -2. If so (or once the thread-reply PR is released), re-pin `.devcontainer/entrypoint.sh`'s `npm install` line from the fork branch to `pi-messenger-bridge@` and validate. -3. Verify the Slack transport still loads and bridges turns after the bump. +2. If so (or once fork PR #2 is released upstream), update `.oh/scripts/gateway.sh`'s `FORK_PIN` from the exact fork commit to `pi-messenger-bridge@` and validate. +3. Verify Slack thread/admin behavior plus acknowledgement-first compact, private IPC restart, and continued-session recovery after the bump. diff --git a/.pi/UPSTREAM.md b/.pi/UPSTREAM.md index e23efcde..ffe5e07f 100644 --- a/.pi/UPSTREAM.md +++ b/.pi/UPSTREAM.md @@ -7,7 +7,7 @@ | **Capability** | Messenger bridge (Telegram / WhatsApp / Slack / Discord / Matrix) | | **Package** | `pi-messenger-bridge` ([tintinweb/pi-messenger-bridge](https://github.com/tintinweb/pi-messenger-bridge)) | | **License** | MIT | -| **Install / load** | `npm install "github:ryaneggz/pi-messenger-bridge#c8b96e9d0fb69611c4e67ae298d1d10d83792a26"` into gitignored `.pi/bridge/` (TEMPORARY exact-commit fork pin carrying the unreleased Slack thread-reply patch plus Slack admin slash-command handlers; the fork's `prepare` script builds `dist/` on install); loaded via `--extension` only in the `client-slack` tmux session (`.devcontainer/entrypoint.sh`), interactive on the pane TTY (no `--mode rpc`), under the self-healing supervisor `.devcontainer/client-slack-supervise.sh` (restart-on-stale-ctx); a sibling in-tree `.pi/bridge-recovery/` extension is co-loaded for Codex retry-recovery | +| **Install / load** | `npm install "git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b"` into gitignored `.pi/bridge/` (TEMPORARY exact-commit fork pin from [ryaneggz/pi-messenger-bridge#2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2), carrying thread replies, admin handlers, and authenticated supervised compact control; `prepare` builds `dist/`); loaded via `--extension` only in `client-slack-pi`, with local `.pi/bridge-recovery/` second for Codex retry, under `.devcontainer/client-slack-supervise.sh` using an isolated continued session and private one-shot IPC | | **Vendored** | No — npm package dependency, not a port | ## Relationship Model @@ -16,10 +16,10 @@ The Slack (and other transport) capability is now provided by the community npm package **pi-messenger-bridge**. This is a **package dependency**, not a vendored or hand-ported in-tree extension: -- The package is installed via npm into a gitignored `.pi/bridge/` directory and loaded via `--extension` only in the dedicated `client-slack` tmux session (`.devcontainer/entrypoint.sh`), wrapped by the self-healing supervisor `.devcontainer/client-slack-supervise.sh` that restarts pi on the stale-ctx error and on crashes. pi runs interactive on the pane TTY (no `--mode rpc`), and a sibling in-tree `.pi/bridge-recovery/` extension — NOT part of the npm package — is co-loaded as a second `--extension` for Codex `previous_response_not_found` retry-recovery. It is **not** pinned in `.pi/settings.json` `packages[]`, so other Pi sessions do not load it — only the `client-slack` bridge session holds the Slack Socket-Mode connection. -- The pin in `.oh/scripts/gateway.sh`'s `FORK_PIN` **is** the review/bump artifact. It currently points at exact merged commit `github:ryaneggz/pi-messenger-bridge#c8b96e9d0fb69611c4e67ae298d1d10d83792a26` (Slack thread-reply patch plus admin slash-command handlers from [ryaneggz/pi-messenger-bridge#1](https://github.com/ryaneggz/pi-messenger-bridge/pull/1)); once upstream publishes a release, re-pin it to `pi-messenger-bridge@`. -- Source lives upstream at `tintinweb/pi-messenger-bridge`; the harness consumes it as published, never edits it locally. -- Track the package by version pin only. The one current exception is the **temporary fork pin** above, authorized to ship the Slack thread-reply fix and admin slash-command handlers ahead of an upstream release; it reverts to a published `pi-messenger-bridge@` as soon as the upstream PR lands. Do **not** vendor the package source into the tree. +- The package is installed via npm into a gitignored `.pi/bridge/` directory and loaded only in the dedicated `client-slack-pi` tmux session. It owns authorization, settled-turn cross-chat/thread correlation, exact compact-request correlation, originating-thread acknowledgement, overlap serialization, current-event-context `ctx.compact`, generation guards, confirmed Slack disconnect with retry, late-bound remote-turn correlation, finalized marker removal, and one-shot completion notification. The harness neither patches `node_modules` nor carries a duplicate compact extension. +- `.devcontainer/client-slack-supervise.sh` owns process/session continuity: mode-700 isolated session storage with explicit `--continue`, listener-before-launch mode-0600 Unix socket IPC authenticated to the exact Pi PID with Linux peer credentials, an isolated exact process group with bounded TERM-to-KILL restart, and lifecycle cleanup. `.pi/bridge-recovery/` remains the sole local co-extension for Codex `previous_response_not_found` retry. +- The pin in `.oh/scripts/gateway.sh`'s `FORK_PIN` **is** the review/bump artifact. It points at exact tested commit `git+https://github.com/ryaneggz/pi-messenger-bridge.git#4056384d7e3901809019e006185a68987fcc8c0b` from [ryaneggz/pi-messenger-bridge#2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2); once upstream publishes a release containing these changes, re-pin to `pi-messenger-bridge@`. +- Track the package by exact version/commit pin only. Do **not** vendor package source or patch `node_modules`. This model keeps the integration thin: upstream maintains the multi-transport bridge, the harness just pins which release it runs. @@ -38,9 +38,9 @@ table and customization log retired with it. **Owner**: `@ryaneggz` **Schedule**: Quarterly (check for a newer `pi-messenger-bridge` release) -**Last reviewed**: 2026-06-21 +**Last reviewed**: 2026-08-11 On each review: 1. Check whether `tintinweb/pi-messenger-bridge` has published a newer release. -2. If so (or once the thread-reply PR is released), re-pin `.devcontainer/entrypoint.sh`'s `npm install` line from the fork branch to `pi-messenger-bridge@` and validate. -3. Verify the Slack transport still loads and bridges turns after the bump. +2. If so (or once fork PR #2 is released upstream), update `.oh/scripts/gateway.sh`'s `FORK_PIN` from the exact fork commit to `pi-messenger-bridge@` and validate. +3. Verify Slack thread/admin behavior plus acknowledgement-first compact, private IPC restart, and continued-session recovery after the bump. diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c7c257..ad01f994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Update policy and release automation live in [`/git`](.claude/skills/git/SKILL.m - Block agent reads and writes to operator-owned `settings.local.json` files across Claude, Codex, and shared Open Harness hooks ([#710](https://github.com/mifunedev/openharness/issues/710)). ### Added +- Let an already-authorized Slack user compact the current dedicated Pi gateway session through the exact-pinned bridge package, with originating-thread acknowledgement, actual-turn-boundary destination correlation, exact-peer-credential Unix IPC, exact process-group restart, and explicit continuation of the compacted active session path ([#739](https://github.com/mifunedev/openharness/issues/739), [bridge #2](https://github.com/ryaneggz/pi-messenger-bridge/pull/2)). - Record the audit's proof for the reviewer as a committed evidence doc during the `.oh/prompts/advisor/pr.yml` flow. A new contract (`.oh/skills/audit/references/reviewer-evidence-doc.md`) defines `.oh/tasks//evidence.md` — per-gate proof table, acceptance-criteria→proof mapping, and honesty rules requiring observed commands and real output (a gate with no observed output is recorded as a gap, never a pass), correlated to `AUDIT_RUN_ID` and the verbatim native verdict. The audit routes stay read-only and do not write it: the orchestrating caller does, from the observations they return. Distinct from the schema-v1 `evidence.json` lifecycle contract at `AUDIT_EVIDENCE_PATH` ([#719](https://github.com/mifunedev/openharness/issues/719)). - Deny agents both read and write access to the operator-only `.config/` directory — at the repo root and in `$HOME` — as a first-class tier in both `PreToolUse` guards, replacing the two hand-picked leaves (`.config/gcloud/**`, `.config/gh/hosts.yml`) that were all the deny-list previously covered. The Bash tier is deliberately **verb-agnostic**: any command naming the directory is denied, not just the enumerated `READ_CMD` readers, because a verb allowlist leaks through `python`/`node`/`perl`/`tar` and every tool added later (`mkdir`, `tar`, `python3`, and a `docker exec` subshell are all covered by the probe). Both tiers anchor on a whole path **segment**, so `jest.config.js`, `vitest.config.ts`, `--config foo`, `git config`, and `.oh/config.json` are unaffected, and the pre-existing secret family — including the `.env.example` template exemption — is unchanged. Closes a related read hole in the same pass: `deny-secret-paths.sh` was wired for `Read|Write|Edit|NotebookEdit` and inspected only `tool_input.file_path`, so `Grep`/`Glob` could walk into a directory `Read` was blocked from; it now runs for those tools too and scans every path-shaped field (`file_path`, `notebook_path`, `path`, `glob`) while deliberately leaving Grep's content `pattern` alone. Pinned by `operator-config-guard.sh`, which asserts the behaviour *and* the wiring and was verified to fail when the guard is neutered. Provider coverage stays asymmetric and is documented as such: claude is fully covered, codex inherits the Bash tier only, and the pi extension addition is write/edit and interactive-mode only ([#707](https://github.com/mifunedev/openharness/issues/707)). - Add `lsof`, `htop`, and the `inetutils-telnet` plaintext diagnostic client to the default sandbox image ([#703](https://github.com/mifunedev/openharness/issues/703)). @@ -25,6 +26,7 @@ Update policy and release automation live in [`/git`](.claude/skills/git/SKILL.m - Expose the supported GPT-5.6 variants in Pi's model selector ([#684](https://github.com/mifunedev/openharness/issues/684)). - Expand Advisor planning with a designer lens and make implementation/PR prompts explicitly finish with delegated audits and retrospectives ([#680](https://github.com/mifunedev/openharness/issues/680)). ### Fixed +- Close the authenticated Slack gateway process group during signal/EXIT cleanup even when its Pi leader has already exited, escalating bounded TERM to KILL for stubborn descendants while preserving unrelated sessions and removing runtime state ([#739](https://github.com/mifunedev/openharness/issues/739)). - Install the pinned `ryaneggz/pi-langfuse` commit carrying the upstream shutdown fix while [gooyoung/pi-langfuse#14](https://github.com/gooyoung/pi-langfuse/pull/14) is reviewed; preserve the user-scoped OpenTelemetry override and npm audit gate, and register the exact Git source with Pi ([#715](https://github.com/mifunedev/openharness/issues/715)). - Stop the cron reaper from reading a `git status` **failure** as evidence of uncommitted work. `inspectFallbackWorktree` returned `dirty: true` whenever `git status --porcelain` exited non-zero, so a worktree directory whose `.git/worktrees/` admin entry had vanished was preserved as "needs manual salvage" on every single fire — 15 consecutive days for `cron-prompt-miner-0718-0500`, which also eroded `WORKTREE_DIRTY`'s value as a triage signal by burying any real one under identical noise. Adds a distinct `WORKTREE_ORPHANED` outcome that removes the directory (`git worktree remove` fails once the admin entry is gone, and `git worktree prune` handles only the inverse case). The orphan is identified structurally — a `.git` file whose `gitdir:` target no longer exists — rather than by matching `git status` stderr, which is locale-dependent; every other status failure still preserves the worktree, since it cannot be shown that there is nothing to salvage ([#694](https://github.com/mifunedev/openharness/issues/694)). - Resolve the `prompt-miner` daily-log write root to the **main** worktree instead of the ephemeral one. `render-log-entry.sh` used `git rev-parse --show-toplevel`, which under the cron's `worktree: true` returns the linked worktree — so every Step 5 log entry was written into `.oh/worktrees/cron//` and destroyed when the runtime reaped it (fired 07-10, 07-14, 07-19, hand-recovered each time). Adopts the `AUTOPILOT_LOG_ROOT` → `CRON_WORKTREE` → toplevel resolution already proven at `.oh/crons/prompt-miner.md:102` and documented at `.oh/crons/README.md:120`, preserving precedence for callers that do export the variable. Guarded by `prompt-miner-log-root-worktree.sh`, which builds a real linked worktree and asserts the entry lands in the main one — a fixture test cannot catch this class of bug ([#693](https://github.com/mifunedev/openharness/issues/693)).