style: Cleaned up unnecessary path prefixes, imports, stray scripts, and some documentation
This commit is contained in:
26 files changed
+132
-1286
No files matched your search
@@ -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.
|
* [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).
|
* [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.
|
* [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.
|
* [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.
|
* [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.
|
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
|
||||||
|
|||||||
@@ -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 <config_dir>/hooks via the real CLI; a non-interactive re-run
|
|
||||||
# keeps locally modified scripts while still installing missing ones.
|
|
||||||
# D) `coyote --agent <bundled-agent> --info` (an inspection flag) fires ZERO
|
|
||||||
# agent.* hook events.
|
|
||||||
# E) `coyote --install-builtins hooks` reconciles manifest-owned leftovers
|
|
||||||
# in roles/hooks and each bundled agent's hooks dir, sparing user files.
|
|
||||||
# F) top-level hooks-dir manifest reconcile: a stale shipped hook is removed
|
|
||||||
# on reinstall; a malformed manifest deletes NOTHING (fail-safe).
|
|
||||||
# G) a plain startup sweeps manifest-owned hooks of removed builtin agents
|
|
||||||
# while leaving user agent dirs and user files untouched.
|
|
||||||
# H) the INSTALLED bundled scripts behave as documented when run directly:
|
|
||||||
# log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/
|
|
||||||
# (0600, UTC ISO-8601 header, secrets filtered, symlink at the default
|
|
||||||
# path refused) and notify.sh's no-notifier/no-tty fallback strips
|
|
||||||
# ANSI/OSC control bytes; the hooks-dir .builtin-manifest is never
|
|
||||||
# installed executable.
|
|
||||||
# I) `--session <name>` discriminates the session events end-to-end: a
|
|
||||||
# fresh named session fires exactly one session.started (and no
|
|
||||||
# session.resumed); resuming a persisted session fires exactly one
|
|
||||||
# session.resumed (and no session.started).
|
|
||||||
# J) pressing ctrl-c in a real terminal during a live headless --agent run
|
|
||||||
# fires exactly one agent.interrupted with NO COYOTE_AGENT_ERROR, and
|
|
||||||
# never agent.failed or agent.completed (needs python3 for the pty and
|
|
||||||
# the hanging LLM endpoint; skipped when python3 is unavailable).
|
|
||||||
#
|
|
||||||
# Not covered here (see src/function/mod.rs tests for tool.* + payload-file
|
|
||||||
# coverage instead): a live tool.started firing requires a real LLM round
|
|
||||||
# trip that actually decides to call a tool; dry_run's echo_messages() path
|
|
||||||
# never invokes tools, so it cannot be exercised via this recipe.
|
|
||||||
#
|
|
||||||
# Usage: scripts/usage-probe-hooks.sh
|
|
||||||
# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or
|
|
||||||
# ./target/release/coyote (build with `cargo build --bin coyote` first).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
BIN="${COYOTE_BIN:-}"
|
|
||||||
if [ -z "$BIN" ]; then
|
|
||||||
if [ -x "$REPO_ROOT/target/debug/coyote" ]; then
|
|
||||||
BIN="$REPO_ROOT/target/debug/coyote"
|
|
||||||
elif [ -x "$REPO_ROOT/target/release/coyote" ]; then
|
|
||||||
BIN="$REPO_ROOT/target/release/coyote"
|
|
||||||
else
|
|
||||||
echo "No coyote binary found; run 'cargo build --bin coyote' first." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
WORKDIR="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$WORKDIR"' EXIT
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $1" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Bounded poll: retries the given command every 0.1s until it succeeds or the
|
|
||||||
# ~10s deadline expires, then fails loudly. Hooks are fire-and-forget child
|
|
||||||
# processes, so every wait on their side effects goes through this helper —
|
|
||||||
# never a bare sleep.
|
|
||||||
wait_for() {
|
|
||||||
local what="$1"
|
|
||||||
shift
|
|
||||||
local tries=0
|
|
||||||
until "$@"; do
|
|
||||||
tries=$((tries + 1))
|
|
||||||
[ "$tries" -ge 100 ] && fail "timed out waiting for $what"
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
write_dryrun_config() {
|
|
||||||
# $1 = config dir, $2..$n = extra YAML fragment lines appended verbatim
|
|
||||||
local dir="$1"
|
|
||||||
shift
|
|
||||||
cat > "$dir/config.yaml" <<'BASE'
|
|
||||||
model: dryrun:dry-model
|
|
||||||
dry_run: true
|
|
||||||
clients:
|
|
||||||
- type: openai
|
|
||||||
name: dryrun
|
|
||||||
auth: none
|
|
||||||
api_key: 'unused'
|
|
||||||
models:
|
|
||||||
- name: dry-model
|
|
||||||
max_input_tokens: 100000
|
|
||||||
supports_function_calling: true
|
|
||||||
save: false
|
|
||||||
memory: false
|
|
||||||
stream: false
|
|
||||||
BASE
|
|
||||||
if [ "$#" -gt 0 ]; then
|
|
||||||
printf '%s\n' "$@" >> "$dir/config.yaml"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "== Scenario A: turn/llm hook cadence and env scoping =="
|
|
||||||
CFG_A="$WORKDIR/a"
|
|
||||||
mkdir -p "$CFG_A"
|
|
||||||
TS_LOG="$WORKDIR/turn-started.log"
|
|
||||||
TC_LOG="$WORKDIR/turn-completed.log"
|
|
||||||
LC_LOG="$WORKDIR/llm-completed.log"
|
|
||||||
write_dryrun_config "$CFG_A" "hooks:" \
|
|
||||||
" turn.started:" \
|
|
||||||
" - name: probe" \
|
|
||||||
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TS_LOG\"" \
|
|
||||||
" turn.completed:" \
|
|
||||||
" - name: probe" \
|
|
||||||
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TC_LOG\"" \
|
|
||||||
" llm.request.completed:" \
|
|
||||||
" - name: probe-llm" \
|
|
||||||
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT PROVIDER=\$COYOTE_LLM_PROVIDER MODEL=\$COYOTE_LLM_MODEL\\\"; } >> $LC_LOG\""
|
|
||||||
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_A" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
scenario_a_logs_ready() { [ -s "$TS_LOG" ] && [ -s "$TC_LOG" ] && [ -s "$LC_LOG" ]; }
|
|
||||||
wait_for "turn/llm hook logs" scenario_a_logs_ready
|
|
||||||
|
|
||||||
[ "$(grep -c '^EVENT=turn.started' "$TS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one turn.started, got: $(cat "$TS_LOG" 2>/dev/null)"
|
|
||||||
[ "$(grep -c '^EVENT=turn.completed' "$TC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one turn.completed, got: $(cat "$TC_LOG" 2>/dev/null)"
|
|
||||||
[ "$(grep -c '^EVENT=llm.request.completed' "$LC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one llm.request.completed, got: $(cat "$LC_LOG" 2>/dev/null)"
|
|
||||||
grep -q 'PROVIDER=dryrun MODEL=dryrun:dry-model' "$LC_LOG" \
|
|
||||||
|| fail "llm.request.completed missing expected COYOTE_LLM_PROVIDER/MODEL: $(cat "$LC_LOG")"
|
|
||||||
echo "PASS: exactly 1 turn.started + 1 turn.completed + 1 llm.request.completed, correctly scoped"
|
|
||||||
|
|
||||||
echo "== Scenario B: failing/missing-script hooks never affect output or exit code =="
|
|
||||||
CFG_B="$WORKDIR/b"
|
|
||||||
mkdir -p "$CFG_B"
|
|
||||||
write_dryrun_config "$CFG_B" "hooks:" \
|
|
||||||
" turn.completed:" \
|
|
||||||
" - name: failing" \
|
|
||||||
" command: /nonexistent/script/does/not/exist" \
|
|
||||||
" - name: exit1" \
|
|
||||||
" command: \"exit 1\""
|
|
||||||
CFG_BASE="$WORKDIR/base"
|
|
||||||
mkdir -p "$CFG_BASE"
|
|
||||||
write_dryrun_config "$CFG_BASE"
|
|
||||||
|
|
||||||
rc_b=0
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || rc_b=$?
|
|
||||||
rc_base=0
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || rc_base=$?
|
|
||||||
|
|
||||||
[ "$rc_b" = "$rc_base" ] || fail "exit code differs: with-hooks=$rc_b baseline=$rc_base"
|
|
||||||
diff -q "$WORKDIR/out_b.txt" "$WORKDIR/out_base.txt" > /dev/null || fail "stdout differs when a hook fails/is missing"
|
|
||||||
diff -q "$WORKDIR/err_b.txt" "$WORKDIR/err_base.txt" > /dev/null || fail "stderr differs when a hook fails/is missing"
|
|
||||||
echo "PASS: failing/missing-script hooks are invisible to stdout/stderr/exit code"
|
|
||||||
|
|
||||||
echo "== Scenario C: coyote --install-builtins hooks (real CLI, clean config dir) =="
|
|
||||||
CFG_C="$WORKDIR/c"
|
|
||||||
mkdir -p "$CFG_C"
|
|
||||||
|
|
||||||
out_install1="$WORKDIR/install1.out"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$out_install1" 2>&1
|
|
||||||
|
|
||||||
[ -f "$CFG_C/hooks/notify.sh" ] || fail "notify.sh was not installed: $(cat "$out_install1")"
|
|
||||||
[ -f "$CFG_C/hooks/log-events.sh" ] || fail "log-events.sh was not installed: $(cat "$out_install1")"
|
|
||||||
grep -q "Reinstalled bundled hooks" "$out_install1" \
|
|
||||||
|| fail "expected confirmation message, got: $(cat "$out_install1")"
|
|
||||||
|
|
||||||
if command -v stat > /dev/null 2>&1; then
|
|
||||||
mode_notify="$(stat -c '%a' "$CFG_C/hooks/notify.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/notify.sh")"
|
|
||||||
mode_log="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
|
|
||||||
[ "$mode_notify" = "755" ] || fail "notify.sh mode is $mode_notify, expected 755"
|
|
||||||
[ "$mode_log" = "755" ] || fail "log-events.sh mode is $mode_log, expected 755"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Modify one script locally and delete the other, then re-run
|
|
||||||
# --install-builtins hooks: without a terminal the conflict resolution keeps
|
|
||||||
# local files, so the edit must survive while the missing script is
|
|
||||||
# reinstalled from the packaged content.
|
|
||||||
echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh"
|
|
||||||
rm "$CFG_C/hooks/log-events.sh"
|
|
||||||
# The refresh also touches the roles and per-agent hook locations: user files
|
|
||||||
# living there must survive it untouched.
|
|
||||||
AGENT_C="$(ls "$CFG_C/agents" | head -1)"
|
|
||||||
mkdir -p "$CFG_C/roles/hooks" "$CFG_C/agents/$AGENT_C/hooks"
|
|
||||||
echo "mine-role" > "$CFG_C/roles/hooks/user-role.sh"
|
|
||||||
echo "mine-agent" > "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$WORKDIR/install2.out" 2>&1
|
|
||||||
grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.sh" \
|
|
||||||
|| fail "non-interactive reinstall overwrote the locally modified notify.sh"
|
|
||||||
[ -f "$CFG_C/hooks/log-events.sh" ] \
|
|
||||||
|| fail "reinstall did not restore the deleted log-events.sh"
|
|
||||||
grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/log-events.sh" \
|
|
||||||
|| fail "log-events.sh was not restored to the packaged script"
|
|
||||||
if command -v stat > /dev/null 2>&1; then
|
|
||||||
mode_restored="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
|
|
||||||
[ "$mode_restored" = "755" ] || fail "restored log-events.sh mode is $mode_restored, expected 755"
|
|
||||||
fi
|
|
||||||
[ "$(cat "$CFG_C/roles/hooks/user-role.sh")" = "mine-role" ] \
|
|
||||||
|| fail "user file in roles/hooks was touched by the refresh"
|
|
||||||
[ "$(cat "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh")" = "mine-agent" ] \
|
|
||||||
|| fail "user file in the agent hooks dir was touched by the refresh"
|
|
||||||
echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files (all hook locations) and restore missing ones 0755"
|
|
||||||
|
|
||||||
echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces =="
|
|
||||||
CFG_C2="$WORKDIR/c2"
|
|
||||||
mkdir -p "$CFG_C2/hooks"
|
|
||||||
write_dryrun_config "$CFG_C2"
|
|
||||||
|
|
||||||
# Every coyote invocation calls install_builtins() (force=false) before
|
|
||||||
# dispatching any flag, so a plain run on a config dir with no hooks/ yet
|
|
||||||
# must auto-populate it...
|
|
||||||
rmdir "$CFG_C2/hooks" 2>/dev/null || true
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
[ -f "$CFG_C2/hooks/notify.sh" ] || fail "cold-start run did not auto-install notify.sh"
|
|
||||||
|
|
||||||
# ...but once a file exists, the non-force path must NEVER touch it again.
|
|
||||||
echo "# LOCALLY MODIFIED" > "$CFG_C2/hooks/notify.sh"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
grep -q "LOCALLY MODIFIED" "$CFG_C2/hooks/notify.sh" \
|
|
||||||
|| fail "a plain (non --install-builtins) invocation overwrote a locally modified hook script"
|
|
||||||
echo "PASS: plain invocations auto-bootstrap missing hooks but never force-overwrite existing ones"
|
|
||||||
|
|
||||||
echo "== Scenario C3: installed log-events.sh fires end-to-end via the documented relative path =="
|
|
||||||
# Exercises the exact wiring promised in the script headers:
|
|
||||||
# command: ./hooks/log-events.sh (resolves against the config dir)
|
|
||||||
# and the script's own contract: COYOTE_HOOK_LOG override honored, log file
|
|
||||||
# created 0600, COYOTE_SECRET_* values excluded from the snapshot.
|
|
||||||
CFG_C3="$WORKDIR/c3"
|
|
||||||
mkdir -p "$CFG_C3"
|
|
||||||
HOOK_LOG="$WORKDIR/c3-hook.log"
|
|
||||||
write_dryrun_config "$CFG_C3" "hooks:" \
|
|
||||||
" turn.completed:" \
|
|
||||||
" - name: log-events" \
|
|
||||||
" command: ./hooks/log-events.sh"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_C3" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_C3" COYOTE_HOOK_LOG="$HOOK_LOG" COYOTE_SECRET_PROBE=hunter2 \
|
|
||||||
"$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
wait_for "installed log-events.sh output" test -s "$HOOK_LOG"
|
|
||||||
|
|
||||||
[ -f "$HOOK_LOG" ] || fail "installed log-events.sh did not write to COYOTE_HOOK_LOG via relative-path wiring"
|
|
||||||
[ "$(grep -c '^=== ' "$HOOK_LOG")" = "1" ] \
|
|
||||||
|| fail "expected exactly one event block in the hook log, got: $(cat "$HOOK_LOG")"
|
|
||||||
grep -q 'COYOTE_EVENT=turn.completed' "$HOOK_LOG" \
|
|
||||||
|| fail "hook log missing COYOTE_EVENT=turn.completed: $(cat "$HOOK_LOG")"
|
|
||||||
grep -q 'hunter2' "$HOOK_LOG" \
|
|
||||||
&& fail "COYOTE_SECRET_* value leaked into the hook log"
|
|
||||||
grep -q 'COYOTE_SECRET_' "$HOOK_LOG" \
|
|
||||||
&& fail "COYOTE_SECRET_ variable name leaked into the hook log"
|
|
||||||
if command -v stat > /dev/null 2>&1; then
|
|
||||||
mode_hook_log="$(stat -c '%a' "$HOOK_LOG" 2>/dev/null || stat -f '%Lp' "$HOOK_LOG")"
|
|
||||||
[ "$mode_hook_log" = "600" ] || fail "hook log mode is $mode_hook_log, expected 600"
|
|
||||||
fi
|
|
||||||
echo "PASS: relative-path wiring fires the installed log-events.sh; log is 0600 with secrets excluded"
|
|
||||||
|
|
||||||
echo "== Scenario D: --agent <bundled> --info fires zero agent.* events =="
|
|
||||||
CFG_D="$WORKDIR/d"
|
|
||||||
mkdir -p "$CFG_D/agents"
|
|
||||||
AGENT_LOG="$WORKDIR/agent-events.log"
|
|
||||||
SENTINEL_LOG="$WORKDIR/d-sentinel.log"
|
|
||||||
write_dryrun_config "$CFG_D" "hooks:" \
|
|
||||||
" agent.started:" \
|
|
||||||
" - name: probe-agent" \
|
|
||||||
" command: \"echo AGENT_STARTED >> $AGENT_LOG\"" \
|
|
||||||
" agent.completed:" \
|
|
||||||
" - name: probe-agent" \
|
|
||||||
" command: \"echo AGENT_DONE >> $AGENT_LOG\"" \
|
|
||||||
" agent.failed:" \
|
|
||||||
" - name: probe-agent" \
|
|
||||||
" command: \"echo AGENT_FAILED >> $AGENT_LOG\"" \
|
|
||||||
" turn.completed:" \
|
|
||||||
" - name: sentinel" \
|
|
||||||
" command: \"echo SENTINEL >> $SENTINEL_LOG\""
|
|
||||||
|
|
||||||
AGENT_NAME="$(ls "$REPO_ROOT/assets/agents" | head -1)"
|
|
||||||
cp -r "$REPO_ROOT/assets/agents/$AGENT_NAME" "$CFG_D/agents/$AGENT_NAME"
|
|
||||||
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --agent "$AGENT_NAME" --info > /dev/null 2>&1
|
|
||||||
# Proving absence needs a positive signal to wait on: a follow-up plain run
|
|
||||||
# fires turn.completed, and its sentinel landing bounds how long the --info
|
|
||||||
# run's (nonexistent) agent.* children could have taken to write.
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
wait_for "scenario D sentinel" test -s "$SENTINEL_LOG"
|
|
||||||
[ -s "$AGENT_LOG" ] && fail "expected zero agent.* events from --agent --info, got: $(cat "$AGENT_LOG")"
|
|
||||||
echo "PASS: --agent $AGENT_NAME --info fired zero agent.* hook events"
|
|
||||||
|
|
||||||
echo "== Scenario E: --install-builtins hooks reconciles roles/hooks and agent hooks dirs =="
|
|
||||||
CFG_E="$WORKDIR/e"
|
|
||||||
AGENT_E="$(ls "$REPO_ROOT/assets/agents" | head -1)"
|
|
||||||
mkdir -p "$CFG_E/roles/hooks" "$CFG_E/agents/$AGENT_E/hooks"
|
|
||||||
# Manifest-owned leftovers a previous release shipped, plus user files that
|
|
||||||
# must survive the refresh in both locations.
|
|
||||||
printf 'stale-role.sh\n' > "$CFG_E/roles/hooks/.builtin-manifest"
|
|
||||||
echo "stale" > "$CFG_E/roles/hooks/stale-role.sh"
|
|
||||||
echo "mine" > "$CFG_E/roles/hooks/user-role.sh"
|
|
||||||
printf 'stale-agent.sh\n' > "$CFG_E/agents/$AGENT_E/hooks/.builtin-manifest"
|
|
||||||
echo "stale" > "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh"
|
|
||||||
echo "mine" > "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh"
|
|
||||||
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_E" "$BIN" --install-builtins hooks > "$WORKDIR/install_e.out" 2>&1 \
|
|
||||||
|| fail "--install-builtins hooks failed: $(cat "$WORKDIR/install_e.out")"
|
|
||||||
|
|
||||||
[ -f "$CFG_E/hooks/notify.sh" ] || fail "global hooks were not installed"
|
|
||||||
[ ! -f "$CFG_E/roles/hooks/stale-role.sh" ] \
|
|
||||||
|| fail "manifest-owned stale role hook survived the refresh"
|
|
||||||
[ "$(cat "$CFG_E/roles/hooks/user-role.sh")" = "mine" ] \
|
|
||||||
|| fail "user file in roles/hooks was touched by the refresh"
|
|
||||||
[ ! -f "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" ] \
|
|
||||||
|| fail "manifest-owned stale agent hook survived the refresh"
|
|
||||||
[ "$(cat "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh")" = "mine" ] \
|
|
||||||
|| fail "user file in the agent hooks dir was touched by the refresh"
|
|
||||||
echo "PASS: refresh reconciles roles/hooks and bundled-agent hooks, sparing user files"
|
|
||||||
|
|
||||||
echo "== Scenario F: top-level manifest reconcile removes stale hooks; malformed manifest deletes nothing =="
|
|
||||||
CFG_F="$WORKDIR/f"
|
|
||||||
mkdir -p "$CFG_F"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
||||||
[ -f "$CFG_F/hooks/.builtin-manifest" ] \
|
|
||||||
|| fail "install did not write a .builtin-manifest to the top-level hooks dir"
|
|
||||||
|
|
||||||
# Simulate an upgrade from a release that also shipped old-hook.sh: the
|
|
||||||
# manifest records it, so reinstall must remove it — while a user script
|
|
||||||
# absent from the manifest survives.
|
|
||||||
printf 'log-events.sh\nnotify.sh\nold-hook.sh\n' > "$CFG_F/hooks/.builtin-manifest"
|
|
||||||
echo "stale" > "$CFG_F/hooks/old-hook.sh"
|
|
||||||
echo "mine" > "$CFG_F/hooks/user-hook.sh"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
||||||
[ ! -f "$CFG_F/hooks/old-hook.sh" ] \
|
|
||||||
|| fail "stale shipped hook recorded in the manifest was not removed on reinstall"
|
|
||||||
[ "$(cat "$CFG_F/hooks/user-hook.sh")" = "mine" ] \
|
|
||||||
|| fail "user hook script was deleted or modified by manifest reconcile"
|
|
||||||
grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \
|
|
||||||
&& fail "manifest still lists the removed stale hook"
|
|
||||||
|
|
||||||
# Fail-safe: a malformed (non-UTF8) manifest must delete NOTHING and must
|
|
||||||
# not fail the command.
|
|
||||||
printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest"
|
|
||||||
echo "keep" > "$CFG_F/hooks/orphan.sh"
|
|
||||||
rc_f=0
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || rc_f=$?
|
|
||||||
[ "$rc_f" = "0" ] \
|
|
||||||
|| fail "reinstall failed on a malformed manifest (exit $rc_f): $(cat "$WORKDIR/install_f.out")"
|
|
||||||
[ -f "$CFG_F/hooks/orphan.sh" ] \
|
|
||||||
|| fail "a malformed manifest caused a deletion (fail-safe broken)"
|
|
||||||
[ -f "$CFG_F/hooks/notify.sh" ] && [ -f "$CFG_F/hooks/log-events.sh" ] \
|
|
||||||
|| fail "shipped hooks missing after malformed-manifest reinstall"
|
|
||||||
echo "PASS: stale manifest-owned hook removed; user files survive; malformed manifest deletes nothing"
|
|
||||||
|
|
||||||
echo "== Scenario G: plain startup sweeps removed-builtin-agent hooks, spares user agents =="
|
|
||||||
CFG_G="$WORKDIR/g"
|
|
||||||
mkdir -p "$CFG_G"
|
|
||||||
write_dryrun_config "$CFG_G"
|
|
||||||
# ghost: a removed builtin agent (has a manifest, absent from the embed)
|
|
||||||
# with a user file beside the manifest-owned hook -> hooks dir survives
|
|
||||||
# with only the user file.
|
|
||||||
mkdir -p "$CFG_G/agents/ghost/hooks"
|
|
||||||
printf 'shipped.sh\n' > "$CFG_G/agents/ghost/hooks/.builtin-manifest"
|
|
||||||
echo "stale" > "$CFG_G/agents/ghost/hooks/shipped.sh"
|
|
||||||
echo "mine" > "$CFG_G/agents/ghost/hooks/user.sh"
|
|
||||||
# ghost2: same, but nothing user-owned -> the emptied hooks dir is removed.
|
|
||||||
mkdir -p "$CFG_G/agents/ghost2/hooks"
|
|
||||||
printf 'shipped.sh\n' > "$CFG_G/agents/ghost2/hooks/.builtin-manifest"
|
|
||||||
echo "stale" > "$CFG_G/agents/ghost2/hooks/shipped.sh"
|
|
||||||
# mine: a user agent with no manifest -> never touched.
|
|
||||||
mkdir -p "$CFG_G/agents/mine/hooks"
|
|
||||||
echo "mine" > "$CFG_G/agents/mine/hooks/mine.sh"
|
|
||||||
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_G" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
|
|
||||||
[ ! -f "$CFG_G/agents/ghost/hooks/shipped.sh" ] \
|
|
||||||
|| fail "removed-agent sweep left the manifest-owned hook behind"
|
|
||||||
[ ! -f "$CFG_G/agents/ghost/hooks/.builtin-manifest" ] \
|
|
||||||
|| fail "removed-agent sweep left the manifest behind"
|
|
||||||
[ "$(cat "$CFG_G/agents/ghost/hooks/user.sh")" = "mine" ] \
|
|
||||||
|| fail "removed-agent sweep deleted a user file"
|
|
||||||
[ ! -d "$CFG_G/agents/ghost2/hooks" ] \
|
|
||||||
|| fail "emptied hooks dir of a removed agent was not removed"
|
|
||||||
[ -d "$CFG_G/agents/ghost2" ] \
|
|
||||||
|| fail "sweep removed more than the hooks dir of a removed agent"
|
|
||||||
[ "$(cat "$CFG_G/agents/mine/hooks/mine.sh")" = "mine" ] \
|
|
||||||
|| fail "sweep touched a user agent dir without a manifest"
|
|
||||||
echo "PASS: startup sweep removes only manifest-owned hooks of removed agents"
|
|
||||||
|
|
||||||
echo "== Scenario H: bundled hook scripts behave as documented when run directly =="
|
|
||||||
# The two shipped scripts are consumer-facing artifacts in their own right.
|
|
||||||
# Exercise the INSTALLED copies (real CLI install) the way a hook runner or a
|
|
||||||
# manual invocation would: clean env, no COYOTE_HOOK_LOG, no notifier
|
|
||||||
# binaries, no controlling terminal.
|
|
||||||
CFG_H="$WORKDIR/h"
|
|
||||||
mkdir -p "$CFG_H"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_H" "$BIN" --install-builtins hooks > /dev/null 2>&1
|
|
||||||
LOG_SCRIPT="$CFG_H/hooks/log-events.sh"
|
|
||||||
NOTIFY_SCRIPT="$CFG_H/hooks/notify.sh"
|
|
||||||
[ -x "$LOG_SCRIPT" ] || fail "installed log-events.sh missing or non-executable"
|
|
||||||
[ -x "$NOTIFY_SCRIPT" ] || fail "installed notify.sh missing or non-executable"
|
|
||||||
|
|
||||||
# The manifest the installer drops beside the scripts is NOT a script: the
|
|
||||||
# unified exec-bit predicate must leave it non-executable.
|
|
||||||
if [ -f "$CFG_H/hooks/.builtin-manifest" ]; then
|
|
||||||
[ ! -x "$CFG_H/hooks/.builtin-manifest" ] \
|
|
||||||
|| fail ".builtin-manifest in the hooks dir is executable; non-script files must stay non-executable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# H1: with COYOTE_HOOK_LOG unset the log defaults under XDG_STATE_HOME, is
|
|
||||||
# created 0600, carries a UTC ISO-8601 header, and never logs COYOTE_SECRET_*.
|
|
||||||
XDG_H="$WORKDIR/h-xdg"
|
|
||||||
env -u COYOTE_HOOK_LOG XDG_STATE_HOME="$XDG_H" HOME="$WORKDIR/h-home-unused" \
|
|
||||||
COYOTE_EVENT=probe.event COYOTE_HOOK_NAME=probe COYOTE_SECRET_TOKEN=hunter2 \
|
|
||||||
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the XDG default path"
|
|
||||||
HLOG="$XDG_H/coyote/hooks.log"
|
|
||||||
[ -f "$HLOG" ] || fail "log-events.sh did not default to \$XDG_STATE_HOME/coyote/hooks.log"
|
|
||||||
if command -v stat > /dev/null 2>&1; then
|
|
||||||
mode_hlog="$(stat -c '%a' "$HLOG" 2>/dev/null || stat -f '%Lp' "$HLOG")"
|
|
||||||
[ "$mode_hlog" = "600" ] || fail "default hook log mode is $mode_hlog, expected 600"
|
|
||||||
fi
|
|
||||||
grep -Eq '^=== [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z probe\.event$' "$HLOG" \
|
|
||||||
|| fail "log header is not a UTC ISO-8601 timestamp: $(head -1 "$HLOG")"
|
|
||||||
grep -q '^COYOTE_EVENT=probe.event$' "$HLOG" \
|
|
||||||
|| fail "log snapshot is missing COYOTE_EVENT"
|
|
||||||
! grep -q 'hunter2' "$HLOG" \
|
|
||||||
|| fail "COYOTE_SECRET_* value leaked into the hook log"
|
|
||||||
|
|
||||||
# H2: with XDG_STATE_HOME also unset, the log falls back to
|
|
||||||
# $HOME/.local/state/coyote/hooks.log.
|
|
||||||
HOME_H="$WORKDIR/h-home"
|
|
||||||
mkdir -p "$HOME_H"
|
|
||||||
env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_H" COYOTE_EVENT=probe.event \
|
|
||||||
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the HOME default path"
|
|
||||||
[ -f "$HOME_H/.local/state/coyote/hooks.log" ] \
|
|
||||||
|| fail "log-events.sh did not fall back to ~/.local/state/coyote/hooks.log"
|
|
||||||
|
|
||||||
# H3: a symlink planted at the DEFAULT log path is refused — exit 0, target
|
|
||||||
# untouched (the hardening must hold on the new default location too).
|
|
||||||
HOME_S="$WORKDIR/h-sym"
|
|
||||||
mkdir -p "$HOME_S/.local/state/coyote"
|
|
||||||
VICTIM_H="$WORKDIR/h-victim.txt"
|
|
||||||
echo "untouched" > "$VICTIM_H"
|
|
||||||
ln -s "$VICTIM_H" "$HOME_S/.local/state/coyote/hooks.log"
|
|
||||||
env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_S" COYOTE_EVENT=probe.event \
|
|
||||||
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero when refusing a symlink"
|
|
||||||
[ "$(cat "$VICTIM_H")" = "untouched" ] \
|
|
||||||
|| fail "log-events.sh wrote through a symlink planted at the default log path"
|
|
||||||
|
|
||||||
# H4: notify.sh with no notifier on PATH and no controlling terminal falls
|
|
||||||
# back to stdout with control bytes (ESC/BEL, i.e. ANSI + OSC) stripped from
|
|
||||||
# model-influenced values.
|
|
||||||
BIN_H="$WORKDIR/h-bin"
|
|
||||||
mkdir -p "$BIN_H"
|
|
||||||
ln -s "$(command -v tr)" "$BIN_H/tr"
|
|
||||||
NOTIFY_OUT="$WORKDIR/h-notify.out"
|
|
||||||
rc_h=0
|
|
||||||
setsid -w env -i PATH="$BIN_H" \
|
|
||||||
COYOTE_EVENT=turn.completed \
|
|
||||||
COYOTE_TOOL_NAME="$(printf 'evil\033]0;own\007\033[31mred')" \
|
|
||||||
"$(command -v bash)" "$NOTIFY_SCRIPT" > "$NOTIFY_OUT" 2> /dev/null || rc_h=$?
|
|
||||||
[ "$rc_h" = "0" ] || fail "notify.sh fallback exited $rc_h"
|
|
||||||
grep -Fq '[coyote] turn.completed tool=evil]0;own[31mred' "$NOTIFY_OUT" \
|
|
||||||
|| fail "notify.sh fallback output missing/unsanitized: $(cat "$NOTIFY_OUT")"
|
|
||||||
! LC_ALL=C grep -q "$(printf '\033')" "$NOTIFY_OUT" \
|
|
||||||
|| fail "ANSI/OSC escape bytes leaked through the notify.sh tty fallback"
|
|
||||||
echo "PASS: installed hook scripts honor XDG default, 0600, UTC timestamps, symlink refusal, secret filter, and ANSI-stripped notify fallback"
|
|
||||||
|
|
||||||
echo "== Scenario I: session.started vs session.resumed via --session =="
|
|
||||||
CFG_I="$WORKDIR/i"
|
|
||||||
mkdir -p "$CFG_I"
|
|
||||||
SESS_LOG="$WORKDIR/i-sess.log"
|
|
||||||
write_dryrun_config "$CFG_I" "hooks:" \
|
|
||||||
" session.started:" \
|
|
||||||
" - name: probe-sess" \
|
|
||||||
" command: \"echo STARTED >> $SESS_LOG\"" \
|
|
||||||
" session.resumed:" \
|
|
||||||
" - name: probe-sess" \
|
|
||||||
" command: \"echo RESUMED >> $SESS_LOG\""
|
|
||||||
|
|
||||||
# A fresh named session (no file on disk yet) fires session.started only.
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-fresh --no-stream "say exactly: hi" > /dev/null
|
|
||||||
wait_for "session.started log" test -s "$SESS_LOG"
|
|
||||||
[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one session.started for a fresh session, got: $(cat "$SESS_LOG")"
|
|
||||||
grep -q '^RESUMED$' "$SESS_LOG" \
|
|
||||||
&& fail "a fresh named session must not fire session.resumed: $(cat "$SESS_LOG")"
|
|
||||||
|
|
||||||
# A persisted session on disk: resuming it fires session.resumed, and never
|
|
||||||
# session.started (the inverse gate).
|
|
||||||
mkdir -p "$CFG_I/sessions"
|
|
||||||
printf 'model: dryrun:dry-model\nmessages: []\n' > "$CFG_I/sessions/probe-resume.yaml"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-resume --no-stream "say exactly: hi" > /dev/null
|
|
||||||
scenario_i_resumed() { grep -q '^RESUMED$' "$SESS_LOG"; }
|
|
||||||
wait_for "session.resumed log" scenario_i_resumed
|
|
||||||
[ "$(grep -c '^RESUMED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one session.resumed for a resumed session, got: $(cat "$SESS_LOG")"
|
|
||||||
[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "resuming a session must not fire session.started: $(cat "$SESS_LOG")"
|
|
||||||
echo "PASS: fresh session fires session.started only; resume fires session.resumed only"
|
|
||||||
|
|
||||||
echo "== Scenario J: terminal ctrl-c during a live headless --agent run fires agent.interrupted =="
|
|
||||||
if ! command -v python3 > /dev/null 2>&1; then
|
|
||||||
echo "SKIP: python3 not available; cannot allocate a pty or hanging LLM endpoint" >&2
|
|
||||||
else
|
|
||||||
CFG_J="$WORKDIR/j"
|
|
||||||
mkdir -p "$CFG_J/agents/probe-int"
|
|
||||||
INT_LOG="$WORKDIR/j-int.log"
|
|
||||||
|
|
||||||
# A local "LLM endpoint" that accepts connections and never answers keeps
|
|
||||||
# the agent run in flight (spinner active, its ctrl-c watcher polling)
|
|
||||||
# while the probe delivers ^C. It writes its port to $1 once listening and
|
|
||||||
# touches $2 on the first accepted connection, so the runner below can
|
|
||||||
# gate the ^C on the request being demonstrably in flight.
|
|
||||||
python3 - "$WORKDIR/j-port" "$WORKDIR/j-conn" <<'PY' &
|
|
||||||
import socket, sys
|
|
||||||
s = socket.socket()
|
|
||||||
s.bind(("127.0.0.1", 0))
|
|
||||||
s.listen(8)
|
|
||||||
open(sys.argv[1], "w").write(str(s.getsockname()[1]))
|
|
||||||
conns = []
|
|
||||||
while True:
|
|
||||||
c, _ = s.accept()
|
|
||||||
if not conns:
|
|
||||||
open(sys.argv[2], "w").write("1")
|
|
||||||
conns.append(c)
|
|
||||||
PY
|
|
||||||
SRV_J=$!
|
|
||||||
trap 'kill "$SRV_J" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT
|
|
||||||
wait_for "hang-server port" test -s "$WORKDIR/j-port"
|
|
||||||
PORT_J="$(cat "$WORKDIR/j-port")"
|
|
||||||
|
|
||||||
cat > "$CFG_J/config.yaml" <<EOF
|
|
||||||
model: hangc:hang-model
|
|
||||||
clients:
|
|
||||||
- type: openai
|
|
||||||
name: hangc
|
|
||||||
auth: none
|
|
||||||
api_key: 'unused'
|
|
||||||
api_base: http://127.0.0.1:$PORT_J/v1
|
|
||||||
models:
|
|
||||||
- name: hang-model
|
|
||||||
max_input_tokens: 100000
|
|
||||||
supports_function_calling: true
|
|
||||||
save: false
|
|
||||||
memory: false
|
|
||||||
stream: false
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Agent-level hooks resolve without any global_hooks whitelisting, so the
|
|
||||||
# probe agent carries its own markers for every terminal agent event.
|
|
||||||
cat > "$CFG_J/agents/probe-int/config.yaml" <<EOF
|
|
||||||
name: probe-int
|
|
||||||
description: ctrl-c interruption probe agent
|
|
||||||
version: 0.1.0
|
|
||||||
instructions: |
|
|
||||||
You are a probe agent. Reply briefly.
|
|
||||||
hooks:
|
|
||||||
agent.started:
|
|
||||||
- name: mark
|
|
||||||
command: "echo STARTED >> $INT_LOG"
|
|
||||||
agent.completed:
|
|
||||||
- name: mark
|
|
||||||
command: "echo COMPLETED >> $INT_LOG"
|
|
||||||
agent.failed:
|
|
||||||
- name: mark
|
|
||||||
command: "echo \"FAILED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG"
|
|
||||||
agent.interrupted:
|
|
||||||
- name: mark
|
|
||||||
command: "echo \"INTERRUPTED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Spawn coyote on a real pty (the spinner's ctrl-c watcher only runs on a
|
|
||||||
# terminal), wait until the agent.started marker AND the first accepted
|
|
||||||
# LLM connection prove the run is in flight, then send a literal ^C
|
|
||||||
# through the pty line discipline — exactly what a terminal user does.
|
|
||||||
cat > "$WORKDIR/j-pty.py" <<'PY'
|
|
||||||
import os, pty, sys, time
|
|
||||||
|
|
||||||
int_log, conn_marker = sys.argv[1], sys.argv[2]
|
|
||||||
argv = sys.argv[3:]
|
|
||||||
|
|
||||||
pid, master = pty.fork()
|
|
||||||
if pid == 0:
|
|
||||||
os.execvp(argv[0], argv)
|
|
||||||
|
|
||||||
os.set_blocking(master, False)
|
|
||||||
|
|
||||||
def drain():
|
|
||||||
try:
|
|
||||||
while os.read(master, 4096):
|
|
||||||
pass
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def marker_seen():
|
|
||||||
try:
|
|
||||||
with open(int_log) as f:
|
|
||||||
return "STARTED" in f.read()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
deadline = time.time() + 15
|
|
||||||
while time.time() < deadline:
|
|
||||||
drain()
|
|
||||||
if marker_seen() and os.path.exists(conn_marker):
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
else:
|
|
||||||
print("TIMEOUT: agent.started marker / LLM connection never appeared", flush=True)
|
|
||||||
os.kill(pid, 9)
|
|
||||||
os.waitpid(pid, 0)
|
|
||||||
sys.exit(3)
|
|
||||||
|
|
||||||
os.write(master, b"\x03") # ^C via the pty line discipline
|
|
||||||
|
|
||||||
deadline = time.time() + 15
|
|
||||||
while time.time() < deadline:
|
|
||||||
drain()
|
|
||||||
got, status = os.waitpid(pid, os.WNOHANG)
|
|
||||||
if got == pid:
|
|
||||||
if os.WIFSIGNALED(status):
|
|
||||||
print(f"FATAL: coyote died on signal {os.WTERMSIG(status)} — no ctrl-c handler was installed", flush=True)
|
|
||||||
sys.exit(4)
|
|
||||||
sys.exit(0)
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
print("TIMEOUT: coyote did not exit within 15s of ^C", flush=True)
|
|
||||||
os.kill(pid, 9)
|
|
||||||
os.waitpid(pid, 0)
|
|
||||||
sys.exit(3)
|
|
||||||
PY
|
|
||||||
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_J" python3 "$WORKDIR/j-pty.py" "$INT_LOG" "$WORKDIR/j-conn" \
|
|
||||||
"$BIN" --agent probe-int --no-stream "say exactly: hi" \
|
|
||||||
|| fail "pty ctrl-c run did not exit cleanly (see message above)"
|
|
||||||
|
|
||||||
scenario_j_interrupted() { grep -qs '^INTERRUPTED err=unset$' "$INT_LOG"; }
|
|
||||||
wait_for "agent.interrupted log" scenario_j_interrupted
|
|
||||||
[ "$(grep -c '^STARTED$' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one agent.started, got: $(cat "$INT_LOG")"
|
|
||||||
[ "$(grep -c '^INTERRUPTED' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \
|
|
||||||
|| fail "expected exactly one agent.interrupted, got: $(cat "$INT_LOG")"
|
|
||||||
grep -q '^FAILED' "$INT_LOG" \
|
|
||||||
&& fail "ctrl-c must fire agent.interrupted, never agent.failed: $(cat "$INT_LOG")"
|
|
||||||
grep -q '^COMPLETED' "$INT_LOG" \
|
|
||||||
&& fail "an interrupted run must not fire agent.completed: $(cat "$INT_LOG")"
|
|
||||||
kill "$SRV_J" 2>/dev/null || true
|
|
||||||
echo "PASS: terminal ctrl-c fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR; never failed/completed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== Scenario K: global_hooks wildcard grammar (exact, <event>.*, *.<name>, *.*, invalid globs) =="
|
|
||||||
# Global hooks: agent.started has one hook named 'mark'; agent.completed has
|
|
||||||
# two hooks named 'mark' and 'other'. Each sub-case runs the same
|
|
||||||
# `--agent wc-probe --macro probe` recipe in its own config dir with a
|
|
||||||
# different global_hooks whitelist, then asserts exactly which markers were
|
|
||||||
# admitted. The binary's exit drain only acknowledges hook SPAWNS — the hook
|
|
||||||
# processes finish on their own after exit — so every expected marker gets
|
|
||||||
# its own wait_for before any absence check reads the log.
|
|
||||||
k_run() {
|
|
||||||
# $1 = label, $2 = marker log, $3.. = global_hooks entries (verbatim)
|
|
||||||
local label="$1" klog="$2"
|
|
||||||
shift 2
|
|
||||||
local kcfg="$WORKDIR/k-$label"
|
|
||||||
mkdir -p "$kcfg/agents/wc-probe" "$kcfg/macros"
|
|
||||||
write_dryrun_config "$kcfg" "hooks:" \
|
|
||||||
" agent.started:" \
|
|
||||||
" - name: mark" \
|
|
||||||
" command: \"echo S-mark >> $klog\"" \
|
|
||||||
" agent.completed:" \
|
|
||||||
" - name: mark" \
|
|
||||||
" command: \"echo C-mark >> $klog\"" \
|
|
||||||
" - name: other" \
|
|
||||||
" command: \"echo C-other >> $klog\""
|
|
||||||
{
|
|
||||||
printf 'name: wc-probe\n'
|
|
||||||
printf 'description: wildcard whitelist probe agent\n'
|
|
||||||
printf 'version: 0.1.0\n'
|
|
||||||
printf 'instructions: |\n'
|
|
||||||
printf ' You are a probe agent. Reply briefly.\n'
|
|
||||||
printf 'global_hooks:\n'
|
|
||||||
local entry
|
|
||||||
for entry in "$@"; do
|
|
||||||
printf ' - "%s"\n' "$entry"
|
|
||||||
done
|
|
||||||
} > "$kcfg/agents/wc-probe/config.yaml"
|
|
||||||
printf 'steps:\n - ".set temperature 0.5"\n' > "$kcfg/macros/probe.yaml"
|
|
||||||
COYOTE_CONFIG_DIR="$kcfg" "$BIN" --agent wc-probe --macro probe > /dev/null 2>&1 \
|
|
||||||
|| fail "scenario K ($label): --agent wc-probe --macro probe exited nonzero"
|
|
||||||
}
|
|
||||||
|
|
||||||
marker_count_is() { # $1 = log, $2 = marker, $3 = expected count
|
|
||||||
local n
|
|
||||||
n="$(grep -c -x -- "$2" "$1" 2>/dev/null)" || n=0
|
|
||||||
[ "${n:-0}" = "$3" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# K1: exact <event>.<name> admits only that hook on that event (unchanged).
|
|
||||||
KLOG1="$WORKDIR/k1.log"
|
|
||||||
k_run exact "$KLOG1" "agent.completed.mark"
|
|
||||||
wait_for "K1 C-mark" marker_count_is "$KLOG1" C-mark 1
|
|
||||||
marker_count_is "$KLOG1" S-mark 0 || fail "K1: exact entry leaked agent.started: $(cat "$KLOG1")"
|
|
||||||
marker_count_is "$KLOG1" C-other 0 || fail "K1: exact entry leaked sibling hook 'other': $(cat "$KLOG1")"
|
|
||||||
|
|
||||||
# K2: <event>.* admits every hook of that event, nothing else.
|
|
||||||
KLOG2="$WORKDIR/k2.log"
|
|
||||||
k_run event-star "$KLOG2" "agent.completed.*"
|
|
||||||
wait_for "K2 C-mark" marker_count_is "$KLOG2" C-mark 1
|
|
||||||
wait_for "K2 C-other" marker_count_is "$KLOG2" C-other 1
|
|
||||||
marker_count_is "$KLOG2" S-mark 0 || fail "K2: agent.completed.* leaked agent.started: $(cat "$KLOG2")"
|
|
||||||
|
|
||||||
# K3: *.<name> admits that hook name on every event, nothing else.
|
|
||||||
KLOG3="$WORKDIR/k3.log"
|
|
||||||
k_run star-name "$KLOG3" "*.mark"
|
|
||||||
wait_for "K3 S-mark" marker_count_is "$KLOG3" S-mark 1
|
|
||||||
wait_for "K3 C-mark" marker_count_is "$KLOG3" C-mark 1
|
|
||||||
marker_count_is "$KLOG3" C-other 0 || fail "K3: *.mark leaked hook 'other': $(cat "$KLOG3")"
|
|
||||||
|
|
||||||
# K4: *.* admits everything (the bare * form is pinned by tests/macro_bracketing.rs).
|
|
||||||
KLOG4="$WORKDIR/k4.log"
|
|
||||||
k_run star-star "$KLOG4" "*.*"
|
|
||||||
wait_for "K4 S-mark" marker_count_is "$KLOG4" S-mark 1
|
|
||||||
wait_for "K4 C-mark" marker_count_is "$KLOG4" C-mark 1
|
|
||||||
wait_for "K4 C-other" marker_count_is "$KLOG4" C-other 1
|
|
||||||
|
|
||||||
# K5: partial/inner globs are NOT wildcards — they admit nothing, and the run
|
|
||||||
# is otherwise unaffected (exit code checked inside k_run). The exact
|
|
||||||
# agent.started.mark entry is the positive signal bounding the absence checks.
|
|
||||||
KLOG5="$WORKDIR/k5.log"
|
|
||||||
k_run invalid-globs "$KLOG5" "agent.completed.ma*" "agent.*" "ag*.completed.mark" "agent.started.mark"
|
|
||||||
wait_for "K5 S-mark" marker_count_is "$KLOG5" S-mark 1
|
|
||||||
marker_count_is "$KLOG5" C-mark 0 || fail "K5: a partial glob admitted agent.completed.mark: $(cat "$KLOG5")"
|
|
||||||
marker_count_is "$KLOG5" C-other 0 || fail "K5: a partial glob admitted agent.completed.other: $(cat "$KLOG5")"
|
|
||||||
echo "PASS: wildcard whitelist grammar admits exactly the spec'd sets; partial globs stay inert"
|
|
||||||
|
|
||||||
echo "== Scenario L: --macro without --agent fires zero agent.* events =="
|
|
||||||
CFG_L="$WORKDIR/l"
|
|
||||||
mkdir -p "$CFG_L/macros"
|
|
||||||
L_AGENT_LOG="$WORKDIR/l-agent-events.log"
|
|
||||||
L_SENTINEL_LOG="$WORKDIR/l-sentinel.log"
|
|
||||||
write_dryrun_config "$CFG_L" "hooks:" \
|
|
||||||
" agent.started:" \
|
|
||||||
" - name: mark" \
|
|
||||||
" command: \"echo STARTED >> $L_AGENT_LOG\"" \
|
|
||||||
" agent.completed:" \
|
|
||||||
" - name: mark" \
|
|
||||||
" command: \"echo COMPLETED >> $L_AGENT_LOG\"" \
|
|
||||||
" agent.failed:" \
|
|
||||||
" - name: mark" \
|
|
||||||
" command: \"echo FAILED >> $L_AGENT_LOG\"" \
|
|
||||||
" turn.completed:" \
|
|
||||||
" - name: sentinel" \
|
|
||||||
" command: \"echo SENTINEL >> $L_SENTINEL_LOG\""
|
|
||||||
printf 'steps:\n - ".set temperature 0.5"\n' > "$CFG_L/macros/probe.yaml"
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --macro probe > /dev/null 2>&1 \
|
|
||||||
|| fail "scenario L: --macro without --agent exited nonzero"
|
|
||||||
# Proving absence needs a positive signal to wait on: a follow-up plain run
|
|
||||||
# fires turn.completed, and its sentinel landing bounds how long the macro
|
|
||||||
# run's (nonexistent) agent.* children could have taken to write.
|
|
||||||
COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --no-stream "say exactly: hi" > /dev/null
|
|
||||||
wait_for "scenario L sentinel" test -s "$L_SENTINEL_LOG"
|
|
||||||
[ -s "$L_AGENT_LOG" ] && fail "expected zero agent.* events from --macro without --agent, got: $(cat "$L_AGENT_LOG")"
|
|
||||||
echo "PASS: --macro without --agent fired zero agent.* hook events and exited cleanly"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "ALL SCENARIOS PASSED"
|
|
||||||
@@ -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" "$@" </dev/null >"$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 </dev/null >"$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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -50,8 +50,6 @@ pub(super) fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
|||||||
|
|
||||||
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
|
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
|
||||||
let h = tokio::runtime::Handle::try_current().ok();
|
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 {
|
let cfg = match h {
|
||||||
Some(handle) => tokio::task::block_in_place(|| {
|
Some(handle) => tokio::task::block_in_place(|| {
|
||||||
handle.block_on(Config::load_with_interpolation(true, true))
|
handle.block_on(Config::load_with_interpolation(true, true))
|
||||||
|
|||||||
+17
-26
@@ -113,7 +113,7 @@ pub(crate) fn install_and_reconcile_agent_hooks(
|
|||||||
/// swept, while files absent from the manifest always survive.
|
/// swept, while files absent from the manifest always survive.
|
||||||
/// Best-effort: a failure here must not abort the install.
|
/// Best-effort: a failure here must not abort the install.
|
||||||
fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
|
fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
|
||||||
let entries = match std::fs::read_dir(paths::agents_data_dir()) {
|
let entries = match read_dir(paths::agents_data_dir()) {
|
||||||
Ok(entries) => entries,
|
Ok(entries) => entries,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Failed to scan agents dir for removed-agent hooks: {err}");
|
warn!("Failed to scan agents dir for removed-agent hooks: {err}");
|
||||||
@@ -156,7 +156,7 @@ fn sweep_removed_agent_hooks(bundled_files: &HashMap<String, HashSet<String>>) {
|
|||||||
}
|
}
|
||||||
// remove_dir only succeeds on an empty directory, so a user file
|
// remove_dir only succeeds on an empty directory, so a user file
|
||||||
// left behind keeps the directory in place.
|
// 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()
|
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 sticky = StickyMode::None;
|
||||||
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
|
let mut written_hooks: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||||
for file in AgentAssets::iter() {
|
for file in AgentAssets::iter() {
|
||||||
@@ -283,15 +280,7 @@ impl Agent {
|
|||||||
Ok(())
|
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<()> {
|
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<String, Vec<(String, String)>> = BTreeMap::new();
|
let mut shipped: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
|
||||||
for file in AgentAssets::iter() {
|
for file in AgentAssets::iter() {
|
||||||
let Some((agent, rest)) = file.as_ref().split_once('/') else {
|
let Some((agent, rest)) = file.as_ref().split_once('/') else {
|
||||||
@@ -311,6 +300,7 @@ impl Agent {
|
|||||||
for (agent, files) in &shipped {
|
for (agent, files) in &shipped {
|
||||||
install_and_reconcile_agent_hooks(agent, files, mode, sticky)?;
|
install_and_reconcile_agent_hooks(agent, files, mode, sticky)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1496,6 +1486,7 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_direct_hook_path_accepts_only_direct_hook_children() {
|
fn parse_direct_hook_path_accepts_only_direct_hook_children() {
|
||||||
@@ -1610,12 +1601,12 @@ global_hooks:
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
fn load_envs_ignores_malformed_global_tools_and_logs_debug() {
|
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 name = "gt-malformed-envtest";
|
||||||
let yaml = format!("name: {name}\ninstructions: hi\nglobal_tools:\n - keep_tool.sh\n");
|
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 mut config: AgentConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||||
let env_name = normalize_env_name(&format!("{name}_global_tools"));
|
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());
|
config.load_envs(&AppConfig::default());
|
||||||
|
|
||||||
@@ -1624,7 +1615,7 @@ global_hooks:
|
|||||||
vec!["keep_tool.sh"],
|
vec!["keep_tool.sh"],
|
||||||
"a malformed override must leave the existing value untouched"
|
"a malformed override must leave the existing value untouched"
|
||||||
);
|
);
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
debugs.iter().any(|message| {
|
debugs.iter().any(|message| {
|
||||||
message.contains("Ignoring malformed global_tools env override")
|
message.contains("Ignoring malformed global_tools env override")
|
||||||
@@ -2076,9 +2067,9 @@ nodes: {}
|
|||||||
let _guard = TestConfigDirGuard::new("agent-hooks-prompt");
|
let _guard = TestConfigDirGuard::new("agent-hooks-prompt");
|
||||||
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
|
let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]);
|
||||||
let dir = paths::agents_data_dir().join("probe").join("hooks");
|
let dir = paths::agents_data_dir().join("probe").join("hooks");
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(dir.join("a.sh"), "local a").unwrap();
|
fs::write(dir.join("a.sh"), "local a").unwrap();
|
||||||
std::fs::write(dir.join("b.sh"), "local b").unwrap();
|
fs::write(dir.join("b.sh"), "local b").unwrap();
|
||||||
|
|
||||||
// First conflict answered per-file, second by the sticky replace-all.
|
// First conflict answered per-file, second by the sticky replace-all.
|
||||||
let script = prompt_script::install(&["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
|
// A sticky mode carried in from an earlier location suppresses all
|
||||||
// prompting: `--install-builtins hooks` shares one sticky scope
|
// prompting: `--install-builtins hooks` shares one sticky scope
|
||||||
// across the global, role, and agent hook locations.
|
// 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 script = prompt_script::install(&[]);
|
||||||
let mut sticky = StickyMode::KeepAll;
|
let mut sticky = StickyMode::KeepAll;
|
||||||
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
|
install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky)
|
||||||
@@ -2121,7 +2112,7 @@ nodes: {}
|
|||||||
&mut StickyMode::None,
|
&mut StickyMode::None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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(
|
install_and_reconcile_agent_hooks(
|
||||||
"probe",
|
"probe",
|
||||||
@@ -2182,7 +2173,7 @@ nodes: {}
|
|||||||
&mut StickyMode::None,
|
&mut StickyMode::None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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();
|
Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
|
||||||
|
|
||||||
@@ -2200,14 +2191,14 @@ nodes: {}
|
|||||||
let guard = TestConfigDirGuard::new("agent-sweep-symlink");
|
let guard = TestConfigDirGuard::new("agent-sweep-symlink");
|
||||||
let real = guard.path.join("agent-elsewhere");
|
let real = guard.path.join("agent-elsewhere");
|
||||||
let hooks = real.join("hooks");
|
let hooks = real.join("hooks");
|
||||||
std::fs::create_dir_all(&hooks).unwrap();
|
create_dir_all(&hooks).unwrap();
|
||||||
std::fs::write(
|
fs::write(
|
||||||
hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE),
|
hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE),
|
||||||
"shipped.sh\n",
|
"shipped.sh\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
std::fs::write(hooks.join("shipped.sh"), "stale").unwrap();
|
fs::write(hooks.join("shipped.sh"), "stale").unwrap();
|
||||||
std::fs::create_dir_all(paths::agents_data_dir()).unwrap();
|
create_dir_all(paths::agents_data_dir()).unwrap();
|
||||||
std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap();
|
std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap();
|
||||||
|
|
||||||
sweep_removed_agent_hooks(&HashMap::new());
|
sweep_removed_agent_hooks(&HashMap::new());
|
||||||
|
|||||||
@@ -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 {
|
let mut functions = if inspection {
|
||||||
Functions::default()
|
Functions::default()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// shaped like one is never accepted: it could direct a deletion at a file
|
||||||
/// the manifest never named.
|
/// the manifest never named.
|
||||||
fn is_ntfs_short_name_alias(name: &str) -> bool {
|
fn is_ntfs_short_name_alias(name: &str) -> bool {
|
||||||
|
|||||||
@@ -241,10 +241,6 @@ impl BundleStore {
|
|||||||
same_name_other_source: None,
|
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 reserved = AssetCategory::parse(&base).is_some();
|
||||||
let collision = self.source_of_other_bundle(&base, &canonical);
|
let collision = self.source_of_other_bundle(&base, &canonical);
|
||||||
if reserved || collision.is_some() {
|
if reserved || collision.is_some() {
|
||||||
|
|||||||
+7
-22
@@ -2,7 +2,7 @@
|
|||||||
//! silently clobber locally modified files. The bundle installer and the
|
//! silently clobber locally modified files. The bundle installer and the
|
||||||
//! builtin hook installers all funnel per-file conflicts through
|
//! builtin hook installers all funnel per-file conflicts through
|
||||||
//! [`resolve_conflict`], so the prompt wording and the sticky
|
//! [`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.
|
//! one sticky scope spans several install locations in a single run.
|
||||||
|
|
||||||
use crate::utils::IS_STDOUT_TERMINAL;
|
use crate::utils::IS_STDOUT_TERMINAL;
|
||||||
@@ -12,17 +12,10 @@ use inquire::Select;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// How an installer treats a destination file that already exists.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum InstallMode {
|
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,
|
Skip,
|
||||||
/// Overwrite existing files unconditionally.
|
|
||||||
Force,
|
Force,
|
||||||
/// Write missing files, silently skip identical ones, and ask per
|
|
||||||
/// differing file.
|
|
||||||
Prompt,
|
Prompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,20 +35,12 @@ pub(crate) enum ConflictAction {
|
|||||||
Replace,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum NonInteractive {
|
pub(crate) enum NonInteractive {
|
||||||
Bail,
|
Bail,
|
||||||
Keep,
|
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(
|
pub(crate) fn should_replace_existing(
|
||||||
dst: &Path,
|
dst: &Path,
|
||||||
content: &str,
|
content: &str,
|
||||||
@@ -337,9 +322,9 @@ mod tests {
|
|||||||
fn should_replace_existing_skips_identical_content_without_prompting() {
|
fn should_replace_existing_skips_identical_content_without_prompting() {
|
||||||
let _script = prompt_script::install(&[]);
|
let _script = prompt_script::install(&[]);
|
||||||
let dir = crate::utils::temp_file("conflict-identical-", "");
|
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");
|
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 mut sticky = StickyMode::None;
|
||||||
let replace = should_replace_existing(
|
let replace = should_replace_existing(
|
||||||
@@ -351,7 +336,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = fs::remove_dir_all(&dir);
|
||||||
assert!(!replace);
|
assert!(!replace);
|
||||||
assert_eq!(prompt_script::prompts_asked(), 0);
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
}
|
}
|
||||||
@@ -361,9 +346,9 @@ mod tests {
|
|||||||
fn should_replace_existing_never_prompts_in_skip_or_force_mode() {
|
fn should_replace_existing_never_prompts_in_skip_or_force_mode() {
|
||||||
let _script = prompt_script::install(&[]);
|
let _script = prompt_script::install(&[]);
|
||||||
let dir = crate::utils::temp_file("conflict-modes-", "");
|
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");
|
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 mut sticky = StickyMode::None;
|
||||||
let skip =
|
let skip =
|
||||||
@@ -371,7 +356,7 @@ mod tests {
|
|||||||
let force =
|
let force =
|
||||||
should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky);
|
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!(!skip.unwrap());
|
||||||
assert!(force.unwrap());
|
assert!(force.unwrap());
|
||||||
assert_eq!(prompt_script::prompts_asked(), 0);
|
assert_eq!(prompt_script::prompts_asked(), 0);
|
||||||
|
|||||||
@@ -1860,12 +1860,6 @@ fn print_plan_summary(plan: &InstallPlan) {
|
|||||||
/// Bundle-shipped hook scripts execute on the user's machine once wired into
|
/// 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
|
/// config, so every planned file landing in a hook location is called out
|
||||||
/// individually. Visibility only: installing them needs no extra approval.
|
/// 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/<name>/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<String> {
|
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
|
||||||
plan.files
|
plan.files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2864,9 +2858,6 @@ mod tests {
|
|||||||
file("x/config.yaml", TopCategory::Agents),
|
file("x/config.yaml", TopCategory::Agents),
|
||||||
file("reviewer.md", TopCategory::Roles),
|
file("reviewer.md", TopCategory::Roles),
|
||||||
file("hello.yaml", TopCategory::Macros),
|
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/hooks/nested/deep.sh", TopCategory::Agents),
|
||||||
file("x/sub/hooks/pre.sh", TopCategory::Agents),
|
file("x/sub/hooks/pre.sh", TopCategory::Agents),
|
||||||
file("sub/hooks/extra.sh", TopCategory::Roles),
|
file("sub/hooks/extra.sh", TopCategory::Roles),
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ pub async fn macro_execute(
|
|||||||
.bootstrap_tools(app.as_ref(), true, abort_signal.clone())
|
.bootstrap_tools(app.as_ref(), true, abort_signal.clone())
|
||||||
.await?;
|
.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 {
|
let result = async {
|
||||||
for step in ¯o_value.steps {
|
for step in ¯o_value.steps {
|
||||||
let command = Macro::interpolate_command(step, &variables);
|
let command = Macro::interpolate_command(step, &variables);
|
||||||
|
|||||||
+25
-36
@@ -419,9 +419,6 @@ impl Default for Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn install_builtins() -> Result<()> {
|
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();
|
hooks::sweep_stale_payload_files();
|
||||||
Functions::install_builtin_global_tools(false)?;
|
Functions::install_builtin_global_tools(false)?;
|
||||||
Agent::install_builtin_agents(InstallMode::Skip)?;
|
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::Functions => Functions::install_builtin_global_tools(true)?,
|
||||||
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
||||||
AssetCategory::Hooks => {
|
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;
|
let mut sticky = StickyMode::None;
|
||||||
hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?;
|
hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?;
|
||||||
Role::install_builtin_role_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 env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
// Inspection-only flags are look-don't-touch: no first-run
|
|
||||||
// config creation, nothing written.
|
|
||||||
if inspection {
|
if inspection {
|
||||||
return Ok(Self::default());
|
return Ok(Self::default());
|
||||||
}
|
}
|
||||||
@@ -778,9 +770,6 @@ impl Config {
|
|||||||
{
|
{
|
||||||
Some(v) => (Self::load_dynamic(&v)?, String::new()),
|
Some(v) => (Self::load_dynamic(&v)?, String::new()),
|
||||||
None => {
|
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 {
|
if inspection {
|
||||||
return Ok(Self::default());
|
return Ok(Self::default());
|
||||||
}
|
}
|
||||||
@@ -1655,12 +1644,12 @@ hooks:
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time;
|
use std::time;
|
||||||
|
|
||||||
let unique = time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(time::UNIX_EPOCH)
|
.duration_since(time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
|
let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
|
||||||
fs::create_dir_all(&tmp_dir).unwrap();
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
let config_path = tmp_dir.join("config.yaml");
|
let config_path = tmp_dir.join("config.yaml");
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1670,12 +1659,12 @@ hooks:
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let config_env = get_env_name("config_file");
|
let config_env = get_env_name("config_file");
|
||||||
let prev_config = std::env::var_os(&config_env);
|
let prev_config = env::var_os(&config_env);
|
||||||
let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG);
|
let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var(&config_env, &config_path);
|
env::set_var(&config_env, &config_path);
|
||||||
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1");
|
env::set_var(SANDBOX_ENV_FLAG, "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = Config::load_with_interpolation(false, false).await;
|
let result = Config::load_with_interpolation(false, false).await;
|
||||||
@@ -1683,12 +1672,12 @@ hooks:
|
|||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
match prev_config {
|
match prev_config {
|
||||||
Some(v) => std::env::set_var(&config_env, v),
|
Some(v) => env::set_var(&config_env, v),
|
||||||
None => std::env::remove_var(&config_env),
|
None => env::remove_var(&config_env),
|
||||||
}
|
}
|
||||||
match prev_sandbox {
|
match prev_sandbox {
|
||||||
Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v),
|
Some(v) => env::set_var(SANDBOX_ENV_FLAG, v),
|
||||||
None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG),
|
None => env::remove_var(SANDBOX_ENV_FLAG),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = fs::remove_dir_all(&tmp_dir);
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
@@ -1713,17 +1702,17 @@ hooks:
|
|||||||
create_dir_all(&tmp_dir).unwrap();
|
create_dir_all(&tmp_dir).unwrap();
|
||||||
let _config_dir = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &tmp_dir);
|
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 _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 _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider"));
|
||||||
let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform"));
|
let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform"));
|
||||||
|
|
||||||
let result = Config::load_with_interpolation(true, true).await;
|
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()
|
.unwrap()
|
||||||
.map(|entry| entry.unwrap().file_name())
|
.map(|entry| entry.unwrap().file_name())
|
||||||
.collect();
|
.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");
|
result.expect("inspection flags must load a default config without a config file");
|
||||||
assert!(
|
assert!(
|
||||||
leftover.is_empty(),
|
leftover.is_empty(),
|
||||||
@@ -1743,12 +1732,12 @@ hooks:
|
|||||||
let config_path = tmp_dir.join("config.yaml");
|
let config_path = tmp_dir.join("config.yaml");
|
||||||
let _config_file =
|
let _config_file =
|
||||||
crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path);
|
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 result = Config::load_with_interpolation(true, true).await;
|
||||||
|
|
||||||
let created = config_path.exists();
|
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");
|
result.expect("inspection flags must load a default config in sandbox mode too");
|
||||||
assert!(
|
assert!(
|
||||||
!created,
|
!created,
|
||||||
@@ -1835,7 +1824,7 @@ hooks:
|
|||||||
let written = write_models_override(vec![entry()]).unwrap();
|
let written = write_models_override(vec![entry()]).unwrap();
|
||||||
assert_eq!(written, path);
|
assert_eq!(written, path);
|
||||||
let mut tmp_name = path.as_os_str().to_os_string();
|
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());
|
assert!(!PathBuf::from(tmp_name).exists());
|
||||||
let loaded = paths::local_models_override().unwrap();
|
let loaded = paths::local_models_override().unwrap();
|
||||||
assert_eq!(loaded.len(), 1);
|
assert_eq!(loaded.len(), 1);
|
||||||
@@ -1910,16 +1899,16 @@ hooks:
|
|||||||
assert_eq!(prompt_script::prompts_asked(), 1);
|
assert_eq!(prompt_script::prompts_asked(), 1);
|
||||||
assert_eq!(sticky, StickyMode::ReplaceAll);
|
assert_eq!(sticky, StickyMode::ReplaceAll);
|
||||||
assert!(
|
assert!(
|
||||||
fs::read_to_string(¬ify)
|
read_to_string(¬ify)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.starts_with("#!/usr/bin/env bash")
|
.starts_with("#!/usr/bin/env bash")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_to_string(role_dir.join("hook.sh")).unwrap(),
|
read_to_string(role_dir.join("hook.sh")).unwrap(),
|
||||||
"shipped\n"
|
"shipped\n"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_to_string(agent_dir.join("hook.sh")).unwrap(),
|
read_to_string(agent_dir.join("hook.sh")).unwrap(),
|
||||||
"shipped\n"
|
"shipped\n"
|
||||||
);
|
);
|
||||||
drop(script);
|
drop(script);
|
||||||
@@ -1931,13 +1920,13 @@ hooks:
|
|||||||
refresh(&mut sticky);
|
refresh(&mut sticky);
|
||||||
assert_eq!(prompt_script::prompts_asked(), 1);
|
assert_eq!(prompt_script::prompts_asked(), 1);
|
||||||
assert_eq!(sticky, StickyMode::KeepAll);
|
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!(
|
assert_eq!(
|
||||||
fs::read_to_string(role_dir.join("hook.sh")).unwrap(),
|
read_to_string(role_dir.join("hook.sh")).unwrap(),
|
||||||
"local role"
|
"local role"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_to_string(agent_dir.join("hook.sh")).unwrap(),
|
read_to_string(agent_dir.join("hook.sh")).unwrap(),
|
||||||
"local agent"
|
"local agent"
|
||||||
);
|
);
|
||||||
drop(script);
|
drop(script);
|
||||||
@@ -1958,12 +1947,12 @@ hooks:
|
|||||||
let script = prompt_script::install(&[]);
|
let script = prompt_script::install(&[]);
|
||||||
install_builtins().unwrap();
|
install_builtins().unwrap();
|
||||||
assert_eq!(prompt_script::prompts_asked(), 0);
|
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);
|
drop(script);
|
||||||
|
|
||||||
let _script = prompt_script::install_non_interactive();
|
let _script = prompt_script::install_non_interactive();
|
||||||
install_builtins().unwrap();
|
install_builtins().unwrap();
|
||||||
assert_eq!(prompt_script::prompts_asked(), 0);
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,8 +417,6 @@ impl RequestContext {
|
|||||||
working_mode: WorkingMode,
|
working_mode: WorkingMode,
|
||||||
info_flag: bool,
|
info_flag: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// 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() {
|
let model = if info_flag && app.config.model_id.is_empty() {
|
||||||
Model::default()
|
Model::default()
|
||||||
} else {
|
} else {
|
||||||
@@ -4971,15 +4969,6 @@ impl RequestContext {
|
|||||||
return;
|
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()) {
|
if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) {
|
||||||
hooks::fire(
|
hooks::fire(
|
||||||
HookEvent::AgentInterrupted,
|
HookEvent::AgentInterrupted,
|
||||||
@@ -10449,7 +10438,7 @@ mod tests {
|
|||||||
// test deadlines. Every spawned script gets its own throwaway lock path.
|
// test deadlines. Every spawned script gets its own throwaway lock path.
|
||||||
fn adversary_script_command(script: &str, state: &serde_json::Value) -> Command {
|
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);
|
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("assets/agents/adversary/scripts")
|
||||||
.join(script);
|
.join(script);
|
||||||
let lock_path = env::temp_dir().join(format!(
|
let lock_path = env::temp_dir().join(format!(
|
||||||
|
|||||||
+2
-16
@@ -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) {
|
fn fire(self, error: Option<&str>, child_abort: &AbortSignal) {
|
||||||
let mut extras = vec![
|
let mut extras = vec![
|
||||||
("COYOTE_AGENT_ID", self.agent_id),
|
("COYOTE_AGENT_ID", self.agent_id),
|
||||||
@@ -1783,13 +1776,6 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result<Val
|
|||||||
let from_agent = request.from_agent_name.clone();
|
let from_agent = request.from_agent_name.clone();
|
||||||
let question = request.question.clone();
|
let question = request.question.clone();
|
||||||
let _ = request.reply_tx.send(reply.to_string());
|
let _ = request.reply_tx.send(reply.to_string());
|
||||||
// Origin envs come from the taken request, not this (replying)
|
|
||||||
// context, so `escalation.answered` and `escalation.raised`
|
|
||||||
// describe the same agent for one escalation id. "Answered"
|
|
||||||
// means a parent submitted an answer, not that the asker
|
|
||||||
// received it: the reply_tx.send failure is ignored by design,
|
|
||||||
// so the event also fires for an escalation whose asker already
|
|
||||||
// timed out or was cancelled.
|
|
||||||
hooks::fire(
|
hooks::fire(
|
||||||
HookEvent::EscalationAnswered,
|
HookEvent::EscalationAnswered,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -3414,10 +3400,10 @@ mod tests {
|
|||||||
fn reply_escalation_fires_answered_once_and_never_on_failure_arms() {
|
fn reply_escalation_fires_answered_once_and_never_on_failure_arms() {
|
||||||
let _sink = hooks::test_sink::install();
|
let _sink = hooks::test_sink::install();
|
||||||
let marker = "t049_answered_once";
|
let marker = "t049_answered_once";
|
||||||
let mut hooks_map = crate::hooks::HooksMap::default();
|
let mut hooks_map = hooks::HooksMap::default();
|
||||||
hooks_map.insert(
|
hooks_map.insert(
|
||||||
"escalation.answered".to_string(),
|
"escalation.answered".to_string(),
|
||||||
vec![crate::hooks::HookDef {
|
vec![hooks::HookDef {
|
||||||
name: marker.to_string(),
|
name: marker.to_string(),
|
||||||
command: "true".to_string(),
|
command: "true".to_string(),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -111,8 +111,6 @@ impl JobHookSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fires `job.started` for a freshly accepted job; both `handle_start`
|
|
||||||
/// branches (MCP and process) dispatch through here.
|
|
||||||
fn fire_job_started(ctx: &RequestContext, job_id: &str, tool: &str) {
|
fn fire_job_started(ctx: &RequestContext, job_id: &str, tool: &str) {
|
||||||
hooks::fire(
|
hooks::fire(
|
||||||
HookEvent::JobStarted,
|
HookEvent::JobStarted,
|
||||||
|
|||||||
+13
-17
@@ -2694,7 +2694,7 @@ impl ToolCallTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String {
|
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &Value) -> String {
|
||||||
if *NO_COLOR {
|
if *NO_COLOR {
|
||||||
return format!("Call {cmd_name} {}", cmd_args.join(" "));
|
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 {
|
fn format_json_colored_keys(value: &Value) -> String {
|
||||||
let serde_json::Value::Object(map) = value else {
|
let Value::Object(map) = value else {
|
||||||
return dimmed_text(&value.to_string());
|
return dimmed_text(&value.to_string());
|
||||||
};
|
};
|
||||||
if map.is_empty() {
|
if map.is_empty() {
|
||||||
@@ -3486,16 +3486,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bundled_bash_tools_generate_declarations() {
|
fn bundled_bash_tools_generate_declarations() {
|
||||||
let tools_dir =
|
let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
|
||||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools");
|
|
||||||
let mut checked = Vec::new();
|
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();
|
let path = entry.unwrap().path();
|
||||||
if path.extension().and_then(OsStr::to_str) != Some("sh") {
|
if path.extension().and_then(OsStr::to_str) != Some("sh") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = path.file_stem().unwrap().to_string_lossy().to_string();
|
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)
|
let (_, declarations) = bash::build_bash_tool(&src, &name)
|
||||||
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
|
.unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4673,7 +4672,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("nonzero exit must return an error payload");
|
.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!(
|
assert!(
|
||||||
json["tool_call_error"]
|
json["tool_call_error"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -4702,7 +4701,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("nonzero exit must return an error payload");
|
.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!(
|
assert!(
|
||||||
json["tool_call_error"]
|
json["tool_call_error"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -4741,7 +4740,7 @@ mod tests {
|
|||||||
let elapsed = started.elapsed();
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
assert!(elapsed < Duration::from_secs(1), "took {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");
|
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
|
// A shim written moments ago can still be open for writing somewhere
|
||||||
// in the process (schedule-dependent), making exec fail with
|
// in the process (schedule-dependent), making exec fail with
|
||||||
// ETXTBSY; retry briefly instead of flaking.
|
// ETXTBSY; retry briefly instead of flaking.
|
||||||
fn output_with_etxtbsy_retry(
|
fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result<process::Output> {
|
||||||
command: &mut process::Command,
|
|
||||||
) -> std::io::Result<process::Output> {
|
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
loop {
|
loop {
|
||||||
match command.output() {
|
match command.output() {
|
||||||
Err(err)
|
Err(err)
|
||||||
if err.kind() == std::io::ErrorKind::ExecutableFileBusy
|
if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 =>
|
||||||
&& attempts < 10 =>
|
|
||||||
{
|
{
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
ret => return ret,
|
ret => return ret,
|
||||||
}
|
}
|
||||||
@@ -4907,7 +4903,7 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut command = process::Command::new(&moved_shim);
|
let mut command = Command::new(&moved_shim);
|
||||||
command
|
command
|
||||||
.arg("{}")
|
.arg("{}")
|
||||||
.env_remove(&config_env)
|
.env_remove(&config_env)
|
||||||
|
|||||||
@@ -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<Value> {
|
fn handle_direct(action: &str, args: &Value) -> Result<Value> {
|
||||||
match action {
|
match action {
|
||||||
"select" => handle_direct_ask(args),
|
"select" => handle_direct_ask(args),
|
||||||
|
|||||||
@@ -50,14 +50,6 @@ impl AgentNodeExecutor {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Err(e) = &result {
|
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
|
if parent_ctx
|
||||||
.session_abort
|
.session_abort
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ impl ApprovalNodeExecutor {
|
|||||||
.interpolate(&node.question)
|
.interpolate(&node.question)
|
||||||
.context("Failed to interpolate approval 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(
|
let response = handle_user_tool(
|
||||||
ctx,
|
ctx,
|
||||||
&format!("{USER_FUNCTION_PREFIX}select"),
|
&format!("{USER_FUNCTION_PREFIX}select"),
|
||||||
|
|||||||
+64
-116
@@ -14,7 +14,7 @@ use std::collections::BTreeSet;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime};
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::Notify;
|
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<String> = HookAssets::iter()
|
let shipped: BTreeSet<String> = HookAssets::iter()
|
||||||
.map(|file| file.as_ref().to_string())
|
.map(|file| file.as_ref().to_string())
|
||||||
.filter(|name| !name.contains('/'))
|
.filter(|name| !name.contains('/'))
|
||||||
.collect();
|
.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) =
|
if let Err(err) =
|
||||||
builtin_manifest::reconcile_builtin_dir(&paths::hooks_dir(), &shipped, &written)
|
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 hooks_dir = canonical_or_original(&paths::hooks_dir());
|
||||||
let default_hooks_dir = canonical_or_original(&paths::config_dir().join("hooks"));
|
let default_hooks_dir = canonical_or_original(&paths::config_dir().join("hooks"));
|
||||||
if wrote_any && hooks_dir != default_hooks_dir {
|
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 `<event>.*` from a typo over a nonexistent event.
|
|
||||||
const ALL: [HookEvent; 26] = [
|
const ALL: [HookEvent; 26] = [
|
||||||
HookEvent::TurnStarted,
|
HookEvent::TurnStarted,
|
||||||
HookEvent::TurnCompleted,
|
HookEvent::TurnCompleted,
|
||||||
@@ -260,12 +249,6 @@ fn resolve_hooks(
|
|||||||
let event_name = event.as_str();
|
let event_name = event.as_str();
|
||||||
let mut resolved = Vec::new();
|
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 {
|
let agent_gate = match event {
|
||||||
HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None,
|
HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None,
|
||||||
_ => agent_gate,
|
_ => agent_gate,
|
||||||
@@ -296,12 +279,6 @@ fn resolve_hooks(
|
|||||||
{
|
{
|
||||||
let prefix = format!("{event_name}.");
|
let prefix = format!("{event_name}.");
|
||||||
for entry in gate {
|
for entry in gate {
|
||||||
// Entries containing `*` are grammatical only in the wildcard
|
|
||||||
// forms `*`, `*.*`, `*.<name>`, and `<event>.*` 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.contains('*') {
|
||||||
if !entry_is_valid_wildcard(entry) {
|
if !entry_is_valid_wildcard(entry) {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -341,12 +318,6 @@ fn resolve_hooks(
|
|||||||
resolved
|
resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whitelist grammar for a `global_hooks` entry: `<event>.<name>` exact,
|
|
||||||
/// `<event>.*` (every hook of that event), `*.<name>` (that hook name on
|
|
||||||
/// every event), and `*` / `*.*` (everything). Event names contain dots
|
|
||||||
/// while hook names never do, so `*.<name>` 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 {
|
fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: &str) -> bool {
|
||||||
if entry == full_name || entry == "*" || entry == "*.*" {
|
if entry == full_name || entry == "*" || entry == "*.*" {
|
||||||
return true;
|
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)
|
entry.strip_suffix(".*") == Some(event_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when a `*`-containing gate entry is one of the wildcard forms
|
|
||||||
/// `gate_entry_admits` understands. `*.<name>` takes a literal hook name
|
|
||||||
/// (globbing inside the name is not supported), and `<event>.*` 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 {
|
fn entry_is_valid_wildcard(entry: &str) -> bool {
|
||||||
if entry == "*" || entry == "*.*" {
|
if entry == "*" || entry == "*.*" {
|
||||||
return true;
|
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(
|
pub(crate) fn base_envs_parts(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
session_name: Option<&str>,
|
session_name: Option<&str>,
|
||||||
@@ -596,12 +556,6 @@ fn payload_dir() -> PathBuf {
|
|||||||
|
|
||||||
const STALE_PAYLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
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() {
|
pub fn sweep_stale_payload_files() {
|
||||||
sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE);
|
sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE);
|
||||||
}
|
}
|
||||||
@@ -618,7 +572,7 @@ fn sweep_payload_files_older_than(max_age: Duration) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let now = std::time::SystemTime::now();
|
let now = SystemTime::now();
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let Some(name) = name.to_str() else {
|
let Some(name) = name.to_str() else {
|
||||||
@@ -643,6 +597,7 @@ fn sweep_payload_files_older_than(max_age: Duration) {
|
|||||||
if !stale {
|
if !stale {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match std::fs::remove_file(entry.path()) {
|
match std::fs::remove_file(entry.path()) {
|
||||||
Ok(()) => debug!("Removed stale hook payload file {}", entry.path().display()),
|
Ok(()) => debug!("Removed stale hook payload file {}", entry.path().display()),
|
||||||
Err(err) => debug!(
|
Err(err) => debug!(
|
||||||
@@ -840,9 +795,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::conflict::prompt_script;
|
use crate::config::conflict::prompt_script;
|
||||||
use crate::config::{AppConfig, AppState, Role, WorkingMode};
|
use crate::config::{AppConfig, AppState, Role, WorkingMode};
|
||||||
|
use crate::{testing, utils};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
fn hooks_map(event: &str, defs: &[(&str, &str)]) -> HooksMap {
|
fn hooks_map(event: &str, defs: &[(&str, &str)]) -> HooksMap {
|
||||||
let defs = defs
|
let defs = defs
|
||||||
@@ -871,22 +828,19 @@ mod tests {
|
|||||||
/// Points the hooks dir at a fresh temp directory for the guard's
|
/// Points the hooks dir at a fresh temp directory for the guard's
|
||||||
/// lifetime and removes it on drop. Tests using it must serialize.
|
/// lifetime and removes it on drop. Tests using it must serialize.
|
||||||
struct HooksDirGuard {
|
struct HooksDirGuard {
|
||||||
_env: crate::testing::EnvVarGuard,
|
_env: testing::EnvVarGuard,
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HooksDirGuard {
|
impl HooksDirGuard {
|
||||||
fn new(label: &str) -> Self {
|
fn new(label: &str) -> Self {
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let root = env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
let root = env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
||||||
Self {
|
Self {
|
||||||
_env: crate::testing::EnvVarGuard::set(
|
_env: testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &root),
|
||||||
crate::utils::get_env_name("hooks_dir"),
|
|
||||||
&root,
|
|
||||||
),
|
|
||||||
root,
|
root,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -938,8 +892,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fresh_payload_dir(label: &str) -> PathBuf {
|
fn fresh_payload_dir(label: &str) -> PathBuf {
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let dir = env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
let dir = env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
||||||
@@ -1026,13 +980,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn install_builtin_hooks_installs_executable_scripts_and_honors_force() {
|
fn install_builtin_hooks_installs_executable_scripts_and_honors_force() {
|
||||||
let env_name = crate::utils::get_env_name("hooks_dir");
|
let env_name = utils::get_env_name("hooks_dir");
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}"));
|
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
|
// Capture every outcome first and assert only after cleanup: the
|
||||||
// guard restores the env var even on panic, but the temp dir removal
|
// guard restores the env var even on panic, but the temp dir removal
|
||||||
@@ -1222,16 +1176,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() {
|
fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-respelled");
|
let guard = testing::TestConfigDirGuard::new("hooks-warn-respelled");
|
||||||
let guard_path_display = guard.path.display().to_string();
|
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
|
// An override that reaches the default `config_dir()/hooks` through a
|
||||||
// `..` hop is the default path in disguise: the canonicalized compare
|
// `..` hop is the default path in disguise: the canonicalized compare
|
||||||
// must stay quiet even though the strings differ.
|
// must stay quiet even though the strings differ.
|
||||||
let alias = guard.path.join("hooks").join("..").join("hooks");
|
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();
|
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
|
||||||
drop(env_guard);
|
drop(env_guard);
|
||||||
|
|
||||||
@@ -1249,7 +1203,7 @@ mod tests {
|
|||||||
let link = guard.path.join("hooks-link");
|
let link = guard.path.join("hooks-link");
|
||||||
std::os::unix::fs::symlink(guard.path.join("hooks"), &link).unwrap();
|
std::os::unix::fs::symlink(guard.path.join("hooks"), &link).unwrap();
|
||||||
std::fs::remove_file(&default_notify).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();
|
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
default_notify.is_file(),
|
default_notify.is_file(),
|
||||||
@@ -1259,7 +1213,7 @@ mod tests {
|
|||||||
|
|
||||||
// The warn buffer is process-global, so scope the check to messages
|
// The warn buffer is process-global, so scope the check to messages
|
||||||
// naming this test's unique directory.
|
// naming this test's unique directory.
|
||||||
let warns = crate::testing::warn_snapshot();
|
let warns = testing::warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
warns.iter().all(|message| {
|
warns.iter().all(|message| {
|
||||||
!(message.contains("overrides the hooks dir")
|
!(message.contains("overrides the hooks dir")
|
||||||
@@ -1272,13 +1226,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn install_builtin_hooks_genuine_override_warns_when_default_dir_is_missing() {
|
fn install_builtin_hooks_genuine_override_warns_when_default_dir_is_missing() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-genuine");
|
let guard = testing::TestConfigDirGuard::new("hooks-warn-genuine");
|
||||||
let override_dir = guard.path.join("elsewhere-hooks");
|
let override_dir = guard.path.join("elsewhere-hooks");
|
||||||
let _env_guard = crate::testing::EnvVarGuard::set(
|
let _env_guard = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &override_dir);
|
||||||
crate::utils::get_env_name("hooks_dir"),
|
|
||||||
&override_dir,
|
|
||||||
);
|
|
||||||
|
|
||||||
install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap();
|
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"
|
"scripts must land in the override dir, so wrote_any was true"
|
||||||
);
|
);
|
||||||
let override_display = override_dir.display().to_string();
|
let override_display = override_dir.display().to_string();
|
||||||
let warns = crate::testing::warn_snapshot();
|
let warns = testing::warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
warns.iter().any(|message| {
|
warns.iter().any(|message| {
|
||||||
message.contains("overrides the hooks dir") && message.contains(&override_display)
|
message.contains("overrides the hooks dir") && message.contains(&override_display)
|
||||||
@@ -1683,7 +1634,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wildcard_whitelist_entries_skip_unknown_entry_diagnostics() {
|
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 global = hooks_map("turn.started", &[("a", "cmd-a")]);
|
||||||
let gate = vec![
|
let gate = vec![
|
||||||
"turn.started.*".to_string(),
|
"turn.started.*".to_string(),
|
||||||
@@ -1701,7 +1652,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(names(&resolved), ["a"]);
|
assert_eq!(names(&resolved), ["a"]);
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1712,7 +1663,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_glob_whitelist_entries_stay_flagged_as_unknown() {
|
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 global = hooks_map("turn.started", &[("a", "cmd-a")]);
|
||||||
let gate = vec!["turn.started.st*-marker-k4w".to_string()];
|
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");
|
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| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("turn.started.st*-marker-k4w")
|
message.contains("turn.started.st*-marker-k4w")
|
||||||
&& message.contains("for agent 'glob-flag-agent-k4w'")
|
&& message.contains("for agent 'glob-flag-agent-k4w'")
|
||||||
@@ -1734,7 +1685,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_glob_over_event_prefix_logs_invalid_glob_diagnostic() {
|
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")]);
|
let global = hooks_map("tool.started", &[("st", "cmd-st")]);
|
||||||
// `tool.st*` extends no full event name (`tool` is not an event), so
|
// `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
|
// 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");
|
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| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("tool.st*") && message.contains("for agent 'prefix-glob-agent-t7c'")
|
message.contains("tool.st*") && message.contains("for agent 'prefix-glob-agent-t7c'")
|
||||||
}));
|
}));
|
||||||
@@ -1758,7 +1709,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inner_glob_entries_log_invalid_glob_diagnostic() {
|
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 global = hooks_map("agent.completed", &[("mark", "cmd-mark")]);
|
||||||
let gate = vec!["ag*.completed.mark".to_string()];
|
let gate = vec!["ag*.completed.mark".to_string()];
|
||||||
|
|
||||||
@@ -1771,7 +1722,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(resolved.is_empty(), "an inner glob admits nothing");
|
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| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("ag*.completed.mark")
|
message.contains("ag*.completed.mark")
|
||||||
&& message.contains("for agent 'inner-glob-agent-m2r'")
|
&& message.contains("for agent 'inner-glob-agent-m2r'")
|
||||||
@@ -1780,7 +1731,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_whitelist_entries_log_debug_and_stay_inert() {
|
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 global = hooks_map("turn.started", &[("a", "cmd-a")]);
|
||||||
let gate = vec![
|
let gate = vec![
|
||||||
"turn.started.a".to_string(),
|
"turn.started.a".to_string(),
|
||||||
@@ -1796,7 +1747,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(names(&resolved), ["a"]);
|
assert_eq!(names(&resolved), ["a"]);
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("turn.started.nonexistent-marker-xyz")
|
message.contains("turn.started.nonexistent-marker-xyz")
|
||||||
&& message.contains("for agent 'gate-agent-xyz'")
|
&& message.contains("for agent 'gate-agent-xyz'")
|
||||||
@@ -1805,7 +1756,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_global_map_short_circuits_whitelist_diagnostics() {
|
fn empty_global_map_short_circuits_whitelist_diagnostics() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let global = HooksMap::default();
|
let global = HooksMap::default();
|
||||||
let role = hooks_map("turn.started", &[("role-only", "role-cmd")]);
|
let role = hooks_map("turn.started", &[("role-only", "role-cmd")]);
|
||||||
let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()];
|
let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()];
|
||||||
@@ -1819,7 +1770,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(names(&resolved), ["role-only"]);
|
assert_eq!(names(&resolved), ["role-only"]);
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2084,12 +2035,12 @@ mod tests {
|
|||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn drain_pending_logs_when_the_timeout_expires() {
|
async fn drain_pending_logs_when_the_timeout_expires() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let pending = SpawnAckGuard::register();
|
let pending = SpawnAckGuard::register();
|
||||||
|
|
||||||
drain_pending(Duration::from_millis(50)).await;
|
drain_pending(Duration::from_millis(50)).await;
|
||||||
|
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2116,7 +2067,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn fire_resolved_without_runtime_skips_instead_of_panicking() {
|
fn fire_resolved_without_runtime_skips_instead_of_panicking() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let hook = ResolvedHook {
|
let hook = ResolvedHook {
|
||||||
name: "no-runtime-marker-a7c".to_string(),
|
name: "no-runtime-marker-a7c".to_string(),
|
||||||
full_name: "turn.failed.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);
|
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| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("no-runtime-marker-a7c")
|
message.contains("no-runtime-marker-a7c")
|
||||||
&& message.contains("no Tokio runtime on this thread")
|
&& message.contains("no Tokio runtime on this thread")
|
||||||
@@ -2136,7 +2087,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn fire_without_runtime_skips_instead_of_panicking() {
|
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(
|
let ctx = ctx_with_global_hooks(hooks_map(
|
||||||
"turn.failed",
|
"turn.failed",
|
||||||
&[("fire-no-runtime-marker-b8d", "true")],
|
&[("fire-no-runtime-marker-b8d", "true")],
|
||||||
@@ -2144,7 +2095,7 @@ mod tests {
|
|||||||
|
|
||||||
fire(HookEvent::TurnFailed, &ctx, &[], None);
|
fire(HookEvent::TurnFailed, &ctx, &[], None);
|
||||||
|
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("fire-no-runtime-marker-b8d")
|
message.contains("fire-no-runtime-marker-b8d")
|
||||||
&& message.contains("no Tokio runtime on this thread")
|
&& message.contains("no Tokio runtime on this thread")
|
||||||
@@ -2153,7 +2104,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_capture_excludes_non_hooks_targets() {
|
fn debug_capture_excludes_non_hooks_targets() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let hook = ResolvedHook {
|
let hook = ResolvedHook {
|
||||||
name: "included-marker-k2v".to_string(),
|
name: "included-marker-k2v".to_string(),
|
||||||
full_name: "turn.failed.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);
|
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
||||||
|
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
debugs
|
debugs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2192,9 +2143,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn debug_capture_recovers_after_buffer_poisoning() {
|
fn debug_capture_recovers_after_buffer_poisoning() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let _held = crate::testing::debug_messages().lock().unwrap();
|
let _held = testing::debug_messages().lock().unwrap();
|
||||||
panic!("poison the debug buffer");
|
panic!("poison the debug buffer");
|
||||||
})
|
})
|
||||||
.join()
|
.join()
|
||||||
@@ -2208,7 +2159,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None);
|
||||||
|
|
||||||
let mut debugs = crate::testing::debug_messages()
|
let mut debugs = testing::debug_messages()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
@@ -2255,7 +2206,7 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn spawn_failure_removes_the_payload_file() {
|
async fn spawn_failure_removes_the_payload_file() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let guard = TestConfigDirGuard::new("hooks-dispatch");
|
let guard = TestConfigDirGuard::new("hooks-dispatch");
|
||||||
let payload_dir = guard.path.join("payloads");
|
let payload_dir = guard.path.join("payloads");
|
||||||
create_dir_all(&payload_dir).unwrap();
|
create_dir_all(&payload_dir).unwrap();
|
||||||
@@ -2281,12 +2232,12 @@ mod tests {
|
|||||||
// does the empty directory below demonstrate the orphan unlink
|
// does the empty directory below demonstrate the orphan unlink
|
||||||
// rather than a payload file that never existed.
|
// rather than a payload file that never existed.
|
||||||
wait_for("spawn failure log", || {
|
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'")
|
message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let debugs = crate::testing::debug_snapshot();
|
let debugs = testing::debug_snapshot();
|
||||||
assert!(debugs.iter().all(|message| {
|
assert!(debugs.iter().all(|message| {
|
||||||
!(message.contains("spawnfail-marker-c9d")
|
!(message.contains("spawnfail-marker-c9d")
|
||||||
&& message.contains("Failed to write payload file"))
|
&& message.contains("Failed to write payload file"))
|
||||||
@@ -2305,10 +2256,10 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn error_paths_log_debug_only() {
|
async fn error_paths_log_debug_only() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let empty_cwd = env::temp_dir();
|
let empty_cwd = env::temp_dir();
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let bad_cwd = env::temp_dir().join(format!("coyote-badcwd-marker-f5b-{unique}"));
|
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);
|
fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None);
|
||||||
drain_pending(Duration::from_secs(10)).await;
|
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();
|
let empty_cwd_display = empty_cwd.display().to_string();
|
||||||
assert!(debugs.iter().any(|message| {
|
assert!(debugs.iter().any(|message| {
|
||||||
message.contains("empty-marker-f5b")
|
message.contains("empty-marker-f5b")
|
||||||
@@ -2342,7 +2293,7 @@ mod tests {
|
|||||||
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
|
message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'")
|
||||||
&& message.contains(bad_cwd_display.as_str())
|
&& 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")));
|
assert!(warns.iter().all(|message| !message.contains("marker-f5b")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2488,7 +2439,7 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn failing_hook_never_disturbs_the_engine() {
|
async fn failing_hook_never_disturbs_the_engine() {
|
||||||
crate::testing::install_log_collector();
|
testing::install_log_collector();
|
||||||
let guard = TestConfigDirGuard::new("hooks-dispatch");
|
let guard = TestConfigDirGuard::new("hooks-dispatch");
|
||||||
let first = guard.path.join("first-out");
|
let first = guard.path.join("first-out");
|
||||||
let second = guard.path.join("second-out");
|
let second = guard.path.join("second-out");
|
||||||
@@ -2514,7 +2465,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
wait_for("second hook output", || second.exists()).await;
|
wait_for("second hook output", || second.exists()).await;
|
||||||
|
|
||||||
let warns = crate::testing::warn_snapshot();
|
let warns = testing::warn_snapshot();
|
||||||
assert!(
|
assert!(
|
||||||
warns
|
warns
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2558,7 +2509,7 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn payload_write_failure_still_runs_hook_without_payload_env() {
|
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 guard = TestConfigDirGuard::new("hooks-dispatch");
|
||||||
let out = guard.path.join("env-out");
|
let out = guard.path.join("env-out");
|
||||||
let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir"));
|
let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir"));
|
||||||
@@ -2582,7 +2533,7 @@ mod tests {
|
|||||||
.lines()
|
.lines()
|
||||||
.all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE="))
|
.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(
|
assert!(debugs.iter().any(|message| message.contains(
|
||||||
"Failed to write payload file for hook 'tool.started.payload-fallback-marker'"
|
"Failed to write payload file for hook 'tool.started.payload-fallback-marker'"
|
||||||
)));
|
)));
|
||||||
@@ -2674,7 +2625,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -2818,18 +2769,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn install_builtin_hooks_lands_under_a_backslashed_override() {
|
fn install_builtin_hooks_lands_under_a_backslashed_override() {
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let hooks_dir = PathBuf::from(format!(
|
let hooks_dir = PathBuf::from(format!(
|
||||||
"{}\\coyote-win-bs-{unique}\\hooks",
|
"{}\\coyote-win-bs-{unique}\\hooks",
|
||||||
env::temp_dir().display()
|
env::temp_dir().display()
|
||||||
));
|
));
|
||||||
let _env = crate::testing::EnvVarGuard::set(
|
let _env = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &hooks_dir);
|
||||||
crate::utils::get_env_name("hooks_dir"),
|
|
||||||
&hooks_dir,
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None);
|
let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None);
|
||||||
|
|
||||||
|
|||||||
+1
-30
@@ -58,15 +58,6 @@ use std::path::PathBuf;
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::{env, fs, process, sync::Arc};
|
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;
|
const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
/// Bootstraps the async entry point on a dedicated thread with an explicit
|
/// 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_macros
|
||||||
|| cli.list_skills
|
|| cli.list_skills
|
||||||
|| cli.list_sessions;
|
|| 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
|
let readout_flag = (cli.info
|
||||||
|| cli.list_models
|
|| cli.list_models
|
||||||
|| cli.list_roles
|
|| cli.list_roles
|
||||||
@@ -191,10 +178,6 @@ async fn async_main() -> Result<()> {
|
|||||||
return config::list_installed_bundles();
|
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 =
|
let mcp_inspect =
|
||||||
cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none();
|
cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none();
|
||||||
let vault_inspect = cli.list_secrets
|
let vault_inspect = cli.list_secrets
|
||||||
@@ -297,10 +280,6 @@ async fn async_main() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
AppConfig::from_config(cfg)?
|
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 {
|
let vault = if mcp_inspect {
|
||||||
Vault::init_lenient(&app_config)
|
Vault::init_lenient(&app_config)
|
||||||
} else {
|
} else {
|
||||||
@@ -351,9 +330,6 @@ async fn async_main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
|
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
|
||||||
let app_config = Arc::clone(&ctx.app.config);
|
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 {
|
if !inspection_only {
|
||||||
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
|
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -488,7 +464,7 @@ async fn run(
|
|||||||
// return early without running the agent, and an open bracket on
|
// return early without running the agent, and an open bracket on
|
||||||
// those paths would leak `agent.started` with no terminal event.
|
// those paths would leak `agent.started` with no terminal event.
|
||||||
// It opens at the dispatch sites further down, where a run is
|
// 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.
|
// rather than inspects.
|
||||||
} else {
|
} else {
|
||||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||||
@@ -622,8 +598,6 @@ async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(name) = &cli.macro_name {
|
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();
|
ctx.top_level_agent_started();
|
||||||
let result = macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await;
|
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));
|
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 {
|
if code == 0 && app.save_shell_history {
|
||||||
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
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);
|
ctx.top_level_agent_finished(None, None);
|
||||||
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||||
process::exit(code);
|
process::exit(code);
|
||||||
|
|||||||
@@ -527,9 +527,6 @@ Type ".help" for additional help.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(supervisor) = self.ctx.read().supervisor.clone() {
|
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();
|
supervisor.read().cancel_recursive();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1718,13 +1715,6 @@ fn reset_continuation(ctx: &mut RequestContext) {
|
|||||||
ctx.reset_continuation_count();
|
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<Supervisor>) {
|
pub(crate) fn latch_prompt_interrupt(abort_signal: &AbortSignal, supervisor: &RwLock<Supervisor>) {
|
||||||
let supervisor = supervisor.read();
|
let supervisor = supervisor.read();
|
||||||
if supervisor.has_active_tasks() {
|
if supervisor.has_active_tasks() {
|
||||||
|
|||||||
@@ -197,11 +197,6 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
|||||||
) {
|
) {
|
||||||
out.push(read_mixin(path)?);
|
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/<name>/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 agent_dir in subdirs_of(&paths::agents_data_dir()) {
|
||||||
for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) {
|
for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) {
|
||||||
out.push(read_mixin(path)?);
|
out.push(read_mixin(path)?);
|
||||||
|
|||||||
@@ -190,11 +190,6 @@ impl Supervisor {
|
|||||||
.count()
|
.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 {
|
pub fn has_active_tasks(&self) -> bool {
|
||||||
self.effective_active_count() > 0
|
self.effective_active_count() > 0
|
||||||
|| self.active_job_count() > 0
|
|| self.active_job_count() > 0
|
||||||
|
|||||||
@@ -332,8 +332,6 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cli.list_secrets {
|
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 {
|
if vault.sandbox_mode {
|
||||||
println!(
|
println!(
|
||||||
"The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host."
|
"The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host."
|
||||||
|
|||||||
Reference in new issue
Block a user