From fcdcb303a3430f1251dc32eb2b110f527d911393 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 12:00:16 -0600 Subject: [PATCH] style: Cleaned up unnecessary path prefixes, imports, stray scripts, and some documentation --- README.md | 2 +- scripts/usage-probe-hooks.sh | 766 ------------------------------ scripts/usage-probe-inspection.sh | 115 ----- scripts/usage-probe-wizard.sh | 68 --- src/cli/completer.rs | 2 - src/config/agent.rs | 43 +- src/config/app_state.rs | 2 - src/config/builtin_manifest.rs | 2 +- src/config/bundles.rs | 4 - src/config/conflict.rs | 29 +- src/config/install_remote.rs | 9 - src/config/macros.rs | 5 - src/config/mod.rs | 61 +-- src/config/request_context.rs | 13 +- src/function/agents.rs | 18 +- src/function/jobs.rs | 2 - src/function/mod.rs | 30 +- src/function/user_interaction.rs | 2 - src/graph/agent.rs | 8 - src/graph/user_interaction.rs | 4 - src/hooks.rs | 180 +++---- src/main.rs | 31 +- src/repl/mod.rs | 10 - src/sandbox/mixins.rs | 5 - src/supervisor/mod.rs | 5 - src/vault/mod.rs | 2 - 26 files changed, 132 insertions(+), 1286 deletions(-) delete mode 100755 scripts/usage-probe-hooks.sh delete mode 100755 scripts/usage-probe-inspection.sh delete mode 100755 scripts/usage-probe-wizard.sh diff --git a/README.md b/README.md index 55c2372..33d366d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g * [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools. * [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns). * [Background Jobs](https://github.com/Dark-Alex-17/coyote/wiki/Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working, and completion arrives as a push notification. -* [Hooks](https://github.com/Dark-Alex-17/coyote/wiki/Hooks): Run your own fire-and-forget commands when lifecycle events fire — turns, tools, LLM requests, agents, sub-agent escalations, and more (26 events). Perfect for desktop notifications, activity logging, and downstream automation; hooks never block Coyote or change its behavior. +* [Hooks](https://github.com/Dark-Alex-17/coyote/wiki/Hooks): Run your own fire-and-forget commands when lifecycle events fire. Turns, tools, LLM requests, agents, sub-agent escalations, and more (26 events). Perfect for desktop notifications, activity logging, and downstream automation; hooks never block Coyote or change its behavior. * [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models. * [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables. * [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers. diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh deleted file mode 100755 index 003a66a..0000000 --- a/scripts/usage-probe-hooks.sh +++ /dev/null @@ -1,766 +0,0 @@ -#!/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 /hooks via the real CLI; a non-interactive re-run -# keeps locally modified scripts while still installing missing ones. -# D) `coyote --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 ` 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 --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" < "$CFG_J/agents/probe-int/config.yaml" <> $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, .*, *., *.*, 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 . 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: .* 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: *. 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" diff --git a/scripts/usage-probe-inspection.sh b/scripts/usage-probe-inspection.sh deleted file mode 100755 index b7e5543..0000000 --- a/scripts/usage-probe-inspection.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# Black-box usage-pattern probe for inspection flags on a pristine config dir. -# -# Contract under test (first-run wizard bypass for look-don't-touch flags): -# With COYOTE_CONFIG_DIR pointing at an EMPTY directory, every inspection -# flag (--info, --list-* , --mcp-list, vault list) must: -# 1) exit without prompting (no tty read, no first-run wizard), -# 2) exit 0 (Config::default() fallback); only --info guarantees output -# on stdout — list flags legitimately emit an empty listing on a -# pristine dir, -# 3) write NOTHING into the config dir (no config.yaml, no builtins). -# The plain interactive path (`coyote` on a tty, no flags) must still reach -# the first-run wizard prompt; that is covered manually / by the REPL recipe -# and is intentionally not asserted here (needs a pty). -# -# Usage: scripts/usage-probe-inspection.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 - -failures=0 -fail() { - echo "FAIL: $1" >&2 - failures=$((failures + 1)) -} - -# Each flag gets a fresh, empty config dir. stdin is closed so any attempt to -# prompt fails fast; timeout guards against a hang instead of a read error. -probe_flag() { - # $1 = "require-output" or "allow-empty" (stdout expectation), - # $2... = the flag (and any args) to pass - local output_mode="$1"; shift - local desc="$*" - local cfg out rc nwritten - cfg="$(mktemp -d "$WORKDIR/cfg.XXXXXX")" - out="$WORKDIR/out.txt" - rc=0 - COYOTE_CONFIG_DIR="$cfg" timeout 30 "$BIN" "$@" "$out" 2>&1 || rc=$? - - if [ "$rc" = "124" ]; then - fail "$desc: hung (likely waiting on a prompt) on an empty config dir" - return - fi - if [ "$rc" != "0" ]; then - fail "$desc: expected clean exit 0 on an empty config dir, got rc=$rc, output: $(head -c 200 "$out")" - fi - if [ "$output_mode" = "require-output" ] && [ ! -s "$out" ]; then - fail "$desc: expected some output, got none" - fi - nwritten="$(find "$cfg" -mindepth 1 | wc -l | tr -d ' ')" - if [ "$nwritten" != "0" ]; then - fail "$desc: expected NOTHING written into the empty config dir, found $nwritten entries: $(find "$cfg" -mindepth 1 -maxdepth 1 | tr '\n' ' ')" - fi -} - -echo "== Inspection flags on a pristine COYOTE_CONFIG_DIR: no wizard, clean exit, zero writes ==" -probe_flag require-output --info -probe_flag allow-empty --list-models -probe_flag allow-empty --list-roles -probe_flag allow-empty --list-sessions -probe_flag allow-empty --list-agents -probe_flag allow-empty --list-macros -probe_flag allow-empty --list-skills -probe_flag allow-empty --list-bundles -probe_flag allow-empty --list-secrets -probe_flag allow-empty --mcp-list - -# --sync-models writes models-override.yaml, so it must NOT behave like an -# inspection flag: on a fresh empty config dir it takes the bootstrap path -# (builtins written) and, off a terminal, fails on the missing config. -# IS_SANDBOX is stripped: the host path is the one under test, and a leaked -# sandbox flag would reroute the missing config to the wizard prompt. -echo "== --sync-models on a pristine COYOTE_CONFIG_DIR: bootstrap path, strict failure ==" -sync_cfg="$(mktemp -d "$WORKDIR/cfg.XXXXXX")" -sync_out="$WORKDIR/sync-out.txt" -sync_rc=0 -env -u IS_SANDBOX -u COYOTE_PROVIDER -u COYOTE_PLATFORM COYOTE_CONFIG_DIR="$sync_cfg" \ - timeout 30 "$BIN" --sync-models "$sync_out" 2>&1 || sync_rc=$? -if [ "$sync_rc" = "124" ]; then - fail "--sync-models: hung (likely waiting on a prompt) on an empty config dir" -else - if [ "$sync_rc" = "0" ]; then - fail "--sync-models: expected the strict missing-config failure on an empty config dir, got rc=0" - fi - sync_nwritten="$(find "$sync_cfg" -mindepth 1 | wc -l | tr -d ' ')" - if [ "$sync_nwritten" = "0" ]; then - fail "--sync-models: expected the builtins bootstrap to populate the empty config dir, found nothing" - fi -fi - -if [ "$failures" != "0" ]; then - echo - echo "$failures inspection-flag scenario(s) FAILED" >&2 - exit 1 -fi - -echo -echo "ALL SCENARIOS PASSED" diff --git a/scripts/usage-probe-wizard.sh b/scripts/usage-probe-wizard.sh deleted file mode 100755 index 947c5d5..0000000 --- a/scripts/usage-probe-wizard.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Black-box usage-pattern probe for the first-run wizard on a real PTY. -# -# Contract under test (companion to scripts/usage-probe-inspection.sh, which -# intentionally cannot assert this because it needs a pty): -# Plain `coyote` (no flags, no message) on a TTY with an EMPTY -# COYOTE_CONFIG_DIR must still reach the first-run wizard — the -# look-don't-touch bypass for inspection flags must NOT swallow the -# interactive setup path. We assert the wizard's first prompt -# ("No config file, create a new one?") appears within the timeout; the -# run is then killed, so the exit code is not asserted. -# -# Usage: scripts/usage-probe-wizard.sh -# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or -# ./target/release/coyote, and the `script` utility (util-linux or BSD). - -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 - -if ! command -v script > /dev/null 2>&1; then - echo "SKIP: 'script' utility not available; cannot allocate a pty" >&2 - exit 0 -fi - -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -CFG="$WORKDIR/cfg" -mkdir -p "$CFG" -OUT="$WORKDIR/out.txt" - -echo "== Plain \`coyote\` on a pty with an empty COYOTE_CONFIG_DIR reaches the wizard ==" - -# `script` allocates a real pty for the child, so IS_STDOUT_TERMINAL is true -# inside even in headless CI. IS_SANDBOX is stripped to exercise the plain -# host path from the recipe. The wizard blocks on its prompt forever, so a -# timeout kill (rc 124) is the expected way out. -rc=0 -if script --version 2>/dev/null | grep -q util-linux; then - env -u IS_SANDBOX COYOTE_CONFIG_DIR="$CFG" \ - timeout 15 script -qec "$BIN" /dev/null > "$OUT" 2>&1 < /dev/null || rc=$? -else - # BSD/macOS script: command goes after the typescript file. - env -u IS_SANDBOX COYOTE_CONFIG_DIR="$CFG" \ - timeout 15 script -q "$OUT" "$BIN" < /dev/null > /dev/null 2>&1 || rc=$? -fi - -if ! grep -aq "No config file, create a new one?" "$OUT"; then - echo "FAIL: wizard prompt never appeared (rc=$rc); output was:" >&2 - head -c 500 "$OUT" >&2 - exit 1 -fi - -echo "PASS: first-run wizard prompt appeared on a pty with an empty config dir" -echo -echo "ALL SCENARIOS PASSED" diff --git a/src/cli/completer.rs b/src/cli/completer.rs index 6e356f5..68b4735 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -50,8 +50,6 @@ pub(super) fn model_completer(current: &OsStr) -> Vec { fn load_app_config_for_completion() -> anyhow::Result { let h = tokio::runtime::Handle::try_current().ok(); - // Tab completion is a read-only inspection surface: never run the - // first-run wizard or write anything from a completer. let cfg = match h { Some(handle) => tokio::task::block_in_place(|| { handle.block_on(Config::load_with_interpolation(true, true)) diff --git a/src/config/agent.rs b/src/config/agent.rs index 156bd5e..358d256 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -113,7 +113,7 @@ pub(crate) fn install_and_reconcile_agent_hooks( /// swept, while files absent from the manifest always survive. /// Best-effort: a failure here must not abort the install. fn sweep_removed_agent_hooks(bundled_files: &HashMap>) { - let entries = match std::fs::read_dir(paths::agents_data_dir()) { + let entries = match read_dir(paths::agents_data_dir()) { Ok(entries) => entries, Err(err) => { warn!("Failed to scan agents dir for removed-agent hooks: {err}"); @@ -156,7 +156,7 @@ fn sweep_removed_agent_hooks(bundled_files: &HashMap>) { } // remove_dir only succeeds on an empty directory, so a user file // left behind keeps the directory in place. - let _ = std::fs::remove_dir(&hooks_dir); + let _ = fs::remove_dir(&hooks_dir); } } @@ -184,9 +184,6 @@ impl Agent { paths::agents_data_dir().display() ); - // Whole-agent installs run Skip (startup) or Force (reinstall); a - // Prompt caller would still get coherent per-file conflict handling, - // scoped to this run. let mut sticky = StickyMode::None; let mut written_hooks: HashMap> = HashMap::new(); for file in AgentAssets::iter() { @@ -283,15 +280,7 @@ impl Agent { Ok(()) } - /// Refreshes only the bundled agents' hook scripts, leaving every other - /// agent file alone: the hooks asset category must be able to update all - /// hook locations without reinstalling whole agents. - /// Every bundled agent's hooks/ directory reconciles, even when the - /// current embed ships no hooks for it. pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> { - // Every bundled agent gets an entry, hooks or not: an agent whose - // hook assets were all dropped still needs its hooks/ directory - // reconciled so manifest-owned leftovers are removed. let mut shipped: BTreeMap> = BTreeMap::new(); for file in AgentAssets::iter() { let Some((agent, rest)) = file.as_ref().split_once('/') else { @@ -311,6 +300,7 @@ impl Agent { for (agent, files) in &shipped { install_and_reconcile_agent_hooks(agent, files, mode, sticky)?; } + Ok(()) } @@ -1496,6 +1486,7 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option #[cfg(test)] mod tests { use super::*; + use crate::testing; #[test] fn parse_direct_hook_path_accepts_only_direct_hook_children() { @@ -1610,12 +1601,12 @@ global_hooks: #[test] #[serial_test::serial] fn load_envs_ignores_malformed_global_tools_and_logs_debug() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let name = "gt-malformed-envtest"; let yaml = format!("name: {name}\ninstructions: hi\nglobal_tools:\n - keep_tool.sh\n"); let mut config: AgentConfig = serde_yaml::from_str(&yaml).unwrap(); let env_name = normalize_env_name(&format!("{name}_global_tools")); - let _guard = crate::testing::EnvVarGuard::set(&env_name, "{not-json"); + let _guard = testing::EnvVarGuard::set(&env_name, "{not-json"); config.load_envs(&AppConfig::default()); @@ -1624,7 +1615,7 @@ global_hooks: vec!["keep_tool.sh"], "a malformed override must leave the existing value untouched" ); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs.iter().any(|message| { message.contains("Ignoring malformed global_tools env override") @@ -2076,9 +2067,9 @@ nodes: {} let _guard = TestConfigDirGuard::new("agent-hooks-prompt"); let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]); let dir = paths::agents_data_dir().join("probe").join("hooks"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("a.sh"), "local a").unwrap(); - std::fs::write(dir.join("b.sh"), "local b").unwrap(); + create_dir_all(&dir).unwrap(); + fs::write(dir.join("a.sh"), "local a").unwrap(); + fs::write(dir.join("b.sh"), "local b").unwrap(); // First conflict answered per-file, second by the sticky replace-all. let script = prompt_script::install(&["replace-all"]); @@ -2098,7 +2089,7 @@ nodes: {} // A sticky mode carried in from an earlier location suppresses all // prompting: `--install-builtins hooks` shares one sticky scope // across the global, role, and agent hook locations. - std::fs::write(dir.join("a.sh"), "local again").unwrap(); + fs::write(dir.join("a.sh"), "local again").unwrap(); let script = prompt_script::install(&[]); let mut sticky = StickyMode::KeepAll; install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky) @@ -2121,7 +2112,7 @@ nodes: {} &mut StickyMode::None, ) .unwrap(); - std::fs::write(dir.join("user.sh"), "user-owned").unwrap(); + fs::write(dir.join("user.sh"), "user-owned").unwrap(); install_and_reconcile_agent_hooks( "probe", @@ -2182,7 +2173,7 @@ nodes: {} &mut StickyMode::None, ) .unwrap(); - std::fs::write(dir.join("user.sh"), "user-owned").unwrap(); + fs::write(dir.join("user.sh"), "user-owned").unwrap(); Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); @@ -2200,14 +2191,14 @@ nodes: {} let guard = TestConfigDirGuard::new("agent-sweep-symlink"); let real = guard.path.join("agent-elsewhere"); let hooks = real.join("hooks"); - std::fs::create_dir_all(&hooks).unwrap(); - std::fs::write( + create_dir_all(&hooks).unwrap(); + fs::write( hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE), "shipped.sh\n", ) .unwrap(); - std::fs::write(hooks.join("shipped.sh"), "stale").unwrap(); - std::fs::create_dir_all(paths::agents_data_dir()).unwrap(); + fs::write(hooks.join("shipped.sh"), "stale").unwrap(); + create_dir_all(paths::agents_data_dir()).unwrap(); std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap(); sweep_removed_agent_hooks(&HashMap::new()); diff --git a/src/config/app_state.rs b/src/config/app_state.rs index 29d20e4..fb882bf 100644 --- a/src/config/app_state.rs +++ b/src/config/app_state.rs @@ -75,8 +75,6 @@ impl AppState { } } - // Inspection-only runs are look-don't-touch readouts: skip the - // function-binary prune/build, which writes into the config dir. let mut functions = if inspection { Functions::default() } else { diff --git a/src/config/builtin_manifest.rs b/src/config/builtin_manifest.rs index abd616e..1b51523 100644 --- a/src/config/builtin_manifest.rs +++ b/src/config/builtin_manifest.rs @@ -24,7 +24,7 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool { } /// NTFS 8.3 short names (`BUILTI~1`, `builti~1.sh`) can alias any long -/// filename on the same volume — including the manifest itself — so an entry +/// filename on the same volume, including the manifest itself, so an entry /// shaped like one is never accepted: it could direct a deletion at a file /// the manifest never named. fn is_ntfs_short_name_alias(name: &str) -> bool { diff --git a/src/config/bundles.rs b/src/config/bundles.rs index c5309c9..caa3ce2 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -241,10 +241,6 @@ impl BundleStore { same_name_other_source: None, }; - // Reserved names are the `--install-builtins` categories. `roles` is - // deliberately NOT among them: builtin roles are not installable that - // way (some are coupled to flag-specific coyote behavior), so the - // asymmetry with InstallFilter is intended. let reserved = AssetCategory::parse(&base).is_some(); let collision = self.source_of_other_bundle(&base, &canonical); if reserved || collision.is_some() { diff --git a/src/config/conflict.rs b/src/config/conflict.rs index 2ff7f1e..7b3c57d 100644 --- a/src/config/conflict.rs +++ b/src/config/conflict.rs @@ -2,7 +2,7 @@ //! silently clobber locally modified files. The bundle installer and the //! builtin hook installers all funnel per-file conflicts through //! [`resolve_conflict`], so the prompt wording and the sticky -//! keep-all/replace-all semantics are identical everywhere — including when +//! keep-all/replace-all semantics are identical everywhere, including when //! one sticky scope spans several install locations in a single run. use crate::utils::IS_STDOUT_TERMINAL; @@ -12,17 +12,10 @@ use inquire::Select; use std::fs; use std::path::Path; -/// How an installer treats a destination file that already exists. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InstallMode { - /// Leave existing files untouched. The only mode startup ever uses: - /// startup runs on every launch — including the REPL, which is a - /// terminal — so it must never prompt. Skip, - /// Overwrite existing files unconditionally. Force, - /// Write missing files, silently skip identical ones, and ask per - /// differing file. Prompt, } @@ -42,20 +35,12 @@ pub(crate) enum ConflictAction { Replace, } -/// What [`resolve_conflict`] does without a terminal: bundle installs bail -/// (an unattended install must not half-apply a plan silently), builtin hook -/// refreshes keep the local file (a scripted refresh must neither clobber -/// local changes nor fail). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NonInteractive { Bail, Keep, } -/// Decides whether an installer should overwrite the existing file at `dst` -/// with `content` under `mode`. Only called for files that already exist; -/// missing files are always written without prompting. In `Prompt` mode an -/// unreadable local file counts as differing, so the user decides. pub(crate) fn should_replace_existing( dst: &Path, content: &str, @@ -337,9 +322,9 @@ mod tests { fn should_replace_existing_skips_identical_content_without_prompting() { let _script = prompt_script::install(&[]); let dir = crate::utils::temp_file("conflict-identical-", ""); - std::fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).unwrap(); let path = dir.join("hook.sh"); - std::fs::write(&path, "same content").unwrap(); + fs::write(&path, "same content").unwrap(); let mut sticky = StickyMode::None; let replace = should_replace_existing( @@ -351,7 +336,7 @@ mod tests { ) .unwrap(); - let _ = std::fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&dir); assert!(!replace); assert_eq!(prompt_script::prompts_asked(), 0); } @@ -361,9 +346,9 @@ mod tests { fn should_replace_existing_never_prompts_in_skip_or_force_mode() { let _script = prompt_script::install(&[]); let dir = crate::utils::temp_file("conflict-modes-", ""); - std::fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).unwrap(); let path = dir.join("hook.sh"); - std::fs::write(&path, "local edit").unwrap(); + fs::write(&path, "local edit").unwrap(); let mut sticky = StickyMode::None; let skip = @@ -371,7 +356,7 @@ mod tests { let force = should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky); - let _ = std::fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&dir); assert!(!skip.unwrap()); assert!(force.unwrap()); assert_eq!(prompt_script::prompts_asked(), 0); diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index d776fd0..98a547c 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1860,12 +1860,6 @@ fn print_plan_summary(plan: &InstallPlan) { /// Bundle-shipped hook scripts execute on the user's machine once wired into /// config, so every planned file landing in a hook location is called out /// individually. Visibility only: installing them needs no extra approval. -/// -/// Only the locations the engine actually resolves hooks from are announced: -/// the top-level hooks category, direct children of `agents//hooks/`, -/// and direct children of `roles/hooks/`. A `hooks/` directory anywhere else -/// (e.g. inside a skill or macro subtree) is inert data and announcing it -/// would be noise. fn hook_script_lines(plan: &InstallPlan) -> Vec { plan.files .iter() @@ -2864,9 +2858,6 @@ mod tests { file("x/config.yaml", TopCategory::Agents), file("reviewer.md", TopCategory::Roles), file("hello.yaml", TopCategory::Macros), - // Only the locations the engine resolves hooks from announce: - // a hooks/ directory nested deeper or under another category - // is inert data. file("x/hooks/nested/deep.sh", TopCategory::Agents), file("x/sub/hooks/pre.sh", TopCategory::Agents), file("sub/hooks/extra.sh", TopCategory::Roles), diff --git a/src/config/macros.rs b/src/config/macros.rs index ebee6a9..5fc7dfd 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -83,11 +83,6 @@ pub async fn macro_execute( .bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .await?; - // An in-macro `.agent` step opens the top-level agent bracket on this - // fresh context, so it must also close here — on the step-failure path - // too, or the run would leak `agent.started` with no terminal event. - // Signal-aware like the headless dispatch arms: a ctrl-c'd macro - // classifies as interrupted, not failed. let result = async { for step in ¯o_value.steps { let command = Macro::interpolate_command(step, &variables); diff --git a/src/config/mod.rs b/src/config/mod.rs index c87a6b0..0491e4e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -419,9 +419,6 @@ impl Default for Config { } pub fn install_builtins() -> Result<()> { - // Best-effort housekeeping riding along with the startup installers: - // payload files orphaned by crashed processes accumulate in a shared - // temp dir and nothing else ever revisits them. hooks::sweep_stale_payload_files(); Functions::install_builtin_global_tools(false)?; Agent::install_builtin_agents(InstallMode::Skip)?; @@ -530,9 +527,6 @@ pub fn install_assets(category: AssetCategory) -> Result<()> { AssetCategory::Functions => Functions::install_builtin_global_tools(true)?, AssetCategory::Skills => Skill::install_builtin_skills(true)?, AssetCategory::Hooks => { - // One sticky scope spans all three hook locations, so a - // keep-all/replace-all answer carries through the whole refresh, - // matching bundle-install semantics. let mut sticky = StickyMode::None; hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?; Role::install_builtin_role_hooks(InstallMode::Prompt, &mut sticky)?; @@ -759,8 +753,6 @@ impl Config { if env::var_os(SANDBOX_ENV_FLAG).is_some() { if !config_path.exists() { - // Inspection-only flags are look-don't-touch: no first-run - // config creation, nothing written. if inspection { return Ok(Self::default()); } @@ -778,9 +770,6 @@ impl Config { { Some(v) => (Self::load_dynamic(&v)?, String::new()), None => { - // Same look-don't-touch rule: never run the interactive - // first-run wizard (or write a config) for a flag that - // only inspects state. if inspection { return Ok(Self::default()); } @@ -1655,12 +1644,12 @@ hooks: use std::fs; use std::time; - let unique = time::SystemTime::now() + let unique = SystemTime::now() .duration_since(time::UNIX_EPOCH) .unwrap() .as_nanos(); - let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}")); - fs::create_dir_all(&tmp_dir).unwrap(); + let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}")); + create_dir_all(&tmp_dir).unwrap(); let config_path = tmp_dir.join("config.yaml"); fs::write( @@ -1670,12 +1659,12 @@ hooks: .unwrap(); let config_env = get_env_name("config_file"); - let prev_config = std::env::var_os(&config_env); - let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG); + let prev_config = env::var_os(&config_env); + let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG); unsafe { - std::env::set_var(&config_env, &config_path); - std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1"); + env::set_var(&config_env, &config_path); + env::set_var(SANDBOX_ENV_FLAG, "1"); } let result = Config::load_with_interpolation(false, false).await; @@ -1683,12 +1672,12 @@ hooks: unsafe { match prev_config { - Some(v) => std::env::set_var(&config_env, v), - None => std::env::remove_var(&config_env), + Some(v) => env::set_var(&config_env, v), + None => env::remove_var(&config_env), } match prev_sandbox { - Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v), - None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG), + Some(v) => env::set_var(SANDBOX_ENV_FLAG, v), + None => env::remove_var(SANDBOX_ENV_FLAG), } } let _ = fs::remove_dir_all(&tmp_dir); @@ -1713,17 +1702,17 @@ hooks: create_dir_all(&tmp_dir).unwrap(); let _config_dir = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &tmp_dir); let _config_file = crate::testing::EnvVarGuard::unset(get_env_name("config_file")); - let _sandbox = crate::testing::EnvVarGuard::unset(crate::sandbox::SANDBOX_ENV_FLAG); + let _sandbox = crate::testing::EnvVarGuard::unset(SANDBOX_ENV_FLAG); let _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider")); let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform")); let result = Config::load_with_interpolation(true, true).await; - let leftover: Vec<_> = std::fs::read_dir(&tmp_dir) + let leftover: Vec<_> = read_dir(&tmp_dir) .unwrap() .map(|entry| entry.unwrap().file_name()) .collect(); - let _ = std::fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&tmp_dir); result.expect("inspection flags must load a default config without a config file"); assert!( leftover.is_empty(), @@ -1743,12 +1732,12 @@ hooks: let config_path = tmp_dir.join("config.yaml"); let _config_file = crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path); - let _sandbox = crate::testing::EnvVarGuard::set(crate::sandbox::SANDBOX_ENV_FLAG, "1"); + let _sandbox = crate::testing::EnvVarGuard::set(SANDBOX_ENV_FLAG, "1"); let result = Config::load_with_interpolation(true, true).await; let created = config_path.exists(); - let _ = std::fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&tmp_dir); result.expect("inspection flags must load a default config in sandbox mode too"); assert!( !created, @@ -1835,7 +1824,7 @@ hooks: let written = write_models_override(vec![entry()]).unwrap(); assert_eq!(written, path); let mut tmp_name = path.as_os_str().to_os_string(); - tmp_name.push(format!(".{}.tmp", std::process::id())); + tmp_name.push(format!(".{}.tmp", process::id())); assert!(!PathBuf::from(tmp_name).exists()); let loaded = paths::local_models_override().unwrap(); assert_eq!(loaded.len(), 1); @@ -1910,16 +1899,16 @@ hooks: assert_eq!(prompt_script::prompts_asked(), 1); assert_eq!(sticky, StickyMode::ReplaceAll); assert!( - fs::read_to_string(¬ify) + read_to_string(¬ify) .unwrap() .starts_with("#!/usr/bin/env bash") ); assert_eq!( - fs::read_to_string(role_dir.join("hook.sh")).unwrap(), + read_to_string(role_dir.join("hook.sh")).unwrap(), "shipped\n" ); assert_eq!( - fs::read_to_string(agent_dir.join("hook.sh")).unwrap(), + read_to_string(agent_dir.join("hook.sh")).unwrap(), "shipped\n" ); drop(script); @@ -1931,13 +1920,13 @@ hooks: refresh(&mut sticky); assert_eq!(prompt_script::prompts_asked(), 1); assert_eq!(sticky, StickyMode::KeepAll); - assert_eq!(fs::read_to_string(¬ify).unwrap(), "local global"); + assert_eq!(read_to_string(¬ify).unwrap(), "local global"); assert_eq!( - fs::read_to_string(role_dir.join("hook.sh")).unwrap(), + read_to_string(role_dir.join("hook.sh")).unwrap(), "local role" ); assert_eq!( - fs::read_to_string(agent_dir.join("hook.sh")).unwrap(), + read_to_string(agent_dir.join("hook.sh")).unwrap(), "local agent" ); drop(script); @@ -1958,12 +1947,12 @@ hooks: let script = prompt_script::install(&[]); install_builtins().unwrap(); assert_eq!(prompt_script::prompts_asked(), 0); - assert_eq!(fs::read_to_string(¬ify).unwrap(), "# local edit"); + assert_eq!(read_to_string(¬ify).unwrap(), "# local edit"); drop(script); let _script = prompt_script::install_non_interactive(); install_builtins().unwrap(); assert_eq!(prompt_script::prompts_asked(), 0); - assert_eq!(fs::read_to_string(¬ify).unwrap(), "# local edit"); + assert_eq!(read_to_string(¬ify).unwrap(), "# local edit"); } } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index a495047..0f791a3 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -417,8 +417,6 @@ impl RequestContext { working_mode: WorkingMode, info_flag: bool, ) -> Result { - // A lenient AppConfig (inspection-only run, no resolvable model) - // leaves model_id empty; render those readouts with a default model. let model = if info_flag && app.config.model_id.is_empty() { Model::default() } else { @@ -4971,15 +4969,6 @@ impl RequestContext { return; }; - // The abort signal, never the error text, decides interrupted: a - // ctrl-c'd run is not a failure, so no error env rides along. The - // gate is deliberately signal-first -- a latched ctrl-c means - // interrupted even with no error in hand -- unlike - // SpawnResultHooks::fire, which is error-first: a child that finished - // cleanly stays agent.completed under a latched signal because its - // output is collectible. Blind spot: a mid-stream ctrl-d aborts live - // work via poll_abort_signal yet still classifies as agent.completed, - // because ctrl-d always means a deliberate exit (accepted). if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) { hooks::fire( HookEvent::AgentInterrupted, @@ -10449,7 +10438,7 @@ mod tests { // test deadlines. Every spawned script gets its own throwaway lock path. fn adversary_script_command(script: &str, state: &serde_json::Value) -> Command { static STACK_LOCK_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("assets/agents/adversary/scripts") .join(script); let lock_path = env::temp_dir().join(format!( diff --git a/src/function/agents.rs b/src/function/agents.rs index ac19b3a..e1b92fe 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -1026,13 +1026,6 @@ impl SpawnResultHooks { } } - /// Fires `agent.completed`, or — for a failed child — `agent.interrupted` - /// when its abort signal was ctrl-c'd (REPL ctrl-c or `agent__cancel`), - /// else `agent.failed`. The signal, never the error text, decides: - /// cancellation is not a failure, so no error env rides along. Unlike the - /// signal-first `top_level_agent_finished` gate, completion wins over a - /// latched ctrl-c at this seam — a finished child's output is collectible, - /// so the asymmetry is intentional. fn fire(self, error: Option<&str>, child_abort: &AbortSignal) { let mut extras = vec![ ("COYOTE_AGENT_ID", self.agent_id), @@ -1783,13 +1776,6 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result String { +fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &Value) -> String { if *NO_COLOR { return format!("Call {cmd_name} {}", cmd_args.join(" ")); } @@ -2713,8 +2713,8 @@ fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json:: ) } -fn format_json_colored_keys(value: &serde_json::Value) -> String { - let serde_json::Value::Object(map) = value else { +fn format_json_colored_keys(value: &Value) -> String { + let Value::Object(map) = value else { return dimmed_text(&value.to_string()); }; if map.is_empty() { @@ -3486,16 +3486,15 @@ mod tests { #[test] fn bundled_bash_tools_generate_declarations() { - let tools_dir = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools"); + let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools"); let mut checked = Vec::new(); - for entry in std::fs::read_dir(&tools_dir).unwrap() { + for entry in fs::read_dir(&tools_dir).unwrap() { let path = entry.unwrap().path(); if path.extension().and_then(OsStr::to_str) != Some("sh") { continue; } let name = path.file_stem().unwrap().to_string_lossy().to_string(); - let src = std::fs::read_to_string(&path).unwrap(); + let src = fs::read_to_string(&path).unwrap(); let (_, declarations) = bash::build_bash_tool(&src, &name) .unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}")); assert!( @@ -4673,7 +4672,7 @@ mod tests { .unwrap() .expect("nonzero exit must return an error payload"); - let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let json: Value = serde_json::from_str(&result).unwrap(); assert!( json["tool_call_error"] .as_str() @@ -4702,7 +4701,7 @@ mod tests { .unwrap() .expect("nonzero exit must return an error payload"); - let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let json: Value = serde_json::from_str(&result).unwrap(); assert!( json["tool_call_error"] .as_str() @@ -4741,7 +4740,7 @@ mod tests { let elapsed = started.elapsed(); assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}"); - let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let json: Value = serde_json::from_str(&result).unwrap(); assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted"); } @@ -4826,18 +4825,15 @@ mod tests { // A shim written moments ago can still be open for writing somewhere // in the process (schedule-dependent), making exec fail with // ETXTBSY; retry briefly instead of flaking. - fn output_with_etxtbsy_retry( - command: &mut process::Command, - ) -> std::io::Result { + fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result { let mut attempts = 0; loop { match command.output() { Err(err) - if err.kind() == std::io::ErrorKind::ExecutableFileBusy - && attempts < 10 => + if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 => { attempts += 1; - std::thread::sleep(Duration::from_millis(50)); + thread::sleep(Duration::from_millis(50)); } ret => return ret, } @@ -4907,7 +4903,7 @@ mod tests { return; } - let mut command = process::Command::new(&moved_shim); + let mut command = Command::new(&moved_shim); command .arg("{}") .env_remove(&config_env) diff --git a/src/function/user_interaction.rs b/src/function/user_interaction.rs index 6281fd1..11f9fd7 100644 --- a/src/function/user_interaction.rs +++ b/src/function/user_interaction.rs @@ -180,8 +180,6 @@ fn handle_headless(action: &str, args: &Value) -> Value { }) } -// The root agent prompting its own user is a direct interaction, not an -// escalation: no escalation event fires on this path. fn handle_direct(action: &str, args: &Value) -> Result { match action { "select" => handle_direct_ask(args), diff --git a/src/graph/agent.rs b/src/graph/agent.rs index 39cdb7e..029198c 100644 --- a/src/graph/agent.rs +++ b/src/graph/agent.rs @@ -50,14 +50,6 @@ impl AgentNodeExecutor { ) .await; if let Err(e) = &result { - // The child's own abort signal dies inside `run`; the session - // signal is what a user interrupt fires (the executor bridges it - // onto the graph abort), so it is the only honest - // interrupted-vs-failed discriminator at this seam. Blind spot: - // agent__cancel sets the child handle's signal, which never - // reaches parent_ctx.session_abort, so a node failing during - // such a cancellation stays agent.failed while the outer bracket - // reports agent.interrupted (narrow window, accepted). if parent_ctx .session_abort .as_ref() diff --git a/src/graph/user_interaction.rs b/src/graph/user_interaction.rs index 85d1d22..9349e0c 100644 --- a/src/graph/user_interaction.rs +++ b/src/graph/user_interaction.rs @@ -21,10 +21,6 @@ impl ApprovalNodeExecutor { .interpolate(&node.question) .context("Failed to interpolate approval question")?; - // No escalation event fires at this seam: a root graph prompts its - // user directly (outside the escalation queue), and a child graph's - // escalation fires inside `handle_escalated` like any other user__* - // call. let response = handle_user_tool( ctx, &format!("{USER_FUNCTION_PREFIX}select"), diff --git a/src/hooks.rs b/src/hooks.rs index 08bcad6..efb19c6 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -14,7 +14,7 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::sync::Notify; @@ -60,16 +60,10 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu } } - // Only direct children of the hooks dir reconcile through the builtin - // manifest — the only shape it tracks — so a hook a release stops - // shipping is removed while user scripts in the same directory are - // never touched. let shipped: BTreeSet = HookAssets::iter() .map(|file| file.as_ref().to_string()) .filter(|name| !name.contains('/')) .collect(); - // Reconciliation is best-effort housekeeping: a failure here must not - // abort startup (install_builtins), matching the role/agent-side policy. if let Err(err) = builtin_manifest::reconcile_builtin_dir(&paths::hooks_dir(), &shipped, &written) { @@ -79,8 +73,6 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu ); } - // Both sides canonicalize so an override that merely spells the default - // path differently (symlinks, `..`, trailing components) does not warn. let hooks_dir = canonical_or_original(&paths::hooks_dir()); let default_hooks_dir = canonical_or_original(&paths::config_dir().join("hooks")); if wrote_any && hooks_dir != default_hooks_dir { @@ -174,9 +166,6 @@ impl HookEvent { } } - /// Every event, in declaration order. Dotted names stay single-sourced - /// through `as_str`; this exists so whitelist-glob validation can tell a - /// real `.*` from a typo over a nonexistent event. const ALL: [HookEvent; 26] = [ HookEvent::TurnStarted, HookEvent::TurnCompleted, @@ -260,12 +249,6 @@ fn resolve_hooks( let event_name = event.as_str(); let mut resolved = Vec::new(); - // Escalations are a root/user-level concern: the user wires these hooks - // to hear about children asking for help, so requiring every agent to - // whitelist them through `global_hooks` would silence exactly the events - // they were set up for. The per-agent gate never applies here (this also - // skips the unknown-entry diagnostics: whitelisting an escalation hook is - // harmless and needs no warning). let agent_gate = match event { HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None, _ => agent_gate, @@ -296,12 +279,6 @@ fn resolve_hooks( { let prefix = format!("{event_name}."); for entry in gate { - // Entries containing `*` are grammatical only in the wildcard - // forms `*`, `*.*`, `*.`, and `.*` over a real - // event. Anything else (`tool.st*`, `ag*.completed.mark`) is a - // typo that admits nothing on any event and never extends a full - // event name, so the prefix check below would stay silent about - // it — flag it here instead. if entry.contains('*') { if !entry_is_valid_wildcard(entry) { debug!( @@ -341,12 +318,6 @@ fn resolve_hooks( resolved } -/// Whitelist grammar for a `global_hooks` entry: `.` exact, -/// `.*` (every hook of that event), `*.` (that hook name on -/// every event), and `*` / `*.*` (everything). Event names contain dots -/// while hook names never do, so `*.` compares everything after the -/// leading `*.` against the hook name alone. Anything else containing `*` -/// (a partial glob like `tool.st*`) is not a wildcard and admits nothing. fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: &str) -> bool { if entry == full_name || entry == "*" || entry == "*.*" { return true; @@ -357,12 +328,6 @@ fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: entry.strip_suffix(".*") == Some(event_name) } -/// True when a `*`-containing gate entry is one of the wildcard forms -/// `gate_entry_admits` understands. `*.` takes a literal hook name -/// (globbing inside the name is not supported), and `.*` requires a -/// real event: event names contain dots, so e.g. `tool.*` names the -/// nonexistent event `tool` rather than a prefix over `tool.started` and -/// `tool.completed`. fn entry_is_valid_wildcard(entry: &str) -> bool { if entry == "*" || entry == "*.*" { return true; @@ -520,11 +485,6 @@ fn base_envs(event: HookEvent, ctx: &RequestContext) -> Vec<(String, String)> { ) } -/// The single construction site for the base env set every hook receives. -/// [`base_envs`] feeds it from a live context; detached call sites that -/// dispatch pre-resolved snapshots after their context is gone pass the -/// names they captured with the snapshot. The timestamp is taken here, at -/// fire time, so it reflects when the event actually happened. pub(crate) fn base_envs_parts( event: HookEvent, session_name: Option<&str>, @@ -596,12 +556,6 @@ fn payload_dir() -> PathBuf { const STALE_PAYLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); -/// Removes payload files left in the shared payload directory by crashed -/// processes — `run_hook` deletes its own file after the hook exits, so -/// anything older than [`STALE_PAYLOAD_MAX_AGE`] is an orphan. Runs once per -/// startup from `install_builtins`. Only exact `coyote-hook-*.json` names are -/// candidates; the directory is shared, so nothing else may ever be touched. -/// Best-effort: failures log at debug and never abort startup. pub fn sweep_stale_payload_files() { sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE); } @@ -618,7 +572,7 @@ fn sweep_payload_files_older_than(max_age: Duration) { return; } }; - let now = std::time::SystemTime::now(); + let now = SystemTime::now(); for entry in entries.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { @@ -643,6 +597,7 @@ fn sweep_payload_files_older_than(max_age: Duration) { if !stale { continue; } + match std::fs::remove_file(entry.path()) { Ok(()) => debug!("Removed stale hook payload file {}", entry.path().display()), Err(err) => debug!( @@ -840,9 +795,11 @@ mod tests { use super::*; use crate::config::conflict::prompt_script; use crate::config::{AppConfig, AppState, Role, WorkingMode}; + use crate::{testing, utils}; use serial_test::serial; use std::env; use std::sync::Arc; + use std::time::UNIX_EPOCH; fn hooks_map(event: &str, defs: &[(&str, &str)]) -> HooksMap { let defs = defs @@ -871,22 +828,19 @@ mod tests { /// Points the hooks dir at a fresh temp directory for the guard's /// lifetime and removes it on drop. Tests using it must serialize. struct HooksDirGuard { - _env: crate::testing::EnvVarGuard, + _env: testing::EnvVarGuard, root: PathBuf, } impl HooksDirGuard { fn new(label: &str) -> Self { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); Self { - _env: crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("hooks_dir"), - &root, - ), + _env: testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &root), root, } } @@ -938,8 +892,8 @@ mod tests { } fn fresh_payload_dir(label: &str) -> PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let dir = env::temp_dir().join(format!("coyote-{label}-{unique}")); @@ -1026,13 +980,13 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_installs_executable_scripts_and_honors_force() { - let env_name = crate::utils::get_env_name("hooks_dir"); - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let env_name = utils::get_env_name("hooks_dir"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}")); - let env_guard = crate::testing::EnvVarGuard::set(&env_name, &root); + let env_guard = testing::EnvVarGuard::set(&env_name, &root); // Capture every outcome first and assert only after cleanup: the // guard restores the env var even on panic, but the temp dir removal @@ -1222,16 +1176,16 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() { - crate::testing::install_log_collector(); - let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-respelled"); + testing::install_log_collector(); + let guard = testing::TestConfigDirGuard::new("hooks-warn-respelled"); let guard_path_display = guard.path.display().to_string(); - let env_name = crate::utils::get_env_name("hooks_dir"); + let env_name = utils::get_env_name("hooks_dir"); // An override that reaches the default `config_dir()/hooks` through a // `..` hop is the default path in disguise: the canonicalized compare // must stay quiet even though the strings differ. let alias = guard.path.join("hooks").join("..").join("hooks"); - let env_guard = crate::testing::EnvVarGuard::set(&env_name, &alias); + let env_guard = testing::EnvVarGuard::set(&env_name, &alias); install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); drop(env_guard); @@ -1249,7 +1203,7 @@ mod tests { let link = guard.path.join("hooks-link"); std::os::unix::fs::symlink(guard.path.join("hooks"), &link).unwrap(); std::fs::remove_file(&default_notify).unwrap(); - let _env_guard = crate::testing::EnvVarGuard::set(&env_name, &link); + let _env_guard = testing::EnvVarGuard::set(&env_name, &link); install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); assert!( default_notify.is_file(), @@ -1259,7 +1213,7 @@ mod tests { // The warn buffer is process-global, so scope the check to messages // naming this test's unique directory. - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!( warns.iter().all(|message| { !(message.contains("overrides the hooks dir") @@ -1272,13 +1226,10 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_genuine_override_warns_when_default_dir_is_missing() { - crate::testing::install_log_collector(); - let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-genuine"); + testing::install_log_collector(); + let guard = testing::TestConfigDirGuard::new("hooks-warn-genuine"); let override_dir = guard.path.join("elsewhere-hooks"); - let _env_guard = crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("hooks_dir"), - &override_dir, - ); + let _env_guard = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &override_dir); install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); @@ -1294,7 +1245,7 @@ mod tests { "scripts must land in the override dir, so wrote_any was true" ); let override_display = override_dir.display().to_string(); - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!( warns.iter().any(|message| { message.contains("overrides the hooks dir") && message.contains(&override_display) @@ -1683,7 +1634,7 @@ mod tests { #[test] fn wildcard_whitelist_entries_skip_unknown_entry_diagnostics() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec![ "turn.started.*".to_string(), @@ -1701,7 +1652,7 @@ mod tests { ); assert_eq!(names(&resolved), ["a"]); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -1712,7 +1663,7 @@ mod tests { #[test] fn partial_glob_whitelist_entries_stay_flagged_as_unknown() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec!["turn.started.st*-marker-k4w".to_string()]; @@ -1725,7 +1676,7 @@ mod tests { ); assert!(resolved.is_empty(), "an invalid glob must admit nothing"); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("turn.started.st*-marker-k4w") && message.contains("for agent 'glob-flag-agent-k4w'") @@ -1734,7 +1685,7 @@ mod tests { #[test] fn partial_glob_over_event_prefix_logs_invalid_glob_diagnostic() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("tool.started", &[("st", "cmd-st")]); // `tool.st*` extends no full event name (`tool` is not an event), so // the event-prefix diagnostic can never see it; the glob check must @@ -1750,7 +1701,7 @@ mod tests { ); assert!(resolved.is_empty(), "a partial glob admits nothing"); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("tool.st*") && message.contains("for agent 'prefix-glob-agent-t7c'") })); @@ -1758,7 +1709,7 @@ mod tests { #[test] fn inner_glob_entries_log_invalid_glob_diagnostic() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("agent.completed", &[("mark", "cmd-mark")]); let gate = vec!["ag*.completed.mark".to_string()]; @@ -1771,7 +1722,7 @@ mod tests { ); assert!(resolved.is_empty(), "an inner glob admits nothing"); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("ag*.completed.mark") && message.contains("for agent 'inner-glob-agent-m2r'") @@ -1780,7 +1731,7 @@ mod tests { #[test] fn unknown_whitelist_entries_log_debug_and_stay_inert() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec![ "turn.started.a".to_string(), @@ -1796,7 +1747,7 @@ mod tests { ); assert_eq!(names(&resolved), ["a"]); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("turn.started.nonexistent-marker-xyz") && message.contains("for agent 'gate-agent-xyz'") @@ -1805,7 +1756,7 @@ mod tests { #[test] fn empty_global_map_short_circuits_whitelist_diagnostics() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = HooksMap::default(); let role = hooks_map("turn.started", &[("role-only", "role-cmd")]); let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()]; @@ -1819,7 +1770,7 @@ mod tests { ); assert_eq!(names(&resolved), ["role-only"]); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -2084,12 +2035,12 @@ mod tests { #[tokio::test(start_paused = true)] #[serial] async fn drain_pending_logs_when_the_timeout_expires() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let pending = SpawnAckGuard::register(); drain_pending(Duration::from_millis(50)).await; - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -2116,7 +2067,7 @@ mod tests { #[test] #[serial] fn fire_resolved_without_runtime_skips_instead_of_panicking() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let hook = ResolvedHook { name: "no-runtime-marker-a7c".to_string(), full_name: "turn.failed.no-runtime-marker-a7c".to_string(), @@ -2126,7 +2077,7 @@ mod tests { fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("no-runtime-marker-a7c") && message.contains("no Tokio runtime on this thread") @@ -2136,7 +2087,7 @@ mod tests { #[test] #[serial] fn fire_without_runtime_skips_instead_of_panicking() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let ctx = ctx_with_global_hooks(hooks_map( "turn.failed", &[("fire-no-runtime-marker-b8d", "true")], @@ -2144,7 +2095,7 @@ mod tests { fire(HookEvent::TurnFailed, &ctx, &[], None); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("fire-no-runtime-marker-b8d") && message.contains("no Tokio runtime on this thread") @@ -2153,7 +2104,7 @@ mod tests { #[test] fn debug_capture_excludes_non_hooks_targets() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let hook = ResolvedHook { name: "included-marker-k2v".to_string(), full_name: "turn.failed.included-marker-k2v".to_string(), @@ -2171,7 +2122,7 @@ mod tests { ); fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -2192,9 +2143,9 @@ mod tests { #[test] #[serial] fn debug_capture_recovers_after_buffer_poisoning() { - crate::testing::install_log_collector(); + testing::install_log_collector(); std::thread::spawn(|| { - let _held = crate::testing::debug_messages().lock().unwrap(); + let _held = testing::debug_messages().lock().unwrap(); panic!("poison the debug buffer"); }) .join() @@ -2208,7 +2159,7 @@ mod tests { }; fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let mut debugs = crate::testing::debug_messages() + let mut debugs = testing::debug_messages() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); assert!(debugs.iter().any(|message| { @@ -2255,7 +2206,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn spawn_failure_removes_the_payload_file() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let guard = TestConfigDirGuard::new("hooks-dispatch"); let payload_dir = guard.path.join("payloads"); create_dir_all(&payload_dir).unwrap(); @@ -2281,12 +2232,12 @@ mod tests { // does the empty directory below demonstrate the orphan unlink // rather than a payload file that never existed. wait_for("spawn failure log", || { - crate::testing::debug_snapshot().iter().any(|message| { + testing::debug_snapshot().iter().any(|message| { message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") }) }) .await; - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().all(|message| { !(message.contains("spawnfail-marker-c9d") && message.contains("Failed to write payload file")) @@ -2305,10 +2256,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn error_paths_log_debug_only() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let empty_cwd = env::temp_dir(); - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let bad_cwd = env::temp_dir().join(format!("coyote-badcwd-marker-f5b-{unique}")); @@ -2330,7 +2281,7 @@ mod tests { fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None); drain_pending(Duration::from_secs(10)).await; - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); let empty_cwd_display = empty_cwd.display().to_string(); assert!(debugs.iter().any(|message| { message.contains("empty-marker-f5b") @@ -2342,7 +2293,7 @@ mod tests { message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'") && message.contains(bad_cwd_display.as_str()) })); - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); } } @@ -2488,7 +2439,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn failing_hook_never_disturbs_the_engine() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let guard = TestConfigDirGuard::new("hooks-dispatch"); let first = guard.path.join("first-out"); let second = guard.path.join("second-out"); @@ -2514,7 +2465,7 @@ mod tests { ); wait_for("second hook output", || second.exists()).await; - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!( warns .iter() @@ -2558,7 +2509,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_write_failure_still_runs_hook_without_payload_env() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let guard = TestConfigDirGuard::new("hooks-dispatch"); let out = guard.path.join("env-out"); let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir")); @@ -2582,7 +2533,7 @@ mod tests { .lines() .all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE=")) ); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| message.contains( "Failed to write payload file for hook 'tool.started.payload-fallback-marker'" ))); @@ -2674,7 +2625,7 @@ mod tests { ) .unwrap(); - ctx.use_agent(&app, agent_name, None, crate::utils::create_abort_signal()) + ctx.use_agent(&app, agent_name, None, utils::create_abort_signal()) .await .unwrap(); @@ -2818,18 +2769,15 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_lands_under_a_backslashed_override() { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let hooks_dir = PathBuf::from(format!( "{}\\coyote-win-bs-{unique}\\hooks", env::temp_dir().display() )); - let _env = crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("hooks_dir"), - &hooks_dir, - ); + let _env = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &hooks_dir); let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None); diff --git a/src/main.rs b/src/main.rs index fda9c6e..77b2d0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,15 +58,6 @@ use std::path::PathBuf; use std::sync::atomic::Ordering; use std::{env, fs, process, sync::Arc}; -/// Stack headroom for the thread that polls the root async future, and for -/// the runtime's worker threads. -/// -/// Windows links the process main thread with a ~1 MiB stack (unix defaults -/// to 8 MiB), and the root future is a compiler-generated state machine -/// whose debug-build poll frames blow through 1 MiB the moment it is -/// polled — every binary invocation dies with STATUS_STACK_OVERFLOW -/// (0xC00000FD) before doing any work. 16 MiB gives comfortable headroom -/// over the deepest observed frames on every platform. const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024; /// Bootstraps the async entry point on a dedicated thread with an explicit @@ -151,10 +142,6 @@ async fn async_main() -> Result<()> { || cli.list_macros || cli.list_skills || cli.list_sessions; - // Read-only whitelist: --sync-models is excluded because it downloads - // and writes models-override.yaml; combined with a readout flag the run - // still writes (run() handles --sync-models first), so it disqualifies - // the whole invocation. let readout_flag = (cli.info || cli.list_models || cli.list_roles @@ -191,10 +178,6 @@ async fn async_main() -> Result<()> { return config::list_installed_bundles(); } - // Inspection flags are look-don't-touch readouts: skip the builtins - // bootstrap so a pristine config dir stays empty. On such a dir the - // list flags list nothing because nothing is installed, by design. - // Agent/role runs still bootstrap: they load builtin tool definitions. let mcp_inspect = cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none(); let vault_inspect = cli.list_secrets @@ -297,10 +280,6 @@ async fn async_main() -> Result<()> { } else { AppConfig::from_config(cfg)? }; - // Listing servers reads no secrets, so an unconfigured vault (no - // Listing servers reads no secrets, so an unconfigured vault (no - // password file) must not block the readout; every other MCP flag - // (get/remove/add) stays on the strict init. let vault = if mcp_inspect { Vault::init_lenient(&app_config) } else { @@ -351,9 +330,6 @@ async fn async_main() -> Result<()> { ); let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?; let app_config = Arc::clone(&ctx.app.config); - // Rebuilding the tool scope prunes and builds function binaries; - // inspection-only runs are readouts that never execute tools, so skip - // it there too. if !inspection_only { ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone()) .await?; @@ -488,7 +464,7 @@ async fn run( // return early without running the agent, and an open bracket on // those paths would leak `agent.started` with no terminal event. // It opens at the dispatch sites further down, where a run is - // actually committed — including the --macro arm, which executes + // actually committed, including the --macro arm, which executes // rather than inspects. } else { let app: Arc = Arc::clone(&ctx.app.config); @@ -622,8 +598,6 @@ async fn run( } } if let Some(name) = &cli.macro_name { - // Dispatch committed: open the top-level agent bracket only now, - // past every inspection-flag early return above. ctx.top_level_agent_started(); let result = macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await; ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal)); @@ -807,9 +781,6 @@ async fn shell_execute( if code == 0 && app.save_shell_history { let _ = append_to_shell_history(&shell.name, &eval_str, code); } - // Executing the generated command is a normal exit: no - // abort signal on purpose, this seam can never report an - // interruption. ctx.top_level_agent_finished(None, None); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; process::exit(code); diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 218962d..58ab8b5 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -527,9 +527,6 @@ Type ".help" for additional help. } if let Some(supervisor) = self.ctx.read().supervisor.clone() { - // Leaving the loop is a deliberate exit, never an interruption: - // cancelling straggler children here does not latch the abort - // signal, so teardown still classifies as agent.completed. supervisor.read().cancel_recursive(); } @@ -1718,13 +1715,6 @@ fn reset_continuation(ctx: &mut RequestContext) { ctx.reset_continuation_count(); } -/// Ctrl-C at the prompt: cancel background work, but only latch the abort -/// signal when there was live work to cancel. An idle-prompt ctrl-c is a -/// step on the advertised exit path ("To exit, press Ctrl+D"), not an -/// interruption, so teardown after the following ctrl-d classifies the run -/// as completed. Mid-turn ctrl-c never passes through here -- it latches -/// the signal directly (wait_user_interrupt / poll_abort_signal), so an -/// interrupted turn followed by ctrl-d still reports agent.interrupted. pub(crate) fn latch_prompt_interrupt(abort_signal: &AbortSignal, supervisor: &RwLock) { let supervisor = supervisor.read(); if supervisor.has_active_tasks() { diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs index 015b2e9..2c0fe64 100644 --- a/src/sandbox/mixins.rs +++ b/src/sandbox/mixins.rs @@ -197,11 +197,6 @@ pub fn discover() -> Result> { ) { out.push(read_mixin(path)?); } - // Hooks directories are deliberately scanned: hook scripts run inside the - // sandbox too, so a sidecar mixin next to one can declare its needs. The - // agents scan above never descends into `agents//hooks/`, and the - // `.builtin-manifest` living in these dirs is not matched by the - // `*.sbx-mixin.yaml` suffix filter. for agent_dir in subdirs_of(&paths::agents_data_dir()) { for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) { out.push(read_mixin(path)?); diff --git a/src/supervisor/mod.rs b/src/supervisor/mod.rs index 7028637..0a6be9f 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -190,11 +190,6 @@ impl Supervisor { .count() } - /// True when any registered agent or job is still running -- i.e. a - /// cancellation right now would actually interrupt in-flight work. - /// Mirrors `cancel_recursive`'s traversal: work buried in a nested child - /// supervisor still counts, so a prompt ctrl-c that cancels only a - /// grandchild latches like any other interruption. pub fn has_active_tasks(&self) -> bool { self.effective_active_count() > 0 || self.active_job_count() > 0 diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 4d187f2..82a9c78 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -332,8 +332,6 @@ impl Vault { } if cli.list_secrets { - // Listing is an inspection readout: report sandbox unavailability - // instead of failing like the mutating vault flags do. if vault.sandbox_mode { println!( "The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host."