Hook processes are fire-and-forget: the binary's exit drain acknowledges spawns only, so the STARTED writer can land after the terminal one. The macro-bracketing tests waited only for the terminal marker and then asserted STARTED's count immediately, flaking on Windows CI and loaded Linux runners. The bounded wait now covers every expected marker before the exact-count and absence assertions run; the probe script's scenario K comment is corrected to state the spawn-ack-only contract it already waits around.
767 lines
35 KiB
Bash
Executable File
767 lines
35 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Black-box usage-pattern probe for the coyote hook system.
|
|
#
|
|
# Verifies, against a REAL built `coyote` binary in a clean/isolated
|
|
# COYOTE_CONFIG_DIR (dry_run:true, no network/LLM credentials required):
|
|
# A) exactly one turn.started + one turn.completed + one llm.request.completed
|
|
# fire per headless run, with correctly scoped env vars (no cross-event
|
|
# extras leakage).
|
|
# B) a failing/missing-script hook never changes coyote's stdout, stderr, or
|
|
# exit code.
|
|
# C) `coyote --install-builtins hooks` installs both bundled example scripts
|
|
# 0755 into <config_dir>/hooks via the real CLI; a non-interactive re-run
|
|
# keeps locally modified scripts while still installing missing ones.
|
|
# D) `coyote --agent <bundled-agent> --info` (an inspection flag) fires ZERO
|
|
# agent.* hook events.
|
|
# E) `coyote --install-builtins hooks` reconciles manifest-owned leftovers
|
|
# in roles/hooks and each bundled agent's hooks dir, sparing user files.
|
|
# F) top-level hooks-dir manifest reconcile: a stale shipped hook is removed
|
|
# on reinstall; a malformed manifest deletes NOTHING (fail-safe).
|
|
# G) a plain startup sweeps manifest-owned hooks of removed builtin agents
|
|
# while leaving user agent dirs and user files untouched.
|
|
# H) the INSTALLED bundled scripts behave as documented when run directly:
|
|
# log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/
|
|
# (0600, UTC ISO-8601 header, secrets filtered, symlink at the default
|
|
# path refused) and notify.sh's no-notifier/no-tty fallback strips
|
|
# ANSI/OSC control bytes; the hooks-dir .builtin-manifest is never
|
|
# installed executable.
|
|
# I) `--session <name>` discriminates the session events end-to-end: a
|
|
# fresh named session fires exactly one session.started (and no
|
|
# session.resumed); resuming a persisted session fires exactly one
|
|
# session.resumed (and no session.started).
|
|
# J) pressing ctrl-c in a real terminal during a live headless --agent run
|
|
# fires exactly one agent.interrupted with NO COYOTE_AGENT_ERROR, and
|
|
# never agent.failed or agent.completed (needs python3 for the pty and
|
|
# the hanging LLM endpoint; skipped when python3 is unavailable).
|
|
#
|
|
# Not covered here (see src/function/mod.rs tests for tool.* + payload-file
|
|
# coverage instead): a live tool.started firing requires a real LLM round
|
|
# trip that actually decides to call a tool; dry_run's echo_messages() path
|
|
# never invokes tools, so it cannot be exercised via this recipe.
|
|
#
|
|
# Usage: scripts/usage-probe-hooks.sh
|
|
# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or
|
|
# ./target/release/coyote (build with `cargo build --bin coyote` first).
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
BIN="${COYOTE_BIN:-}"
|
|
if [ -z "$BIN" ]; then
|
|
if [ -x "$REPO_ROOT/target/debug/coyote" ]; then
|
|
BIN="$REPO_ROOT/target/debug/coyote"
|
|
elif [ -x "$REPO_ROOT/target/release/coyote" ]; then
|
|
BIN="$REPO_ROOT/target/release/coyote"
|
|
else
|
|
echo "No coyote binary found; run 'cargo build --bin coyote' first." >&2
|
|
exit 2
|
|
fi
|
|
fi
|
|
|
|
WORKDIR="$(mktemp -d)"
|
|
trap 'rm -rf "$WORKDIR"' EXIT
|
|
|
|
fail() {
|
|
echo "FAIL: $1" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Bounded poll: retries the given command every 0.1s until it succeeds or the
|
|
# ~10s deadline expires, then fails loudly. Hooks are fire-and-forget child
|
|
# processes, so every wait on their side effects goes through this helper —
|
|
# never a bare sleep.
|
|
wait_for() {
|
|
local what="$1"
|
|
shift
|
|
local tries=0
|
|
until "$@"; do
|
|
tries=$((tries + 1))
|
|
[ "$tries" -ge 100 ] && fail "timed out waiting for $what"
|
|
sleep 0.1
|
|
done
|
|
}
|
|
|
|
write_dryrun_config() {
|
|
# $1 = config dir, $2..$n = extra YAML fragment lines appended verbatim
|
|
local dir="$1"
|
|
shift
|
|
cat > "$dir/config.yaml" <<'BASE'
|
|
model: dryrun:dry-model
|
|
dry_run: true
|
|
clients:
|
|
- type: openai
|
|
name: dryrun
|
|
auth: none
|
|
api_key: 'unused'
|
|
models:
|
|
- name: dry-model
|
|
max_input_tokens: 100000
|
|
supports_function_calling: true
|
|
save: false
|
|
memory: false
|
|
stream: false
|
|
BASE
|
|
if [ "$#" -gt 0 ]; then
|
|
printf '%s\n' "$@" >> "$dir/config.yaml"
|
|
fi
|
|
}
|
|
|
|
echo "== Scenario A: turn/llm hook cadence and env scoping =="
|
|
CFG_A="$WORKDIR/a"
|
|
mkdir -p "$CFG_A"
|
|
TS_LOG="$WORKDIR/turn-started.log"
|
|
TC_LOG="$WORKDIR/turn-completed.log"
|
|
LC_LOG="$WORKDIR/llm-completed.log"
|
|
write_dryrun_config "$CFG_A" "hooks:" \
|
|
" turn.started:" \
|
|
" - name: probe" \
|
|
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TS_LOG\"" \
|
|
" turn.completed:" \
|
|
" - name: probe" \
|
|
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TC_LOG\"" \
|
|
" llm.request.completed:" \
|
|
" - name: probe-llm" \
|
|
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT PROVIDER=\$COYOTE_LLM_PROVIDER MODEL=\$COYOTE_LLM_MODEL\\\"; } >> $LC_LOG\""
|
|
|
|
COYOTE_CONFIG_DIR="$CFG_A" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
scenario_a_logs_ready() { [ -s "$TS_LOG" ] && [ -s "$TC_LOG" ] && [ -s "$LC_LOG" ]; }
|
|
wait_for "turn/llm hook logs" scenario_a_logs_ready
|
|
|
|
[ "$(grep -c '^EVENT=turn.started' "$TS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one turn.started, got: $(cat "$TS_LOG" 2>/dev/null)"
|
|
[ "$(grep -c '^EVENT=turn.completed' "$TC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one turn.completed, got: $(cat "$TC_LOG" 2>/dev/null)"
|
|
[ "$(grep -c '^EVENT=llm.request.completed' "$LC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one llm.request.completed, got: $(cat "$LC_LOG" 2>/dev/null)"
|
|
grep -q 'PROVIDER=dryrun MODEL=dryrun:dry-model' "$LC_LOG" \
|
|
|| fail "llm.request.completed missing expected COYOTE_LLM_PROVIDER/MODEL: $(cat "$LC_LOG")"
|
|
echo "PASS: exactly 1 turn.started + 1 turn.completed + 1 llm.request.completed, correctly scoped"
|
|
|
|
echo "== Scenario B: failing/missing-script hooks never affect output or exit code =="
|
|
CFG_B="$WORKDIR/b"
|
|
mkdir -p "$CFG_B"
|
|
write_dryrun_config "$CFG_B" "hooks:" \
|
|
" turn.completed:" \
|
|
" - name: failing" \
|
|
" command: /nonexistent/script/does/not/exist" \
|
|
" - name: exit1" \
|
|
" command: \"exit 1\""
|
|
CFG_BASE="$WORKDIR/base"
|
|
mkdir -p "$CFG_BASE"
|
|
write_dryrun_config "$CFG_BASE"
|
|
|
|
rc_b=0
|
|
COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || rc_b=$?
|
|
rc_base=0
|
|
COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || rc_base=$?
|
|
|
|
[ "$rc_b" = "$rc_base" ] || fail "exit code differs: with-hooks=$rc_b baseline=$rc_base"
|
|
diff -q "$WORKDIR/out_b.txt" "$WORKDIR/out_base.txt" > /dev/null || fail "stdout differs when a hook fails/is missing"
|
|
diff -q "$WORKDIR/err_b.txt" "$WORKDIR/err_base.txt" > /dev/null || fail "stderr differs when a hook fails/is missing"
|
|
echo "PASS: failing/missing-script hooks are invisible to stdout/stderr/exit code"
|
|
|
|
echo "== Scenario C: coyote --install-builtins hooks (real CLI, clean config dir) =="
|
|
CFG_C="$WORKDIR/c"
|
|
mkdir -p "$CFG_C"
|
|
|
|
out_install1="$WORKDIR/install1.out"
|
|
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$out_install1" 2>&1
|
|
|
|
[ -f "$CFG_C/hooks/notify.sh" ] || fail "notify.sh was not installed: $(cat "$out_install1")"
|
|
[ -f "$CFG_C/hooks/log-events.sh" ] || fail "log-events.sh was not installed: $(cat "$out_install1")"
|
|
grep -q "Reinstalled bundled hooks" "$out_install1" \
|
|
|| fail "expected confirmation message, got: $(cat "$out_install1")"
|
|
|
|
if command -v stat > /dev/null 2>&1; then
|
|
mode_notify="$(stat -c '%a' "$CFG_C/hooks/notify.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/notify.sh")"
|
|
mode_log="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
|
|
[ "$mode_notify" = "755" ] || fail "notify.sh mode is $mode_notify, expected 755"
|
|
[ "$mode_log" = "755" ] || fail "log-events.sh mode is $mode_log, expected 755"
|
|
fi
|
|
|
|
# Modify one script locally and delete the other, then re-run
|
|
# --install-builtins hooks: without a terminal the conflict resolution keeps
|
|
# local files, so the edit must survive while the missing script is
|
|
# reinstalled from the packaged content.
|
|
echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh"
|
|
rm "$CFG_C/hooks/log-events.sh"
|
|
# The refresh also touches the roles and per-agent hook locations: user files
|
|
# living there must survive it untouched.
|
|
AGENT_C="$(ls "$CFG_C/agents" | head -1)"
|
|
mkdir -p "$CFG_C/roles/hooks" "$CFG_C/agents/$AGENT_C/hooks"
|
|
echo "mine-role" > "$CFG_C/roles/hooks/user-role.sh"
|
|
echo "mine-agent" > "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh"
|
|
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$WORKDIR/install2.out" 2>&1
|
|
grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.sh" \
|
|
|| fail "non-interactive reinstall overwrote the locally modified notify.sh"
|
|
[ -f "$CFG_C/hooks/log-events.sh" ] \
|
|
|| fail "reinstall did not restore the deleted log-events.sh"
|
|
grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/log-events.sh" \
|
|
|| fail "log-events.sh was not restored to the packaged script"
|
|
if command -v stat > /dev/null 2>&1; then
|
|
mode_restored="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
|
|
[ "$mode_restored" = "755" ] || fail "restored log-events.sh mode is $mode_restored, expected 755"
|
|
fi
|
|
[ "$(cat "$CFG_C/roles/hooks/user-role.sh")" = "mine-role" ] \
|
|
|| fail "user file in roles/hooks was touched by the refresh"
|
|
[ "$(cat "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh")" = "mine-agent" ] \
|
|
|| fail "user file in the agent hooks dir was touched by the refresh"
|
|
echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files (all hook locations) and restore missing ones 0755"
|
|
|
|
echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces =="
|
|
CFG_C2="$WORKDIR/c2"
|
|
mkdir -p "$CFG_C2/hooks"
|
|
write_dryrun_config "$CFG_C2"
|
|
|
|
# Every coyote invocation calls install_builtins() (force=false) before
|
|
# dispatching any flag, so a plain run on a config dir with no hooks/ yet
|
|
# must auto-populate it...
|
|
rmdir "$CFG_C2/hooks" 2>/dev/null || true
|
|
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
[ -f "$CFG_C2/hooks/notify.sh" ] || fail "cold-start run did not auto-install notify.sh"
|
|
|
|
# ...but once a file exists, the non-force path must NEVER touch it again.
|
|
echo "# LOCALLY MODIFIED" > "$CFG_C2/hooks/notify.sh"
|
|
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
grep -q "LOCALLY MODIFIED" "$CFG_C2/hooks/notify.sh" \
|
|
|| fail "a plain (non --install-builtins) invocation overwrote a locally modified hook script"
|
|
echo "PASS: plain invocations auto-bootstrap missing hooks but never force-overwrite existing ones"
|
|
|
|
echo "== Scenario C3: installed log-events.sh fires end-to-end via the documented relative path =="
|
|
# Exercises the exact wiring promised in the script headers:
|
|
# command: ./hooks/log-events.sh (resolves against the config dir)
|
|
# and the script's own contract: COYOTE_HOOK_LOG override honored, log file
|
|
# created 0600, COYOTE_SECRET_* values excluded from the snapshot.
|
|
CFG_C3="$WORKDIR/c3"
|
|
mkdir -p "$CFG_C3"
|
|
HOOK_LOG="$WORKDIR/c3-hook.log"
|
|
write_dryrun_config "$CFG_C3" "hooks:" \
|
|
" turn.completed:" \
|
|
" - name: log-events" \
|
|
" command: ./hooks/log-events.sh"
|
|
COYOTE_CONFIG_DIR="$CFG_C3" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
COYOTE_CONFIG_DIR="$CFG_C3" COYOTE_HOOK_LOG="$HOOK_LOG" COYOTE_SECRET_PROBE=hunter2 \
|
|
"$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
wait_for "installed log-events.sh output" test -s "$HOOK_LOG"
|
|
|
|
[ -f "$HOOK_LOG" ] || fail "installed log-events.sh did not write to COYOTE_HOOK_LOG via relative-path wiring"
|
|
[ "$(grep -c '^=== ' "$HOOK_LOG")" = "1" ] \
|
|
|| fail "expected exactly one event block in the hook log, got: $(cat "$HOOK_LOG")"
|
|
grep -q 'COYOTE_EVENT=turn.completed' "$HOOK_LOG" \
|
|
|| fail "hook log missing COYOTE_EVENT=turn.completed: $(cat "$HOOK_LOG")"
|
|
grep -q 'hunter2' "$HOOK_LOG" \
|
|
&& fail "COYOTE_SECRET_* value leaked into the hook log"
|
|
grep -q 'COYOTE_SECRET_' "$HOOK_LOG" \
|
|
&& fail "COYOTE_SECRET_ variable name leaked into the hook log"
|
|
if command -v stat > /dev/null 2>&1; then
|
|
mode_hook_log="$(stat -c '%a' "$HOOK_LOG" 2>/dev/null || stat -f '%Lp' "$HOOK_LOG")"
|
|
[ "$mode_hook_log" = "600" ] || fail "hook log mode is $mode_hook_log, expected 600"
|
|
fi
|
|
echo "PASS: relative-path wiring fires the installed log-events.sh; log is 0600 with secrets excluded"
|
|
|
|
echo "== Scenario D: --agent <bundled> --info fires zero agent.* events =="
|
|
CFG_D="$WORKDIR/d"
|
|
mkdir -p "$CFG_D/agents"
|
|
AGENT_LOG="$WORKDIR/agent-events.log"
|
|
SENTINEL_LOG="$WORKDIR/d-sentinel.log"
|
|
write_dryrun_config "$CFG_D" "hooks:" \
|
|
" agent.started:" \
|
|
" - name: probe-agent" \
|
|
" command: \"echo AGENT_STARTED >> $AGENT_LOG\"" \
|
|
" agent.completed:" \
|
|
" - name: probe-agent" \
|
|
" command: \"echo AGENT_DONE >> $AGENT_LOG\"" \
|
|
" agent.failed:" \
|
|
" - name: probe-agent" \
|
|
" command: \"echo AGENT_FAILED >> $AGENT_LOG\"" \
|
|
" turn.completed:" \
|
|
" - name: sentinel" \
|
|
" command: \"echo SENTINEL >> $SENTINEL_LOG\""
|
|
|
|
AGENT_NAME="$(ls "$REPO_ROOT/assets/agents" | head -1)"
|
|
cp -r "$REPO_ROOT/assets/agents/$AGENT_NAME" "$CFG_D/agents/$AGENT_NAME"
|
|
|
|
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --agent "$AGENT_NAME" --info > /dev/null 2>&1
|
|
# Proving absence needs a positive signal to wait on: a follow-up plain run
|
|
# fires turn.completed, and its sentinel landing bounds how long the --info
|
|
# run's (nonexistent) agent.* children could have taken to write.
|
|
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
wait_for "scenario D sentinel" test -s "$SENTINEL_LOG"
|
|
[ -s "$AGENT_LOG" ] && fail "expected zero agent.* events from --agent --info, got: $(cat "$AGENT_LOG")"
|
|
echo "PASS: --agent $AGENT_NAME --info fired zero agent.* hook events"
|
|
|
|
echo "== Scenario E: --install-builtins hooks reconciles roles/hooks and agent hooks dirs =="
|
|
CFG_E="$WORKDIR/e"
|
|
AGENT_E="$(ls "$REPO_ROOT/assets/agents" | head -1)"
|
|
mkdir -p "$CFG_E/roles/hooks" "$CFG_E/agents/$AGENT_E/hooks"
|
|
# Manifest-owned leftovers a previous release shipped, plus user files that
|
|
# must survive the refresh in both locations.
|
|
printf 'stale-role.sh\n' > "$CFG_E/roles/hooks/.builtin-manifest"
|
|
echo "stale" > "$CFG_E/roles/hooks/stale-role.sh"
|
|
echo "mine" > "$CFG_E/roles/hooks/user-role.sh"
|
|
printf 'stale-agent.sh\n' > "$CFG_E/agents/$AGENT_E/hooks/.builtin-manifest"
|
|
echo "stale" > "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh"
|
|
echo "mine" > "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh"
|
|
|
|
COYOTE_CONFIG_DIR="$CFG_E" "$BIN" --install-builtins hooks > "$WORKDIR/install_e.out" 2>&1 \
|
|
|| fail "--install-builtins hooks failed: $(cat "$WORKDIR/install_e.out")"
|
|
|
|
[ -f "$CFG_E/hooks/notify.sh" ] || fail "global hooks were not installed"
|
|
[ ! -f "$CFG_E/roles/hooks/stale-role.sh" ] \
|
|
|| fail "manifest-owned stale role hook survived the refresh"
|
|
[ "$(cat "$CFG_E/roles/hooks/user-role.sh")" = "mine" ] \
|
|
|| fail "user file in roles/hooks was touched by the refresh"
|
|
[ ! -f "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" ] \
|
|
|| fail "manifest-owned stale agent hook survived the refresh"
|
|
[ "$(cat "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh")" = "mine" ] \
|
|
|| fail "user file in the agent hooks dir was touched by the refresh"
|
|
echo "PASS: refresh reconciles roles/hooks and bundled-agent hooks, sparing user files"
|
|
|
|
echo "== Scenario F: top-level manifest reconcile removes stale hooks; malformed manifest deletes nothing =="
|
|
CFG_F="$WORKDIR/f"
|
|
mkdir -p "$CFG_F"
|
|
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
[ -f "$CFG_F/hooks/.builtin-manifest" ] \
|
|
|| fail "install did not write a .builtin-manifest to the top-level hooks dir"
|
|
|
|
# Simulate an upgrade from a release that also shipped old-hook.sh: the
|
|
# manifest records it, so reinstall must remove it — while a user script
|
|
# absent from the manifest survives.
|
|
printf 'log-events.sh\nnotify.sh\nold-hook.sh\n' > "$CFG_F/hooks/.builtin-manifest"
|
|
echo "stale" > "$CFG_F/hooks/old-hook.sh"
|
|
echo "mine" > "$CFG_F/hooks/user-hook.sh"
|
|
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
[ ! -f "$CFG_F/hooks/old-hook.sh" ] \
|
|
|| fail "stale shipped hook recorded in the manifest was not removed on reinstall"
|
|
[ "$(cat "$CFG_F/hooks/user-hook.sh")" = "mine" ] \
|
|
|| fail "user hook script was deleted or modified by manifest reconcile"
|
|
grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \
|
|
&& fail "manifest still lists the removed stale hook"
|
|
|
|
# Fail-safe: a malformed (non-UTF8) manifest must delete NOTHING and must
|
|
# not fail the command.
|
|
printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest"
|
|
echo "keep" > "$CFG_F/hooks/orphan.sh"
|
|
rc_f=0
|
|
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || rc_f=$?
|
|
[ "$rc_f" = "0" ] \
|
|
|| fail "reinstall failed on a malformed manifest (exit $rc_f): $(cat "$WORKDIR/install_f.out")"
|
|
[ -f "$CFG_F/hooks/orphan.sh" ] \
|
|
|| fail "a malformed manifest caused a deletion (fail-safe broken)"
|
|
[ -f "$CFG_F/hooks/notify.sh" ] && [ -f "$CFG_F/hooks/log-events.sh" ] \
|
|
|| fail "shipped hooks missing after malformed-manifest reinstall"
|
|
echo "PASS: stale manifest-owned hook removed; user files survive; malformed manifest deletes nothing"
|
|
|
|
echo "== Scenario G: plain startup sweeps removed-builtin-agent hooks, spares user agents =="
|
|
CFG_G="$WORKDIR/g"
|
|
mkdir -p "$CFG_G"
|
|
write_dryrun_config "$CFG_G"
|
|
# ghost: a removed builtin agent (has a manifest, absent from the embed)
|
|
# with a user file beside the manifest-owned hook -> hooks dir survives
|
|
# with only the user file.
|
|
mkdir -p "$CFG_G/agents/ghost/hooks"
|
|
printf 'shipped.sh\n' > "$CFG_G/agents/ghost/hooks/.builtin-manifest"
|
|
echo "stale" > "$CFG_G/agents/ghost/hooks/shipped.sh"
|
|
echo "mine" > "$CFG_G/agents/ghost/hooks/user.sh"
|
|
# ghost2: same, but nothing user-owned -> the emptied hooks dir is removed.
|
|
mkdir -p "$CFG_G/agents/ghost2/hooks"
|
|
printf 'shipped.sh\n' > "$CFG_G/agents/ghost2/hooks/.builtin-manifest"
|
|
echo "stale" > "$CFG_G/agents/ghost2/hooks/shipped.sh"
|
|
# mine: a user agent with no manifest -> never touched.
|
|
mkdir -p "$CFG_G/agents/mine/hooks"
|
|
echo "mine" > "$CFG_G/agents/mine/hooks/mine.sh"
|
|
|
|
COYOTE_CONFIG_DIR="$CFG_G" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
|
|
[ ! -f "$CFG_G/agents/ghost/hooks/shipped.sh" ] \
|
|
|| fail "removed-agent sweep left the manifest-owned hook behind"
|
|
[ ! -f "$CFG_G/agents/ghost/hooks/.builtin-manifest" ] \
|
|
|| fail "removed-agent sweep left the manifest behind"
|
|
[ "$(cat "$CFG_G/agents/ghost/hooks/user.sh")" = "mine" ] \
|
|
|| fail "removed-agent sweep deleted a user file"
|
|
[ ! -d "$CFG_G/agents/ghost2/hooks" ] \
|
|
|| fail "emptied hooks dir of a removed agent was not removed"
|
|
[ -d "$CFG_G/agents/ghost2" ] \
|
|
|| fail "sweep removed more than the hooks dir of a removed agent"
|
|
[ "$(cat "$CFG_G/agents/mine/hooks/mine.sh")" = "mine" ] \
|
|
|| fail "sweep touched a user agent dir without a manifest"
|
|
echo "PASS: startup sweep removes only manifest-owned hooks of removed agents"
|
|
|
|
echo "== Scenario H: bundled hook scripts behave as documented when run directly =="
|
|
# The two shipped scripts are consumer-facing artifacts in their own right.
|
|
# Exercise the INSTALLED copies (real CLI install) the way a hook runner or a
|
|
# manual invocation would: clean env, no COYOTE_HOOK_LOG, no notifier
|
|
# binaries, no controlling terminal.
|
|
CFG_H="$WORKDIR/h"
|
|
mkdir -p "$CFG_H"
|
|
COYOTE_CONFIG_DIR="$CFG_H" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
LOG_SCRIPT="$CFG_H/hooks/log-events.sh"
|
|
NOTIFY_SCRIPT="$CFG_H/hooks/notify.sh"
|
|
[ -x "$LOG_SCRIPT" ] || fail "installed log-events.sh missing or non-executable"
|
|
[ -x "$NOTIFY_SCRIPT" ] || fail "installed notify.sh missing or non-executable"
|
|
|
|
# The manifest the installer drops beside the scripts is NOT a script: the
|
|
# unified exec-bit predicate must leave it non-executable.
|
|
if [ -f "$CFG_H/hooks/.builtin-manifest" ]; then
|
|
[ ! -x "$CFG_H/hooks/.builtin-manifest" ] \
|
|
|| fail ".builtin-manifest in the hooks dir is executable; non-script files must stay non-executable"
|
|
fi
|
|
|
|
# H1: with COYOTE_HOOK_LOG unset the log defaults under XDG_STATE_HOME, is
|
|
# created 0600, carries a UTC ISO-8601 header, and never logs COYOTE_SECRET_*.
|
|
XDG_H="$WORKDIR/h-xdg"
|
|
env -u COYOTE_HOOK_LOG XDG_STATE_HOME="$XDG_H" HOME="$WORKDIR/h-home-unused" \
|
|
COYOTE_EVENT=probe.event COYOTE_HOOK_NAME=probe COYOTE_SECRET_TOKEN=hunter2 \
|
|
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the XDG default path"
|
|
HLOG="$XDG_H/coyote/hooks.log"
|
|
[ -f "$HLOG" ] || fail "log-events.sh did not default to \$XDG_STATE_HOME/coyote/hooks.log"
|
|
if command -v stat > /dev/null 2>&1; then
|
|
mode_hlog="$(stat -c '%a' "$HLOG" 2>/dev/null || stat -f '%Lp' "$HLOG")"
|
|
[ "$mode_hlog" = "600" ] || fail "default hook log mode is $mode_hlog, expected 600"
|
|
fi
|
|
grep -Eq '^=== [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z probe\.event$' "$HLOG" \
|
|
|| fail "log header is not a UTC ISO-8601 timestamp: $(head -1 "$HLOG")"
|
|
grep -q '^COYOTE_EVENT=probe.event$' "$HLOG" \
|
|
|| fail "log snapshot is missing COYOTE_EVENT"
|
|
! grep -q 'hunter2' "$HLOG" \
|
|
|| fail "COYOTE_SECRET_* value leaked into the hook log"
|
|
|
|
# H2: with XDG_STATE_HOME also unset, the log falls back to
|
|
# $HOME/.local/state/coyote/hooks.log.
|
|
HOME_H="$WORKDIR/h-home"
|
|
mkdir -p "$HOME_H"
|
|
env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_H" COYOTE_EVENT=probe.event \
|
|
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the HOME default path"
|
|
[ -f "$HOME_H/.local/state/coyote/hooks.log" ] \
|
|
|| fail "log-events.sh did not fall back to ~/.local/state/coyote/hooks.log"
|
|
|
|
# H3: a symlink planted at the DEFAULT log path is refused — exit 0, target
|
|
# untouched (the hardening must hold on the new default location too).
|
|
HOME_S="$WORKDIR/h-sym"
|
|
mkdir -p "$HOME_S/.local/state/coyote"
|
|
VICTIM_H="$WORKDIR/h-victim.txt"
|
|
echo "untouched" > "$VICTIM_H"
|
|
ln -s "$VICTIM_H" "$HOME_S/.local/state/coyote/hooks.log"
|
|
env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_S" COYOTE_EVENT=probe.event \
|
|
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero when refusing a symlink"
|
|
[ "$(cat "$VICTIM_H")" = "untouched" ] \
|
|
|| fail "log-events.sh wrote through a symlink planted at the default log path"
|
|
|
|
# H4: notify.sh with no notifier on PATH and no controlling terminal falls
|
|
# back to stdout with control bytes (ESC/BEL, i.e. ANSI + OSC) stripped from
|
|
# model-influenced values.
|
|
BIN_H="$WORKDIR/h-bin"
|
|
mkdir -p "$BIN_H"
|
|
ln -s "$(command -v tr)" "$BIN_H/tr"
|
|
NOTIFY_OUT="$WORKDIR/h-notify.out"
|
|
rc_h=0
|
|
setsid -w env -i PATH="$BIN_H" \
|
|
COYOTE_EVENT=turn.completed \
|
|
COYOTE_TOOL_NAME="$(printf 'evil\033]0;own\007\033[31mred')" \
|
|
"$(command -v bash)" "$NOTIFY_SCRIPT" > "$NOTIFY_OUT" 2> /dev/null || rc_h=$?
|
|
[ "$rc_h" = "0" ] || fail "notify.sh fallback exited $rc_h"
|
|
grep -Fq '[coyote] turn.completed tool=evil]0;own[31mred' "$NOTIFY_OUT" \
|
|
|| fail "notify.sh fallback output missing/unsanitized: $(cat "$NOTIFY_OUT")"
|
|
! LC_ALL=C grep -q "$(printf '\033')" "$NOTIFY_OUT" \
|
|
|| fail "ANSI/OSC escape bytes leaked through the notify.sh tty fallback"
|
|
echo "PASS: installed hook scripts honor XDG default, 0600, UTC timestamps, symlink refusal, secret filter, and ANSI-stripped notify fallback"
|
|
|
|
echo "== Scenario I: session.started vs session.resumed via --session =="
|
|
CFG_I="$WORKDIR/i"
|
|
mkdir -p "$CFG_I"
|
|
SESS_LOG="$WORKDIR/i-sess.log"
|
|
write_dryrun_config "$CFG_I" "hooks:" \
|
|
" session.started:" \
|
|
" - name: probe-sess" \
|
|
" command: \"echo STARTED >> $SESS_LOG\"" \
|
|
" session.resumed:" \
|
|
" - name: probe-sess" \
|
|
" command: \"echo RESUMED >> $SESS_LOG\""
|
|
|
|
# A fresh named session (no file on disk yet) fires session.started only.
|
|
COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-fresh --no-stream "say exactly: hi" > /dev/null
|
|
wait_for "session.started log" test -s "$SESS_LOG"
|
|
[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one session.started for a fresh session, got: $(cat "$SESS_LOG")"
|
|
grep -q '^RESUMED$' "$SESS_LOG" \
|
|
&& fail "a fresh named session must not fire session.resumed: $(cat "$SESS_LOG")"
|
|
|
|
# A persisted session on disk: resuming it fires session.resumed, and never
|
|
# session.started (the inverse gate).
|
|
mkdir -p "$CFG_I/sessions"
|
|
printf 'model: dryrun:dry-model\nmessages: []\n' > "$CFG_I/sessions/probe-resume.yaml"
|
|
COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-resume --no-stream "say exactly: hi" > /dev/null
|
|
scenario_i_resumed() { grep -q '^RESUMED$' "$SESS_LOG"; }
|
|
wait_for "session.resumed log" scenario_i_resumed
|
|
[ "$(grep -c '^RESUMED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one session.resumed for a resumed session, got: $(cat "$SESS_LOG")"
|
|
[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "resuming a session must not fire session.started: $(cat "$SESS_LOG")"
|
|
echo "PASS: fresh session fires session.started only; resume fires session.resumed only"
|
|
|
|
echo "== Scenario J: terminal ctrl-c during a live headless --agent run fires agent.interrupted =="
|
|
if ! command -v python3 > /dev/null 2>&1; then
|
|
echo "SKIP: python3 not available; cannot allocate a pty or hanging LLM endpoint" >&2
|
|
else
|
|
CFG_J="$WORKDIR/j"
|
|
mkdir -p "$CFG_J/agents/probe-int"
|
|
INT_LOG="$WORKDIR/j-int.log"
|
|
|
|
# A local "LLM endpoint" that accepts connections and never answers keeps
|
|
# the agent run in flight (spinner active, its ctrl-c watcher polling)
|
|
# while the probe delivers ^C. It writes its port to $1 once listening and
|
|
# touches $2 on the first accepted connection, so the runner below can
|
|
# gate the ^C on the request being demonstrably in flight.
|
|
python3 - "$WORKDIR/j-port" "$WORKDIR/j-conn" <<'PY' &
|
|
import socket, sys
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
s.listen(8)
|
|
open(sys.argv[1], "w").write(str(s.getsockname()[1]))
|
|
conns = []
|
|
while True:
|
|
c, _ = s.accept()
|
|
if not conns:
|
|
open(sys.argv[2], "w").write("1")
|
|
conns.append(c)
|
|
PY
|
|
SRV_J=$!
|
|
trap 'kill "$SRV_J" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT
|
|
wait_for "hang-server port" test -s "$WORKDIR/j-port"
|
|
PORT_J="$(cat "$WORKDIR/j-port")"
|
|
|
|
cat > "$CFG_J/config.yaml" <<EOF
|
|
model: hangc:hang-model
|
|
clients:
|
|
- type: openai
|
|
name: hangc
|
|
auth: none
|
|
api_key: 'unused'
|
|
api_base: http://127.0.0.1:$PORT_J/v1
|
|
models:
|
|
- name: hang-model
|
|
max_input_tokens: 100000
|
|
supports_function_calling: true
|
|
save: false
|
|
memory: false
|
|
stream: false
|
|
EOF
|
|
|
|
# Agent-level hooks resolve without any global_hooks whitelisting, so the
|
|
# probe agent carries its own markers for every terminal agent event.
|
|
cat > "$CFG_J/agents/probe-int/config.yaml" <<EOF
|
|
name: probe-int
|
|
description: ctrl-c interruption probe agent
|
|
version: 0.1.0
|
|
instructions: |
|
|
You are a probe agent. Reply briefly.
|
|
hooks:
|
|
agent.started:
|
|
- name: mark
|
|
command: "echo STARTED >> $INT_LOG"
|
|
agent.completed:
|
|
- name: mark
|
|
command: "echo COMPLETED >> $INT_LOG"
|
|
agent.failed:
|
|
- name: mark
|
|
command: "echo \"FAILED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG"
|
|
agent.interrupted:
|
|
- name: mark
|
|
command: "echo \"INTERRUPTED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG"
|
|
EOF
|
|
|
|
# Spawn coyote on a real pty (the spinner's ctrl-c watcher only runs on a
|
|
# terminal), wait until the agent.started marker AND the first accepted
|
|
# LLM connection prove the run is in flight, then send a literal ^C
|
|
# through the pty line discipline — exactly what a terminal user does.
|
|
cat > "$WORKDIR/j-pty.py" <<'PY'
|
|
import os, pty, sys, time
|
|
|
|
int_log, conn_marker = sys.argv[1], sys.argv[2]
|
|
argv = sys.argv[3:]
|
|
|
|
pid, master = pty.fork()
|
|
if pid == 0:
|
|
os.execvp(argv[0], argv)
|
|
|
|
os.set_blocking(master, False)
|
|
|
|
def drain():
|
|
try:
|
|
while os.read(master, 4096):
|
|
pass
|
|
except OSError:
|
|
pass
|
|
|
|
def marker_seen():
|
|
try:
|
|
with open(int_log) as f:
|
|
return "STARTED" in f.read()
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
drain()
|
|
if marker_seen() and os.path.exists(conn_marker):
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
print("TIMEOUT: agent.started marker / LLM connection never appeared", flush=True)
|
|
os.kill(pid, 9)
|
|
os.waitpid(pid, 0)
|
|
sys.exit(3)
|
|
|
|
os.write(master, b"\x03") # ^C via the pty line discipline
|
|
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
drain()
|
|
got, status = os.waitpid(pid, os.WNOHANG)
|
|
if got == pid:
|
|
if os.WIFSIGNALED(status):
|
|
print(f"FATAL: coyote died on signal {os.WTERMSIG(status)} — no ctrl-c handler was installed", flush=True)
|
|
sys.exit(4)
|
|
sys.exit(0)
|
|
time.sleep(0.1)
|
|
|
|
print("TIMEOUT: coyote did not exit within 15s of ^C", flush=True)
|
|
os.kill(pid, 9)
|
|
os.waitpid(pid, 0)
|
|
sys.exit(3)
|
|
PY
|
|
|
|
COYOTE_CONFIG_DIR="$CFG_J" python3 "$WORKDIR/j-pty.py" "$INT_LOG" "$WORKDIR/j-conn" \
|
|
"$BIN" --agent probe-int --no-stream "say exactly: hi" \
|
|
|| fail "pty ctrl-c run did not exit cleanly (see message above)"
|
|
|
|
scenario_j_interrupted() { grep -qs '^INTERRUPTED err=unset$' "$INT_LOG"; }
|
|
wait_for "agent.interrupted log" scenario_j_interrupted
|
|
[ "$(grep -c '^STARTED$' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one agent.started, got: $(cat "$INT_LOG")"
|
|
[ "$(grep -c '^INTERRUPTED' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
|| fail "expected exactly one agent.interrupted, got: $(cat "$INT_LOG")"
|
|
grep -q '^FAILED' "$INT_LOG" \
|
|
&& fail "ctrl-c must fire agent.interrupted, never agent.failed: $(cat "$INT_LOG")"
|
|
grep -q '^COMPLETED' "$INT_LOG" \
|
|
&& fail "an interrupted run must not fire agent.completed: $(cat "$INT_LOG")"
|
|
kill "$SRV_J" 2>/dev/null || true
|
|
echo "PASS: terminal ctrl-c fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR; never failed/completed"
|
|
fi
|
|
|
|
echo "== Scenario K: global_hooks wildcard grammar (exact, <event>.*, *.<name>, *.*, invalid globs) =="
|
|
# Global hooks: agent.started has one hook named 'mark'; agent.completed has
|
|
# two hooks named 'mark' and 'other'. Each sub-case runs the same
|
|
# `--agent wc-probe --macro probe` recipe in its own config dir with a
|
|
# different global_hooks whitelist, then asserts exactly which markers were
|
|
# admitted. The binary's exit drain only acknowledges hook SPAWNS — the hook
|
|
# processes finish on their own after exit — so every expected marker gets
|
|
# its own wait_for before any absence check reads the log.
|
|
k_run() {
|
|
# $1 = label, $2 = marker log, $3.. = global_hooks entries (verbatim)
|
|
local label="$1" klog="$2"
|
|
shift 2
|
|
local kcfg="$WORKDIR/k-$label"
|
|
mkdir -p "$kcfg/agents/wc-probe" "$kcfg/macros"
|
|
write_dryrun_config "$kcfg" "hooks:" \
|
|
" agent.started:" \
|
|
" - name: mark" \
|
|
" command: \"echo S-mark >> $klog\"" \
|
|
" agent.completed:" \
|
|
" - name: mark" \
|
|
" command: \"echo C-mark >> $klog\"" \
|
|
" - name: other" \
|
|
" command: \"echo C-other >> $klog\""
|
|
{
|
|
printf 'name: wc-probe\n'
|
|
printf 'description: wildcard whitelist probe agent\n'
|
|
printf 'version: 0.1.0\n'
|
|
printf 'instructions: |\n'
|
|
printf ' You are a probe agent. Reply briefly.\n'
|
|
printf 'global_hooks:\n'
|
|
local entry
|
|
for entry in "$@"; do
|
|
printf ' - "%s"\n' "$entry"
|
|
done
|
|
} > "$kcfg/agents/wc-probe/config.yaml"
|
|
printf 'steps:\n - ".set temperature 0.5"\n' > "$kcfg/macros/probe.yaml"
|
|
COYOTE_CONFIG_DIR="$kcfg" "$BIN" --agent wc-probe --macro probe > /dev/null 2>&1 \
|
|
|| fail "scenario K ($label): --agent wc-probe --macro probe exited nonzero"
|
|
}
|
|
|
|
marker_count_is() { # $1 = log, $2 = marker, $3 = expected count
|
|
local n
|
|
n="$(grep -c -x -- "$2" "$1" 2>/dev/null)" || n=0
|
|
[ "${n:-0}" = "$3" ]
|
|
}
|
|
|
|
# K1: exact <event>.<name> admits only that hook on that event (unchanged).
|
|
KLOG1="$WORKDIR/k1.log"
|
|
k_run exact "$KLOG1" "agent.completed.mark"
|
|
wait_for "K1 C-mark" marker_count_is "$KLOG1" C-mark 1
|
|
marker_count_is "$KLOG1" S-mark 0 || fail "K1: exact entry leaked agent.started: $(cat "$KLOG1")"
|
|
marker_count_is "$KLOG1" C-other 0 || fail "K1: exact entry leaked sibling hook 'other': $(cat "$KLOG1")"
|
|
|
|
# K2: <event>.* admits every hook of that event, nothing else.
|
|
KLOG2="$WORKDIR/k2.log"
|
|
k_run event-star "$KLOG2" "agent.completed.*"
|
|
wait_for "K2 C-mark" marker_count_is "$KLOG2" C-mark 1
|
|
wait_for "K2 C-other" marker_count_is "$KLOG2" C-other 1
|
|
marker_count_is "$KLOG2" S-mark 0 || fail "K2: agent.completed.* leaked agent.started: $(cat "$KLOG2")"
|
|
|
|
# K3: *.<name> admits that hook name on every event, nothing else.
|
|
KLOG3="$WORKDIR/k3.log"
|
|
k_run star-name "$KLOG3" "*.mark"
|
|
wait_for "K3 S-mark" marker_count_is "$KLOG3" S-mark 1
|
|
wait_for "K3 C-mark" marker_count_is "$KLOG3" C-mark 1
|
|
marker_count_is "$KLOG3" C-other 0 || fail "K3: *.mark leaked hook 'other': $(cat "$KLOG3")"
|
|
|
|
# K4: *.* admits everything (the bare * form is pinned by tests/macro_bracketing.rs).
|
|
KLOG4="$WORKDIR/k4.log"
|
|
k_run star-star "$KLOG4" "*.*"
|
|
wait_for "K4 S-mark" marker_count_is "$KLOG4" S-mark 1
|
|
wait_for "K4 C-mark" marker_count_is "$KLOG4" C-mark 1
|
|
wait_for "K4 C-other" marker_count_is "$KLOG4" C-other 1
|
|
|
|
# K5: partial/inner globs are NOT wildcards — they admit nothing, and the run
|
|
# is otherwise unaffected (exit code checked inside k_run). The exact
|
|
# agent.started.mark entry is the positive signal bounding the absence checks.
|
|
KLOG5="$WORKDIR/k5.log"
|
|
k_run invalid-globs "$KLOG5" "agent.completed.ma*" "agent.*" "ag*.completed.mark" "agent.started.mark"
|
|
wait_for "K5 S-mark" marker_count_is "$KLOG5" S-mark 1
|
|
marker_count_is "$KLOG5" C-mark 0 || fail "K5: a partial glob admitted agent.completed.mark: $(cat "$KLOG5")"
|
|
marker_count_is "$KLOG5" C-other 0 || fail "K5: a partial glob admitted agent.completed.other: $(cat "$KLOG5")"
|
|
echo "PASS: wildcard whitelist grammar admits exactly the spec'd sets; partial globs stay inert"
|
|
|
|
echo "== Scenario L: --macro without --agent fires zero agent.* events =="
|
|
CFG_L="$WORKDIR/l"
|
|
mkdir -p "$CFG_L/macros"
|
|
L_AGENT_LOG="$WORKDIR/l-agent-events.log"
|
|
L_SENTINEL_LOG="$WORKDIR/l-sentinel.log"
|
|
write_dryrun_config "$CFG_L" "hooks:" \
|
|
" agent.started:" \
|
|
" - name: mark" \
|
|
" command: \"echo STARTED >> $L_AGENT_LOG\"" \
|
|
" agent.completed:" \
|
|
" - name: mark" \
|
|
" command: \"echo COMPLETED >> $L_AGENT_LOG\"" \
|
|
" agent.failed:" \
|
|
" - name: mark" \
|
|
" command: \"echo FAILED >> $L_AGENT_LOG\"" \
|
|
" turn.completed:" \
|
|
" - name: sentinel" \
|
|
" command: \"echo SENTINEL >> $L_SENTINEL_LOG\""
|
|
printf 'steps:\n - ".set temperature 0.5"\n' > "$CFG_L/macros/probe.yaml"
|
|
COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --macro probe > /dev/null 2>&1 \
|
|
|| fail "scenario L: --macro without --agent exited nonzero"
|
|
# Proving absence needs a positive signal to wait on: a follow-up plain run
|
|
# fires turn.completed, and its sentinel landing bounds how long the macro
|
|
# run's (nonexistent) agent.* children could have taken to write.
|
|
COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
wait_for "scenario L sentinel" test -s "$L_SENTINEL_LOG"
|
|
[ -s "$L_AGENT_LOG" ] && fail "expected zero agent.* events from --macro without --agent, got: $(cat "$L_AGENT_LOG")"
|
|
echo "PASS: --macro without --agent fired zero agent.* hook events and exited cleanly"
|
|
|
|
echo
|
|
echo "ALL SCENARIOS PASSED"
|