#!/usr/bin/env bash # Sandboxed selftests for the mission/task layer. # # Fast cases are validation-only (no Docker, no network). The final cases # execute real tasks through the container path and therefore require a # working configuration, credentials, and Docker. set -uo pipefail cd "$(dirname "$0")/.." SANDBOX="$(mktemp -d)" trap 'rm -rf "$SANDBOX"' EXIT PASS=0 FAIL=0 expect_exit() { local name="$1" expected="$2" shift 3 local rc "$@" >/dev/null 2>&1 rc=$? if [ "$rc" -eq "$expected" ]; then PASS=$((PASS+1)); echo "ok $name (exit $rc)" else FAIL=$((FAIL+1)); echo "FAIL $name (exit $rc, expected $expected)" fi } check() { if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi } latest_reason() { local latest latest="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)" [ -n "$latest" ] && node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.reason??"")}catch{console.log("")}' "$latest/result.json" 2>/dev/null } check() { if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi } CONFIG="$SANDBOX/config.json" DATA_ROOT="$SANDBOX/data" mkdir -p "$DATA_ROOT" cat > "$CONFIG" < "$1" < "$1" <<'EOF' {"missionVersion":1,"id":"m-ok","objective":"Prove the marker path.","directives":["Be terse."]} EOF } # ---------- fast: schema negatives ---------- good_task "$SANDBOX/ok.json" expect_exit "valid task validates" 0 -- $TASK validate "$SANDBOX/ok.json" printf '{"taskVersion":1,"id":"t-x","prompt":"hi","extra":1}' > "$SANDBOX/unknownkey.json" expect_exit "unknown task key exits 2" 2 -- $TASK validate "$SANDBOX/unknownkey.json" printf '{"taskVersion":2,"id":"t-x","prompt":"hi"}' > "$SANDBOX/badver.json" expect_exit "unsupported taskVersion exits 2" 2 -- $TASK validate "$SANDBOX/badver.json" printf '{"taskVersion":1,"id":"BAD ID","prompt":"hi"}' > "$SANDBOX/badid.json" expect_exit "invalid task id exits 2" 2 -- $TASK validate "$SANDBOX/badid.json" printf '{"taskVersion":1,"id":"t-x","prompt":""}' > "$SANDBOX/emptyprompt.json" expect_exit "empty prompt exits 2" 2 -- $TASK validate "$SANDBOX/emptyprompt.json" printf '{"taskVersion":1,"id":"t-x","prompt":"hi","expectExact":"bad\\u0000nul"}' > "$SANDBOX/badexpect.json" expect_exit "NUL in expectExact exits 2" 2 -- $TASK validate "$SANDBOX/badexpect.json" printf '{"taskVersion":1,"id":"t-x","prompt":"hi","timeoutSeconds":9999}' > "$SANDBOX/badtimeout.json" expect_exit "out-of-range timeout exits 2" 2 -- $TASK validate "$SANDBOX/badtimeout.json" printf '{"taskVersion":1,"id":"t-x","prompt":"hi","mission":"missing.json"}' > "$SANDBOX/missmission.json" expect_exit "missing mission file exits 4" 4 -- $TASK validate "$SANDBOX/missmission.json" good_mission "$SANDBOX/m.json" printf '{"taskVersion":1,"id":"t-x","prompt":"hi","mission":"m.json"}' > "$SANDBOX/withmission.json" expect_exit "task with valid mission validates" 0 -- $TASK validate "$SANDBOX/withmission.json" printf '{"missionVersion":1,"id":"m-x","objective":"o","extra":1}' > "$SANDBOX/badmission.json" printf '{"taskVersion":1,"id":"t-x","prompt":"hi","mission":"badmission.json"}' > "$SANDBOX/withbadmission.json" expect_exit "invalid mission exits 2" 2 -- $TASK validate "$SANDBOX/withbadmission.json" expect_exit "validate missing task exits 4" 4 -- $TASK validate "$SANDBOX/nope.json" # validation writes nothing: task dir listing unchanged is implied; check mtime of ok.json M1=$(stat -c %Y "$SANDBOX/ok.json"); sleep 1.1 $TASK validate "$SANDBOX/ok.json" >/dev/null 2>&1 M2=$(stat -c %Y "$SANDBOX/ok.json") [ "$M1" = "$M2" ] && check "validation does not modify the task file" 0 || check "validation does not modify the task file" 1 # ---------- adapter seam: deterministic mock cases (Docker, no provider) ---------- if docker info >/dev/null 2>&1; then good_task "$SANDBOX/ok.json" mock_config() { # file adapter cat > "$SANDBOX/$1" < "$SANDBOX/mission-task.json" # Dedicated mission with distinctive directives for the injection assertion. cat > "$SANDBOX/m-seam.json" <<'EOF' {"missionVersion":1,"id":"m-seam","objective":"Prove the mission injection point.","directives":["Seam directive A.","Seam directive B."]} EOF printf '{"taskVersion":1,"id":"t-mission-seam","prompt":"ignored by mock","mission":"m-seam.json"}' > "$SANDBOX/seam-task.json" expect_exit "mission task runs via mock adapter" 0 -- \ env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \ scripts/run-task.sh run "$SANDBOX/seam-task.json" grep -q 'MISSION (runtime)' "$SANDBOX/data/system-prompt.md" \ && grep -q 'Seam directive A.' "$SANDBOX/data/system-prompt.md" \ && check "mission section injected into generated prompt" 0 \ || check "mission section injected into generated prompt" 1 else echo "skip adapter seam cases (docker daemon unavailable)" fi # ---------- live: real runs (Docker + credentials required) ---------- # On failure, surface the run record + agent stderr BEFORE the sandbox # cleanup destroys them. Never let a wrong-exit mask the real reason. dump_latest_run() { local latest latest="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)" if [ -n "$latest" ]; then echo "--- latest run evidence: $latest ---" >&2 cat "$latest/result.json" 2>/dev/null >&2 echo "--- stderr.txt (tail) ---" >&2 tail -8 "$latest/stderr.txt" 2>/dev/null >&2 else echo "--- no run dir was created at all ---" >&2 fi } latest_reason() { local latest latest="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)" [ -n "$latest" ] && node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.reason??"")}catch{console.log("")}' "$latest/result.json" 2>/dev/null } if docker info >/dev/null 2>&1; then RUNS1=$(ls "$DATA_ROOT/runs" 2>/dev/null | wc -l) if scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "ok live hello task succeeds with exact marker" else FAIL=$((FAIL+1)); echo "FAIL live hello task succeeds with exact marker" >&2 dump_latest_run fi R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the live run above [ -f "$DATA_ROOT/runs/$R1/result.json" ] && check "result.json written in run dir" 0 || check "result.json written in run dir" 1 node -e ' const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.expectedExact === "MOSAIC_HELLO_OK" ? 0 : 1); ' "$DATA_ROOT/runs/$R1/result.json" check "result.json contents are correct" $? printf '{"taskVersion":1,"id":"t-wrong","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_NOT_OK"}' > "$SANDBOX/wrong.json" scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1 RC=$? WRONG_REASON="$(latest_reason)" if [ "$RC" -eq 1 ] && [ "$WRONG_REASON" = "expect-mismatch" ]; then PASS=$((PASS+1)); echo "ok wrong expectExact fails with exit 1 (reason: expect-mismatch)" else FAIL=$((FAIL+1)); echo "FAIL wrong expectExact (exit $RC, reason: '${WRONG_REASON:-none}')" >&2 dump_latest_run fi RUNS2=$(ls "$DATA_ROOT/runs" | wc -l) [ "$RUNS2" -gt "${RUNS1:-0}" ] && check "each run gets a distinct run dir (no clobber)" 0 \ || check "each run gets a distinct run dir (no clobber)" 1 COUNT=$($TASK list | wc -l) [ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1 else echo "skip live task cases (docker unavailable)" fi echo echo "selftest: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]