agentic-loop 3.21.0 → 3.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/ralph/loop.sh +2 -2
- package/ralph/setup.sh +27 -4
- package/ralph/uat.sh +659 -10
- package/ralph/utils.sh +15 -1
package/package.json
CHANGED
package/ralph/loop.sh
CHANGED
|
@@ -334,8 +334,8 @@ $existing_signs
|
|
|
334
334
|
- Extract a single, actionable pattern that prevents this class of failure
|
|
335
335
|
- The pattern should be general enough to apply to future stories, not specific to this one
|
|
336
336
|
- NEVER include credentials, passwords, API keys, tokens, emails, or secrets in the pattern
|
|
337
|
-
Instead of: "Login with admin@example.com / Password123"
|
|
338
|
-
Write: "Use Playwright to login with test credentials from environment variables"
|
|
337
|
+
Instead of: \"Login with admin@example.com / Password123\"
|
|
338
|
+
Write: \"Use Playwright to login with test credentials from environment variables\"
|
|
339
339
|
- If the failure is trivial, unclear, or you can't extract a useful pattern, respond with just: NONE
|
|
340
340
|
- Category must be one of: backend, frontend, testing, general, database, security
|
|
341
341
|
|
package/ralph/setup.sh
CHANGED
|
@@ -307,6 +307,11 @@ setup_gitignore() {
|
|
|
307
307
|
".ralph/last_*"
|
|
308
308
|
".ralph/screenshots/"
|
|
309
309
|
".ralph/archive/"
|
|
310
|
+
".ralph/progress.txt"
|
|
311
|
+
".ralph/tool-log.txt"
|
|
312
|
+
".ralph/suggested-signs.txt"
|
|
313
|
+
".ralph/.preflight_cache"
|
|
314
|
+
".ralph/.lock"
|
|
310
315
|
".backups/"
|
|
311
316
|
".claude/settings.json"
|
|
312
317
|
)
|
|
@@ -353,9 +358,13 @@ setup_claude_hooks() {
|
|
|
353
358
|
|
|
354
359
|
# Copy hooks into the project (so they survive package moves)
|
|
355
360
|
mkdir -p "$project_hooks_dir"
|
|
356
|
-
cp "$src_hooks_dir"/*.sh "$project_hooks_dir/" 2>/dev/null
|
|
357
|
-
|
|
358
|
-
|
|
361
|
+
if cp "$src_hooks_dir"/*.sh "$project_hooks_dir/" 2>/dev/null; then
|
|
362
|
+
chmod +x "$project_hooks_dir"/*.sh 2>/dev/null || true
|
|
363
|
+
echo " Copied hooks to $project_hooks_dir/"
|
|
364
|
+
else
|
|
365
|
+
print_warning "Failed to copy hooks from $src_hooks_dir to $project_hooks_dir/"
|
|
366
|
+
echo " Hooks will reference package paths instead (may break after npm update)"
|
|
367
|
+
fi
|
|
359
368
|
|
|
360
369
|
# Note if global hooks exist
|
|
361
370
|
if [[ -d "$global_hooks_dir" ]] && ls -1 "$global_hooks_dir"/*.sh &>/dev/null; then
|
|
@@ -631,12 +640,26 @@ setup_mcp() {
|
|
|
631
640
|
tmp=$(mktemp)
|
|
632
641
|
jq '.mcpServers["chrome-devtools"] = {
|
|
633
642
|
"command": "npx",
|
|
634
|
-
"args": ["chrome-devtools-mcp@latest"]
|
|
643
|
+
"args": ["-y", "chrome-devtools-mcp@latest"]
|
|
635
644
|
}' "$claude_json" > "$tmp" && mv "$tmp" "$claude_json"
|
|
636
645
|
echo " Added chrome-devtools MCP server (debugging & inspection)"
|
|
637
646
|
added_any=true
|
|
638
647
|
fi
|
|
639
648
|
|
|
649
|
+
# Add WebMCP if not configured
|
|
650
|
+
# Allows websites to expose custom MCP tools to Claude via WebSocket
|
|
651
|
+
if ! jq -e '.mcpServers["webmcp"]' "$claude_json" > /dev/null 2>&1; then
|
|
652
|
+
[[ "$added_any" == "false" ]] && echo "Configuring MCP servers..."
|
|
653
|
+
local tmp
|
|
654
|
+
tmp=$(mktemp)
|
|
655
|
+
jq '.mcpServers["webmcp"] = {
|
|
656
|
+
"command": "npx",
|
|
657
|
+
"args": ["-y", "@jason.today/webmcp@latest"]
|
|
658
|
+
}' "$claude_json" > "$tmp" && mv "$tmp" "$claude_json"
|
|
659
|
+
echo " Added WebMCP server (website-exposed tools via WebSocket)"
|
|
660
|
+
added_any=true
|
|
661
|
+
fi
|
|
662
|
+
|
|
640
663
|
# Ask about test credentials
|
|
641
664
|
if [[ "$added_any" == "true" ]]; then
|
|
642
665
|
setup_test_credentials
|
package/ralph/uat.sh
CHANGED
|
@@ -32,6 +32,14 @@ UAT_MODE_LABEL=""
|
|
|
32
32
|
UAT_CONFIG_NS="" # config namespace: "uat" or "chaos"
|
|
33
33
|
UAT_CMD_NAME="" # CLI command name: "uat" or "chaos-agent"
|
|
34
34
|
|
|
35
|
+
# Docker isolation state (set by _should_use_docker_isolation / _chaos_docker_up)
|
|
36
|
+
CHAOS_ISOLATION_RESULT=""
|
|
37
|
+
CHAOS_FRONTEND_URL=""
|
|
38
|
+
CHAOS_API_URL=""
|
|
39
|
+
CHAOS_OVERRIDE_FILE=""
|
|
40
|
+
CHAOS_COMPOSE_FILE=""
|
|
41
|
+
CHAOS_COMPOSE_CMD=""
|
|
42
|
+
|
|
35
43
|
# TDD phases
|
|
36
44
|
readonly UAT_PHASE_RED="RED"
|
|
37
45
|
readonly UAT_PHASE_GREEN="GREEN"
|
|
@@ -45,6 +53,9 @@ readonly DEFAULT_UAT_MAX_CASE_RETRIES=5
|
|
|
45
53
|
readonly DEFAULT_UAT_SESSION_SECONDS=1800
|
|
46
54
|
readonly DEFAULT_CHAOS_SESSION_SECONDS=1800
|
|
47
55
|
|
|
56
|
+
# Archive retention
|
|
57
|
+
readonly MAX_UAT_ARCHIVE_COUNT=20
|
|
58
|
+
|
|
48
59
|
# ============================================================================
|
|
49
60
|
# DIRECTORY INIT
|
|
50
61
|
# ============================================================================
|
|
@@ -148,7 +159,7 @@ run_uat() {
|
|
|
148
159
|
print_info "Phase 1: Exploring your app and building a test plan"
|
|
149
160
|
echo ""
|
|
150
161
|
if ! _discover_and_plan "$quiet_mode" "uat"; then
|
|
151
|
-
|
|
162
|
+
_print_discovery_failure_help
|
|
152
163
|
return 1
|
|
153
164
|
fi
|
|
154
165
|
fi
|
|
@@ -179,6 +190,12 @@ run_uat() {
|
|
|
179
190
|
# Phase 3: Report
|
|
180
191
|
_print_report
|
|
181
192
|
|
|
193
|
+
# Archive and reset for next run
|
|
194
|
+
if [[ "$UAT_TESTS_WRITTEN" -gt 0 ]]; then
|
|
195
|
+
_archive_plan
|
|
196
|
+
rm -f "$UAT_PLAN_FILE"
|
|
197
|
+
fi
|
|
198
|
+
|
|
182
199
|
return $loop_exit
|
|
183
200
|
}
|
|
184
201
|
|
|
@@ -211,6 +228,30 @@ run_chaos() {
|
|
|
211
228
|
# Banner
|
|
212
229
|
_print_chaos_banner
|
|
213
230
|
|
|
231
|
+
# Isolation: spin up Docker copy for chaos to attack
|
|
232
|
+
# Call directly (not in $() subshell) so globals are preserved
|
|
233
|
+
local use_docker=false
|
|
234
|
+
_should_use_docker_isolation
|
|
235
|
+
if [[ "$CHAOS_ISOLATION_RESULT" == "true" ]]; then
|
|
236
|
+
print_info "Starting isolated Docker environment..."
|
|
237
|
+
if _chaos_docker_up; then
|
|
238
|
+
use_docker=true
|
|
239
|
+
else
|
|
240
|
+
print_warning "Docker isolation failed — testing against live app"
|
|
241
|
+
print_warning "Non-destructive guardrails are active"
|
|
242
|
+
fi
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
# Helper to tear down Docker on early exit
|
|
246
|
+
_chaos_early_exit() {
|
|
247
|
+
local code="$1"
|
|
248
|
+
if [[ "$use_docker" == "true" ]]; then
|
|
249
|
+
print_info "Tearing down isolated environment..."
|
|
250
|
+
_chaos_docker_down
|
|
251
|
+
fi
|
|
252
|
+
return "$code"
|
|
253
|
+
}
|
|
254
|
+
|
|
214
255
|
# Phase 1: Adversarial Discovery + Plan
|
|
215
256
|
if [[ ! -f "$UAT_PLAN_FILE" ]] || [[ "$force_review" == "true" ]] || [[ "$plan_only" == "true" ]]; then
|
|
216
257
|
if [[ -f "$UAT_PLAN_FILE" ]] && [[ "$force_review" == "true" ]]; then
|
|
@@ -220,7 +261,8 @@ run_chaos() {
|
|
|
220
261
|
print_info "Phase 1: Red team exploring your app for vulnerabilities"
|
|
221
262
|
echo ""
|
|
222
263
|
if ! _discover_and_plan "$quiet_mode" "chaos"; then
|
|
223
|
-
|
|
264
|
+
_print_discovery_failure_help
|
|
265
|
+
_chaos_early_exit 1
|
|
224
266
|
return 1
|
|
225
267
|
fi
|
|
226
268
|
fi
|
|
@@ -228,11 +270,13 @@ run_chaos() {
|
|
|
228
270
|
# Review the plan
|
|
229
271
|
if ! _review_plan; then
|
|
230
272
|
print_info "Plan review cancelled. No changes were made."
|
|
273
|
+
_chaos_early_exit 0
|
|
231
274
|
return 0
|
|
232
275
|
fi
|
|
233
276
|
|
|
234
277
|
if [[ "$plan_only" == "true" ]]; then
|
|
235
278
|
print_success "Plan generated. Run 'npx agentic-loop chaos-agent' to execute."
|
|
279
|
+
_chaos_early_exit 0
|
|
236
280
|
return 0
|
|
237
281
|
fi
|
|
238
282
|
else
|
|
@@ -251,6 +295,18 @@ run_chaos() {
|
|
|
251
295
|
# Phase 3: Report
|
|
252
296
|
_print_report
|
|
253
297
|
|
|
298
|
+
# Archive and reset for next run
|
|
299
|
+
if [[ "$UAT_TESTS_WRITTEN" -gt 0 ]]; then
|
|
300
|
+
_archive_plan
|
|
301
|
+
rm -f "$UAT_PLAN_FILE"
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# Isolation: tear down Docker environment
|
|
305
|
+
if [[ "$use_docker" == "true" ]]; then
|
|
306
|
+
print_info "Tearing down isolated environment..."
|
|
307
|
+
_chaos_docker_down
|
|
308
|
+
fi
|
|
309
|
+
|
|
254
310
|
return $loop_exit
|
|
255
311
|
}
|
|
256
312
|
|
|
@@ -278,17 +334,82 @@ _acquire_uat_lock() {
|
|
|
278
334
|
|
|
279
335
|
_uat_cleanup() {
|
|
280
336
|
rm -f "$RALPH_DIR/.lock"
|
|
337
|
+
# Safety net: tear down Docker if still running
|
|
338
|
+
if [[ -n "${CHAOS_OVERRIDE_FILE:-}" ]]; then
|
|
339
|
+
_chaos_docker_down 2>/dev/null
|
|
340
|
+
fi
|
|
281
341
|
}
|
|
282
342
|
|
|
283
343
|
_uat_interrupt() {
|
|
284
344
|
echo ""
|
|
285
345
|
print_warning "Interrupted. Wrapping up $UAT_MODE_LABEL..."
|
|
346
|
+
if [[ -n "${CHAOS_OVERRIDE_FILE:-}" ]]; then
|
|
347
|
+
print_info "Tearing down isolated Docker environment..."
|
|
348
|
+
_chaos_docker_down
|
|
349
|
+
fi
|
|
286
350
|
# Kill all child processes (Claude sessions, test runners)
|
|
287
351
|
kill 0 2>/dev/null || true
|
|
288
352
|
_uat_cleanup
|
|
289
353
|
exit 130
|
|
290
354
|
}
|
|
291
355
|
|
|
356
|
+
# ============================================================================
|
|
357
|
+
# DISCOVERY FAILURE RECOVERY
|
|
358
|
+
# ============================================================================
|
|
359
|
+
|
|
360
|
+
_print_discovery_failure_help() {
|
|
361
|
+
echo ""
|
|
362
|
+
echo " ┌──────────────────────────────────────────────────────┐"
|
|
363
|
+
echo " │ Discovery failed — here's how to recover │"
|
|
364
|
+
echo " └──────────────────────────────────────────────────────┘"
|
|
365
|
+
echo ""
|
|
366
|
+
|
|
367
|
+
# Check common causes and give specific advice
|
|
368
|
+
local has_config=false has_app_url=false app_url=""
|
|
369
|
+
if [[ -f "$RALPH_DIR/config.json" ]]; then
|
|
370
|
+
has_config=true
|
|
371
|
+
app_url=$(jq -r '.frontendUrl // .url // empty' "$RALPH_DIR/config.json" 2>/dev/null)
|
|
372
|
+
[[ -n "$app_url" ]] && has_app_url=true
|
|
373
|
+
fi
|
|
374
|
+
|
|
375
|
+
# Check if the app is reachable
|
|
376
|
+
if [[ "$has_app_url" == "true" ]]; then
|
|
377
|
+
if ! curl -s --max-time 3 "$app_url" > /dev/null 2>&1; then
|
|
378
|
+
echo " Likely cause: Your app at $app_url is not responding."
|
|
379
|
+
echo ""
|
|
380
|
+
echo " Fix: Start your app first, then retry:"
|
|
381
|
+
echo " npm run dev # or whatever starts your app"
|
|
382
|
+
echo " npx agentic-loop $UAT_CMD_NAME"
|
|
383
|
+
echo ""
|
|
384
|
+
return
|
|
385
|
+
fi
|
|
386
|
+
fi
|
|
387
|
+
|
|
388
|
+
if [[ "$has_config" == "false" ]]; then
|
|
389
|
+
echo " Likely cause: No .ralph/config.json found."
|
|
390
|
+
echo ""
|
|
391
|
+
echo " Fix: Run 'npx agentic-loop init' to create one."
|
|
392
|
+
echo ""
|
|
393
|
+
return
|
|
394
|
+
fi
|
|
395
|
+
|
|
396
|
+
# Generic recovery: show progress log and suggest retry
|
|
397
|
+
echo " What happened:"
|
|
398
|
+
if [[ -f "$UAT_PROGRESS_FILE" ]]; then
|
|
399
|
+
echo ""
|
|
400
|
+
tail -5 "$UAT_PROGRESS_FILE" | sed 's/^/ /'
|
|
401
|
+
echo ""
|
|
402
|
+
fi
|
|
403
|
+
|
|
404
|
+
echo " To retry:"
|
|
405
|
+
echo " npx agentic-loop $UAT_CMD_NAME"
|
|
406
|
+
echo ""
|
|
407
|
+
echo " To retry with more time (default: ${DEFAULT_UAT_SESSION_SECONDS}s):"
|
|
408
|
+
echo " Set $UAT_CONFIG_NS.sessionSeconds in .ralph/config.json"
|
|
409
|
+
echo ""
|
|
410
|
+
echo " Full log: $UAT_PROGRESS_FILE"
|
|
411
|
+
}
|
|
412
|
+
|
|
292
413
|
# ============================================================================
|
|
293
414
|
# PHASE 1: DISCOVER + PLAN
|
|
294
415
|
# ============================================================================
|
|
@@ -326,20 +447,26 @@ _discover_and_plan() {
|
|
|
326
447
|
|
|
327
448
|
if [[ $claude_exit -ne 0 ]]; then
|
|
328
449
|
_log_uat "DISCOVER" "Claude session failed (exit $claude_exit)"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
echo "
|
|
332
|
-
|
|
450
|
+
if [[ $claude_exit -eq 124 ]]; then
|
|
451
|
+
print_error "Discovery timed out after ${timeout}s"
|
|
452
|
+
echo " The exploration ran out of time before finishing."
|
|
453
|
+
echo " Increase timeout: set $UAT_CONFIG_NS.sessionSeconds in .ralph/config.json"
|
|
454
|
+
else
|
|
455
|
+
print_error "Discovery session crashed (exit code $claude_exit)"
|
|
456
|
+
if [[ -f "$output_file" ]]; then
|
|
457
|
+
echo " Last output:"
|
|
458
|
+
tail -5 "$output_file" | sed 's/^/ /'
|
|
459
|
+
fi
|
|
333
460
|
fi
|
|
334
461
|
return 1
|
|
335
462
|
fi
|
|
336
463
|
|
|
337
464
|
# Validate plan was generated
|
|
338
465
|
if [[ ! -f "$UAT_PLAN_FILE" ]]; then
|
|
339
|
-
print_error "
|
|
466
|
+
print_error "Discovery finished but no test plan was written"
|
|
340
467
|
echo ""
|
|
341
|
-
echo "
|
|
342
|
-
echo "
|
|
468
|
+
echo " Claude explored the app but didn't write .ralph/$UAT_CONFIG_NS/plan.json."
|
|
469
|
+
echo " This usually means the app wasn't reachable or had no testable features."
|
|
343
470
|
return 1
|
|
344
471
|
fi
|
|
345
472
|
|
|
@@ -965,6 +1092,7 @@ _run_green_phase() {
|
|
|
965
1092
|
print_success "$case_id: Fixed! Test passes and nothing else broke"
|
|
966
1093
|
_mark_passed "$case_id"
|
|
967
1094
|
_track_fixed_files "$case_id"
|
|
1095
|
+
_auto_sign_from_case "$case_id"
|
|
968
1096
|
UAT_BUGS_FIXED=$((UAT_BUGS_FIXED + 1))
|
|
969
1097
|
_commit_result "$case_id" "$test_file"
|
|
970
1098
|
UAT_CASES_PASSED=$((UAT_CASES_PASSED + 1))
|
|
@@ -1757,6 +1885,7 @@ _print_uat_banner() {
|
|
|
1757
1885
|
echo " | |_| / ___ \\| | | |__| (_) | (_) | |_) |"
|
|
1758
1886
|
echo " \\___/_/ \\_\\_| |_____\\___/ \\___/| .__/"
|
|
1759
1887
|
echo " |_|"
|
|
1888
|
+
echo " Acceptance testing loop — verifying things work"
|
|
1760
1889
|
echo ""
|
|
1761
1890
|
}
|
|
1762
1891
|
|
|
@@ -1768,7 +1897,7 @@ _print_chaos_banner() {
|
|
|
1768
1897
|
echo " | |___| | | | (_| | (_) \\__ \\/ ___ \\ (_| | __/ | | | |_ "
|
|
1769
1898
|
echo " \\____|_| |_|\\__,_|\\___/|___/_/ \\_\\__, |\\___|_| |_|\\__|"
|
|
1770
1899
|
echo " |___/ "
|
|
1771
|
-
echo " Red team
|
|
1900
|
+
echo " Red team loop — trying to break things"
|
|
1772
1901
|
echo ""
|
|
1773
1902
|
}
|
|
1774
1903
|
|
|
@@ -1970,11 +2099,505 @@ This is NOT a copy of the template — it's ground truth from the red team's exp
|
|
|
1970
2099
|
- `targetFiles` should list the app source files the test covers
|
|
1971
2100
|
- `testFile` path should use the project's test directory conventions
|
|
1972
2101
|
- Always clean up: shutdown teammates and delete team when done
|
|
2102
|
+
|
|
1973
2103
|
PROMPT_SECTION
|
|
1974
2104
|
|
|
2105
|
+
# Conditional section: Docker isolation vs non-destructive guardrails
|
|
2106
|
+
if [[ -n "${CHAOS_FRONTEND_URL:-}" ]]; then
|
|
2107
|
+
cat >> "$prompt_file" << PROMPT_DOCKER
|
|
2108
|
+
### ISOLATED ENVIRONMENT (Docker)
|
|
2109
|
+
|
|
2110
|
+
You are attacking an ISOLATED Docker copy of the application.
|
|
2111
|
+
The developer's live server is NOT affected. Go deeper and harder.
|
|
2112
|
+
|
|
2113
|
+
- Frontend: ${CHAOS_FRONTEND_URL}
|
|
2114
|
+
- API: ${CHAOS_API_URL}
|
|
2115
|
+
|
|
2116
|
+
Use THESE URLs for all testing. Ignore URLs in .ralph/config.json.
|
|
2117
|
+
You CAN test destructive operations (DELETE endpoints, data mutations, etc.)
|
|
2118
|
+
since this environment is disposable.
|
|
2119
|
+
PROMPT_DOCKER
|
|
2120
|
+
else
|
|
2121
|
+
cat >> "$prompt_file" << 'PROMPT_SAFE'
|
|
2122
|
+
### Non-Destructive Testing (CRITICAL)
|
|
2123
|
+
|
|
2124
|
+
The developer is actively running this app. Your testing MUST NOT corrupt application state:
|
|
2125
|
+
|
|
2126
|
+
- **OBSERVE, don't destroy** — read data, don't delete it. Test inputs, don't wipe databases.
|
|
2127
|
+
- **NO destructive API calls** — do NOT call DELETE endpoints, DROP tables, or clear/reset data
|
|
2128
|
+
- **NO mass mutations** — don't create thousands of records, flood queues, or exhaust rate limits
|
|
2129
|
+
- **Prefer GET over POST/PUT/DELETE** for reconnaissance
|
|
2130
|
+
- **Test XSS/injection via form inputs**, not direct database manipulation
|
|
2131
|
+
- **If you find a destructive vulnerability**, DOCUMENT IT in the plan — don't exploit it live
|
|
2132
|
+
- **Leave the app in a usable state** after each agent finishes
|
|
2133
|
+
- **If the app crashes or becomes unresponsive**, stop testing and report what caused it
|
|
2134
|
+
PROMPT_SAFE
|
|
2135
|
+
fi
|
|
2136
|
+
|
|
1975
2137
|
_inject_prompt_context "$prompt_file"
|
|
1976
2138
|
}
|
|
1977
2139
|
|
|
2140
|
+
# ============================================================================
|
|
2141
|
+
# ISOLATION: DOCKER-BASED CHAOS ENVIRONMENT
|
|
2142
|
+
# ============================================================================
|
|
2143
|
+
|
|
2144
|
+
# Check whether Docker isolation should be used for chaos-agent runs.
|
|
2145
|
+
# Sets CHAOS_ISOLATION_RESULT to "true" or "false".
|
|
2146
|
+
# Must be called directly (not in a $() subshell) so globals are preserved.
|
|
2147
|
+
# Also sets: CHAOS_COMPOSE_CMD, CHAOS_COMPOSE_FILE
|
|
2148
|
+
_should_use_docker_isolation() {
|
|
2149
|
+
CHAOS_ISOLATION_RESULT="false"
|
|
2150
|
+
|
|
2151
|
+
# Read chaos.isolate directly — get_config uses `// empty` which treats
|
|
2152
|
+
# boolean false as falsy and falls through to the default
|
|
2153
|
+
local isolate="true"
|
|
2154
|
+
local config="$RALPH_DIR/config.json"
|
|
2155
|
+
if [[ -f "$config" ]]; then
|
|
2156
|
+
local raw
|
|
2157
|
+
raw=$(jq -r 'if .chaos.isolate == false then "false" elif .chaos.isolate then .chaos.isolate else "unset" end' "$config" 2>/dev/null)
|
|
2158
|
+
[[ "$raw" != "unset" && "$raw" != "null" && -n "$raw" ]] && isolate="$raw"
|
|
2159
|
+
fi
|
|
2160
|
+
if [[ "$isolate" != "true" ]]; then
|
|
2161
|
+
print_info "Docker isolation disabled (chaos.isolate=false)"
|
|
2162
|
+
return 0
|
|
2163
|
+
fi
|
|
2164
|
+
|
|
2165
|
+
CHAOS_COMPOSE_CMD=$(_detect_compose_cmd)
|
|
2166
|
+
if [[ -z "$CHAOS_COMPOSE_CMD" ]]; then
|
|
2167
|
+
print_info "Docker not available — skipping isolation"
|
|
2168
|
+
return 0
|
|
2169
|
+
fi
|
|
2170
|
+
|
|
2171
|
+
# Find compose file: config override, then standard names
|
|
2172
|
+
local compose_file
|
|
2173
|
+
compose_file=$(get_config '.docker.composeFile' "")
|
|
2174
|
+
if [[ -n "$compose_file" && -f "$compose_file" ]]; then
|
|
2175
|
+
CHAOS_COMPOSE_FILE="$compose_file"
|
|
2176
|
+
CHAOS_ISOLATION_RESULT="true"
|
|
2177
|
+
return 0
|
|
2178
|
+
fi
|
|
2179
|
+
|
|
2180
|
+
for candidate in "docker-compose.yml" "docker-compose.yaml" "compose.yml" "compose.yaml"; do
|
|
2181
|
+
if [[ -f "$candidate" ]]; then
|
|
2182
|
+
CHAOS_COMPOSE_FILE="$candidate"
|
|
2183
|
+
CHAOS_ISOLATION_RESULT="true"
|
|
2184
|
+
return 0
|
|
2185
|
+
fi
|
|
2186
|
+
done
|
|
2187
|
+
|
|
2188
|
+
print_info "No compose file found — skipping Docker isolation"
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
# Parse the compose file for port mappings and generate an override file
|
|
2192
|
+
# with ports offset by chaos.docker.portOffset (default: 10000).
|
|
2193
|
+
# Sets: CHAOS_OVERRIDE_FILE, CHAOS_COMPOSE_FILE
|
|
2194
|
+
_generate_chaos_override() {
|
|
2195
|
+
local port_offset
|
|
2196
|
+
port_offset=$(get_config '.chaos.docker.portOffset' "10000")
|
|
2197
|
+
|
|
2198
|
+
local override_file
|
|
2199
|
+
override_file=$(create_temp_file ".chaos-override.yml")
|
|
2200
|
+
|
|
2201
|
+
# Check for network_mode: host (at service-level indentation, 4+ spaces)
|
|
2202
|
+
if grep -qE '^[[:space:]]{4,}network_mode:[[:space:]]*"?host"?' "$CHAOS_COMPOSE_FILE" 2>/dev/null; then
|
|
2203
|
+
print_error "Compose file uses network_mode: host — cannot isolate ports"
|
|
2204
|
+
return 1
|
|
2205
|
+
fi
|
|
2206
|
+
|
|
2207
|
+
# Build override YAML
|
|
2208
|
+
echo "services:" > "$override_file"
|
|
2209
|
+
|
|
2210
|
+
local current_service=""
|
|
2211
|
+
local in_ports=false
|
|
2212
|
+
local service_has_ports=false
|
|
2213
|
+
|
|
2214
|
+
while IFS= read -r line; do
|
|
2215
|
+
# Detect top-level service name: 2-space indent, alphanumeric/dot/dash/underscore, colon
|
|
2216
|
+
# Allows trailing whitespace and comments (e.g., " web: # my service")
|
|
2217
|
+
if [[ "$line" =~ ^[[:space:]]{2}[a-zA-Z0-9._-]+:[[:space:]]*(#.*)?$ ]] && ! [[ "$line" =~ ^[[:space:]]{4} ]]; then
|
|
2218
|
+
current_service=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/:[[:space:]]*#.*//' | tr -d ':')
|
|
2219
|
+
in_ports=false
|
|
2220
|
+
service_has_ports=false
|
|
2221
|
+
fi
|
|
2222
|
+
|
|
2223
|
+
# Detect ports: section (must be under a service, i.e. 4+ spaces)
|
|
2224
|
+
if [[ "$line" =~ ^[[:space:]]{4,}ports:[[:space:]]*(#.*)?$ ]]; then
|
|
2225
|
+
in_ports=true
|
|
2226
|
+
continue
|
|
2227
|
+
fi
|
|
2228
|
+
|
|
2229
|
+
# Parse port mappings within a ports: section
|
|
2230
|
+
if [[ "$in_ports" == "true" ]]; then
|
|
2231
|
+
# Handle three-part format: "IP:HOST:CONTAINER" (e.g., "127.0.0.1:8080:8080")
|
|
2232
|
+
if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\"?([0-9.]+):([0-9]+):([0-9]+)\"? ]]; then
|
|
2233
|
+
local bind_ip="${BASH_REMATCH[1]}"
|
|
2234
|
+
local host_port="${BASH_REMATCH[2]}"
|
|
2235
|
+
local container_port="${BASH_REMATCH[3]}"
|
|
2236
|
+
local new_port=$((host_port + port_offset))
|
|
2237
|
+
|
|
2238
|
+
if [[ "$new_port" -gt 65535 ]]; then
|
|
2239
|
+
print_error "Port ${host_port}+${port_offset}=${new_port} exceeds 65535"
|
|
2240
|
+
print_error "Reduce chaos.docker.portOffset in .ralph/config.json"
|
|
2241
|
+
return 1
|
|
2242
|
+
fi
|
|
2243
|
+
|
|
2244
|
+
if [[ "$service_has_ports" == "false" ]]; then
|
|
2245
|
+
echo " ${current_service}:" >> "$override_file"
|
|
2246
|
+
echo " ports:" >> "$override_file"
|
|
2247
|
+
service_has_ports=true
|
|
2248
|
+
fi
|
|
2249
|
+
|
|
2250
|
+
echo " - \"${bind_ip}:${new_port}:${container_port}\"" >> "$override_file"
|
|
2251
|
+
# Standard two-part format: "HOST:CONTAINER" (e.g., "8001:8001")
|
|
2252
|
+
elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\"?([0-9]+):([0-9]+)\"? ]]; then
|
|
2253
|
+
local host_port="${BASH_REMATCH[1]}"
|
|
2254
|
+
local container_port="${BASH_REMATCH[2]}"
|
|
2255
|
+
local new_port=$((host_port + port_offset))
|
|
2256
|
+
|
|
2257
|
+
if [[ "$new_port" -gt 65535 ]]; then
|
|
2258
|
+
print_error "Port ${host_port}+${port_offset}=${new_port} exceeds 65535"
|
|
2259
|
+
print_error "Reduce chaos.docker.portOffset in .ralph/config.json"
|
|
2260
|
+
return 1
|
|
2261
|
+
fi
|
|
2262
|
+
|
|
2263
|
+
# Write service header on first port
|
|
2264
|
+
if [[ "$service_has_ports" == "false" ]]; then
|
|
2265
|
+
echo " ${current_service}:" >> "$override_file"
|
|
2266
|
+
echo " ports:" >> "$override_file"
|
|
2267
|
+
service_has_ports=true
|
|
2268
|
+
fi
|
|
2269
|
+
|
|
2270
|
+
echo " - \"${new_port}:${container_port}\"" >> "$override_file"
|
|
2271
|
+
elif [[ ! "$line" =~ ^[[:space:]]*- ]] && [[ ! "$line" =~ ^[[:space:]]*$ ]] && [[ ! "$line" =~ ^[[:space:]]*# ]]; then
|
|
2272
|
+
# Non-list, non-blank, non-comment line means we exited the ports section
|
|
2273
|
+
in_ports=false
|
|
2274
|
+
fi
|
|
2275
|
+
fi
|
|
2276
|
+
done < "$CHAOS_COMPOSE_FILE"
|
|
2277
|
+
|
|
2278
|
+
CHAOS_OVERRIDE_FILE="$override_file"
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
# Start the isolated Docker stack for chaos-agent.
|
|
2282
|
+
# Sets: CHAOS_FRONTEND_URL, CHAOS_API_URL
|
|
2283
|
+
_chaos_docker_up() {
|
|
2284
|
+
# Clean up any stale containers from interrupted runs
|
|
2285
|
+
_chaos_docker_down 2>/dev/null
|
|
2286
|
+
|
|
2287
|
+
# Call directly (not in $() subshell) so CHAOS_OVERRIDE_FILE global is preserved
|
|
2288
|
+
_generate_chaos_override || return 1
|
|
2289
|
+
|
|
2290
|
+
local port_offset health_timeout
|
|
2291
|
+
port_offset=$(get_config '.chaos.docker.portOffset' "10000")
|
|
2292
|
+
health_timeout=$(get_config '.chaos.docker.healthTimeout' "120")
|
|
2293
|
+
|
|
2294
|
+
# Read chaos.docker.build directly — get_config treats boolean false as falsy
|
|
2295
|
+
local should_build="true"
|
|
2296
|
+
local config="$RALPH_DIR/config.json"
|
|
2297
|
+
if [[ -f "$config" ]]; then
|
|
2298
|
+
local raw_build
|
|
2299
|
+
raw_build=$(jq -r 'if .chaos.docker.build == false then "false" elif .chaos.docker.build then .chaos.docker.build else "unset" end' "$config" 2>/dev/null)
|
|
2300
|
+
[[ "$raw_build" != "unset" && "$raw_build" != "null" && -n "$raw_build" ]] && should_build="$raw_build"
|
|
2301
|
+
fi
|
|
2302
|
+
|
|
2303
|
+
local build_flag=""
|
|
2304
|
+
[[ "$should_build" == "true" ]] && build_flag="--build"
|
|
2305
|
+
|
|
2306
|
+
# Check if compose v2 supports --wait
|
|
2307
|
+
local wait_flag=""
|
|
2308
|
+
if $CHAOS_COMPOSE_CMD up --help 2>&1 | grep -q '\-\-wait'; then
|
|
2309
|
+
wait_flag="--wait --wait-timeout $health_timeout"
|
|
2310
|
+
fi
|
|
2311
|
+
|
|
2312
|
+
_log_uat "ISOLATE" "Starting Docker stack: $CHAOS_COMPOSE_CMD -p ralph-chaos up -d $build_flag $wait_flag"
|
|
2313
|
+
|
|
2314
|
+
# shellcheck disable=SC2086
|
|
2315
|
+
if ! $CHAOS_COMPOSE_CMD -f "$CHAOS_COMPOSE_FILE" -f "$CHAOS_OVERRIDE_FILE" \
|
|
2316
|
+
-p ralph-chaos up -d $build_flag $wait_flag 2>&1; then
|
|
2317
|
+
print_error "Docker stack failed to start"
|
|
2318
|
+
_log_uat "ISOLATE" "Docker stack failed"
|
|
2319
|
+
_chaos_docker_down 2>/dev/null
|
|
2320
|
+
return 1
|
|
2321
|
+
fi
|
|
2322
|
+
|
|
2323
|
+
# If --wait wasn't available, poll for health
|
|
2324
|
+
if [[ -z "$wait_flag" ]]; then
|
|
2325
|
+
if ! _chaos_poll_health "$port_offset" "$health_timeout"; then
|
|
2326
|
+
print_error "Health check timed out after ${health_timeout}s"
|
|
2327
|
+
_log_uat "ISOLATE" "Health check timeout"
|
|
2328
|
+
_chaos_docker_down 2>/dev/null
|
|
2329
|
+
return 1
|
|
2330
|
+
fi
|
|
2331
|
+
fi
|
|
2332
|
+
|
|
2333
|
+
# Compute isolated URLs from offset ports
|
|
2334
|
+
# Extract port after the last colon in URL (handles http://host:PORT/path)
|
|
2335
|
+
local frontend_port api_port
|
|
2336
|
+
frontend_port=$(get_config '.urls.frontend' "http://localhost:5173" | grep -oE ':[0-9]+' | tail -1 | tr -d ':')
|
|
2337
|
+
api_port=$(get_config '.urls.api' "" | grep -oE ':[0-9]+' | tail -1 | tr -d ':')
|
|
2338
|
+
|
|
2339
|
+
if [[ -n "$frontend_port" ]]; then
|
|
2340
|
+
CHAOS_FRONTEND_URL="http://localhost:$((frontend_port + port_offset))"
|
|
2341
|
+
fi
|
|
2342
|
+
if [[ -n "$api_port" ]]; then
|
|
2343
|
+
CHAOS_API_URL="http://localhost:$((api_port + port_offset))"
|
|
2344
|
+
fi
|
|
2345
|
+
|
|
2346
|
+
_log_uat "ISOLATE" "Docker stack ready (frontend: ${CHAOS_FRONTEND_URL:-none}, api: ${CHAOS_API_URL:-none})"
|
|
2347
|
+
print_info "Isolated environment ready (frontend: ${CHAOS_FRONTEND_URL:-none}, api: ${CHAOS_API_URL:-none})"
|
|
2348
|
+
return 0
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
# Fallback health check when --wait is unavailable.
|
|
2352
|
+
# Polls the API health endpoint or checks container state.
|
|
2353
|
+
_chaos_poll_health() {
|
|
2354
|
+
local port_offset="$1"
|
|
2355
|
+
local timeout="$2"
|
|
2356
|
+
|
|
2357
|
+
local health_endpoint
|
|
2358
|
+
health_endpoint=$(get_config '.api.healthEndpoint' "/health")
|
|
2359
|
+
local api_port
|
|
2360
|
+
api_port=$(get_config '.urls.api' "" | grep -oE ':[0-9]+' | tail -1 | tr -d ':')
|
|
2361
|
+
|
|
2362
|
+
local start_time
|
|
2363
|
+
start_time=$(date +%s)
|
|
2364
|
+
|
|
2365
|
+
if [[ -n "$api_port" ]]; then
|
|
2366
|
+
local url="http://localhost:$((api_port + port_offset))${health_endpoint}"
|
|
2367
|
+
print_info "Waiting for health check at $url..."
|
|
2368
|
+
while true; do
|
|
2369
|
+
local now
|
|
2370
|
+
now=$(date +%s)
|
|
2371
|
+
[[ $((now - start_time)) -ge "$timeout" ]] && break
|
|
2372
|
+
if curl -sf --max-time 5 "$url" >/dev/null 2>&1; then
|
|
2373
|
+
return 0
|
|
2374
|
+
fi
|
|
2375
|
+
sleep 3
|
|
2376
|
+
done
|
|
2377
|
+
else
|
|
2378
|
+
# No API URL — just wait for containers to be running
|
|
2379
|
+
print_info "Waiting for containers to be running..."
|
|
2380
|
+
while true; do
|
|
2381
|
+
local now
|
|
2382
|
+
now=$(date +%s)
|
|
2383
|
+
[[ $((now - start_time)) -ge "$timeout" ]] && break
|
|
2384
|
+
# shellcheck disable=SC2086
|
|
2385
|
+
local running
|
|
2386
|
+
running=$($CHAOS_COMPOSE_CMD -p ralph-chaos ps --format json 2>/dev/null | \
|
|
2387
|
+
grep -c '"running"' 2>/dev/null || echo "0")
|
|
2388
|
+
if [[ "$running" -gt 0 ]]; then
|
|
2389
|
+
return 0
|
|
2390
|
+
fi
|
|
2391
|
+
sleep 3
|
|
2392
|
+
done
|
|
2393
|
+
fi
|
|
2394
|
+
|
|
2395
|
+
return 1
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
# Tear down the isolated Docker stack. Idempotent — safe to call when nothing is running.
|
|
2399
|
+
_chaos_docker_down() {
|
|
2400
|
+
if [[ -z "${CHAOS_COMPOSE_CMD:-}" || -z "${CHAOS_COMPOSE_FILE:-}" ]]; then
|
|
2401
|
+
return 0
|
|
2402
|
+
fi
|
|
2403
|
+
|
|
2404
|
+
if [[ -n "${CHAOS_OVERRIDE_FILE:-}" && -f "${CHAOS_OVERRIDE_FILE:-}" ]]; then
|
|
2405
|
+
$CHAOS_COMPOSE_CMD -f "$CHAOS_COMPOSE_FILE" -f "$CHAOS_OVERRIDE_FILE" \
|
|
2406
|
+
-p ralph-chaos down -v --timeout 10 2>/dev/null
|
|
2407
|
+
else
|
|
2408
|
+
$CHAOS_COMPOSE_CMD -f "$CHAOS_COMPOSE_FILE" \
|
|
2409
|
+
-p ralph-chaos down -v --timeout 10 2>/dev/null
|
|
2410
|
+
fi
|
|
2411
|
+
|
|
2412
|
+
CHAOS_FRONTEND_URL=""
|
|
2413
|
+
CHAOS_API_URL=""
|
|
2414
|
+
CHAOS_OVERRIDE_FILE=""
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
# ============================================================================
|
|
2418
|
+
# SELF-LEARNING: ARCHIVE, AUTO-SIGN, HISTORY
|
|
2419
|
+
# ============================================================================
|
|
2420
|
+
|
|
2421
|
+
# Auto-add a sign when chaos-agent fixes a vulnerability (GREEN success only).
|
|
2422
|
+
# UAT mode is skipped — functional test titles are too generic to be useful signs.
|
|
2423
|
+
_auto_sign_from_case() {
|
|
2424
|
+
local case_id="$1"
|
|
2425
|
+
|
|
2426
|
+
# Only for chaos-agent — security findings are high-signal
|
|
2427
|
+
[[ "$UAT_CONFIG_NS" != "chaos" ]] && return 0
|
|
2428
|
+
|
|
2429
|
+
# Read case data from plan.json
|
|
2430
|
+
local case_json title test_approach pattern
|
|
2431
|
+
case_json=$(jq --arg id "$case_id" '.testCases[] | select(.id==$id)' "$UAT_PLAN_FILE" 2>/dev/null)
|
|
2432
|
+
[[ -z "$case_json" ]] && return 0
|
|
2433
|
+
|
|
2434
|
+
title=$(echo "$case_json" | jq -r '.title // empty')
|
|
2435
|
+
[[ -z "$title" ]] && return 0
|
|
2436
|
+
|
|
2437
|
+
test_approach=$(echo "$case_json" | jq -r '.testApproach // empty')
|
|
2438
|
+
|
|
2439
|
+
# Build pattern: "title -- testApproach" or just title
|
|
2440
|
+
if [[ -n "$test_approach" ]]; then
|
|
2441
|
+
pattern="$title -- $test_approach"
|
|
2442
|
+
else
|
|
2443
|
+
pattern="$title"
|
|
2444
|
+
fi
|
|
2445
|
+
|
|
2446
|
+
# Truncate at 200 chars
|
|
2447
|
+
[[ ${#pattern} -gt 200 ]] && pattern="${pattern:0:200}"
|
|
2448
|
+
|
|
2449
|
+
# Check for duplicates
|
|
2450
|
+
if _sign_is_duplicate "$pattern"; then
|
|
2451
|
+
_log_uat "$case_id" "AUTO_SIGN: Skipped duplicate — $pattern"
|
|
2452
|
+
return 0
|
|
2453
|
+
fi
|
|
2454
|
+
|
|
2455
|
+
# Add sign with output suppressed (redirect to log)
|
|
2456
|
+
if ralph_sign "$pattern" "security" "true" "$case_id" > /dev/null 2>&1; then
|
|
2457
|
+
_log_uat "$case_id" "AUTO_SIGN: Added [security] $pattern"
|
|
2458
|
+
print_info "Learned: [security] $pattern"
|
|
2459
|
+
else
|
|
2460
|
+
_log_uat "$case_id" "AUTO_SIGN: Failed to add sign"
|
|
2461
|
+
fi
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
# Archive a completed plan for future reference.
|
|
2465
|
+
_archive_plan() {
|
|
2466
|
+
local archive_dir="$UAT_MODE_DIR/archive"
|
|
2467
|
+
mkdir -p "$archive_dir"
|
|
2468
|
+
|
|
2469
|
+
local timestamp
|
|
2470
|
+
timestamp=$(date +%Y%m%d-%H%M%S 2>/dev/null || date +%Y%m%d-%H%M%S)
|
|
2471
|
+
|
|
2472
|
+
local archive_file="$archive_dir/plan-${timestamp}.json"
|
|
2473
|
+
|
|
2474
|
+
# Record current git hash in the archived plan
|
|
2475
|
+
local git_hash=""
|
|
2476
|
+
if command -v git &>/dev/null && [[ -d ".git" ]]; then
|
|
2477
|
+
git_hash=$(git rev-parse HEAD 2>/dev/null || echo "")
|
|
2478
|
+
fi
|
|
2479
|
+
|
|
2480
|
+
if [[ -n "$git_hash" ]]; then
|
|
2481
|
+
jq --arg hash "$git_hash" '.testSuite.gitHash = $hash' "$UAT_PLAN_FILE" > "$archive_file" 2>/dev/null
|
|
2482
|
+
else
|
|
2483
|
+
cp "$UAT_PLAN_FILE" "$archive_file"
|
|
2484
|
+
fi
|
|
2485
|
+
|
|
2486
|
+
_prune_archives
|
|
2487
|
+
_log_uat "ARCHIVE" "Plan archived: $archive_file"
|
|
2488
|
+
print_info "Plan archived for future reference"
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
# Remove oldest archives beyond retention limit.
|
|
2492
|
+
_prune_archives() {
|
|
2493
|
+
local archive_dir="$UAT_MODE_DIR/archive"
|
|
2494
|
+
[[ ! -d "$archive_dir" ]] && return 0
|
|
2495
|
+
|
|
2496
|
+
local count
|
|
2497
|
+
count=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
|
|
2498
|
+
|
|
2499
|
+
if [[ "$count" -gt "$MAX_UAT_ARCHIVE_COUNT" ]]; then
|
|
2500
|
+
local to_remove=$((count - MAX_UAT_ARCHIVE_COUNT))
|
|
2501
|
+
# Sort by modification time (oldest first), remove excess
|
|
2502
|
+
find "$archive_dir" -name 'plan-*.json' -type f -print0 2>/dev/null \
|
|
2503
|
+
| xargs -0 ls -1t 2>/dev/null \
|
|
2504
|
+
| tail -"$to_remove" \
|
|
2505
|
+
| while IFS= read -r f; do
|
|
2506
|
+
rm -f "$f"
|
|
2507
|
+
done
|
|
2508
|
+
_log_uat "ARCHIVE" "Pruned $to_remove old archive(s)"
|
|
2509
|
+
fi
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
# Read git hash from the most recent archived plan.
|
|
2513
|
+
# Returns 1 if no archive exists.
|
|
2514
|
+
_get_last_run_git_hash() {
|
|
2515
|
+
local archive_dir="$UAT_MODE_DIR/archive"
|
|
2516
|
+
[[ ! -d "$archive_dir" ]] && return 1
|
|
2517
|
+
|
|
2518
|
+
# Find most recent archive by name (timestamps sort lexically)
|
|
2519
|
+
local latest
|
|
2520
|
+
latest=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | sort -r | head -1)
|
|
2521
|
+
[[ -z "$latest" ]] && return 1
|
|
2522
|
+
|
|
2523
|
+
local hash
|
|
2524
|
+
hash=$(jq -r '.testSuite.gitHash // empty' "$latest" 2>/dev/null)
|
|
2525
|
+
[[ -z "$hash" ]] && return 1
|
|
2526
|
+
|
|
2527
|
+
echo "$hash"
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
# List files changed since last run (excluding .ralph/).
|
|
2531
|
+
# Returns empty if no prior run or git unavailable.
|
|
2532
|
+
_get_changed_files_since_last_run() {
|
|
2533
|
+
command -v git &>/dev/null || return 0
|
|
2534
|
+
[[ -d ".git" ]] || return 0
|
|
2535
|
+
|
|
2536
|
+
local last_hash
|
|
2537
|
+
last_hash=$(_get_last_run_git_hash) || return 0
|
|
2538
|
+
|
|
2539
|
+
# Verify the hash is still valid (not from a force push)
|
|
2540
|
+
if ! git rev-parse --verify "$last_hash" &>/dev/null; then
|
|
2541
|
+
return 0
|
|
2542
|
+
fi
|
|
2543
|
+
|
|
2544
|
+
git diff --name-only "${last_hash}..HEAD" 2>/dev/null | grep -v '\.ralph/' || true
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
# Build markdown summary of the last 5 archived plans.
|
|
2548
|
+
_build_archive_summary() {
|
|
2549
|
+
local archive_dir="$UAT_MODE_DIR/archive"
|
|
2550
|
+
[[ ! -d "$archive_dir" ]] && return 0
|
|
2551
|
+
|
|
2552
|
+
local archives
|
|
2553
|
+
archives=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | sort -r | head -5)
|
|
2554
|
+
[[ -z "$archives" ]] && return 0
|
|
2555
|
+
|
|
2556
|
+
local archive_count
|
|
2557
|
+
archive_count=$(find "$archive_dir" -name 'plan-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
|
|
2558
|
+
|
|
2559
|
+
echo ""
|
|
2560
|
+
echo "### Prior Run History ($archive_count previous run$([ "$archive_count" -ne 1 ] && echo "s"))"
|
|
2561
|
+
echo ""
|
|
2562
|
+
echo "These tests have ALREADY been run. Do NOT repeat them."
|
|
2563
|
+
echo ""
|
|
2564
|
+
|
|
2565
|
+
local run_num=0
|
|
2566
|
+
while IFS= read -r archive_file; do
|
|
2567
|
+
[[ -z "$archive_file" ]] && continue
|
|
2568
|
+
run_num=$((run_num + 1))
|
|
2569
|
+
|
|
2570
|
+
# Extract timestamp from filename: plan-YYYYMMDD-HHMMSS.json
|
|
2571
|
+
local ts
|
|
2572
|
+
ts=$(basename "$archive_file" .json | sed 's/^plan-//')
|
|
2573
|
+
|
|
2574
|
+
echo "**Run $run_num** ($ts):"
|
|
2575
|
+
|
|
2576
|
+
# List test cases with status
|
|
2577
|
+
jq -r '.testCases[] | " \(.id) [\(.category // "general")] \(.title) — \(if .passes then (if .skipped then "SKIPPED" else "PASSED" end) else "FAILED" end)"' \
|
|
2578
|
+
"$archive_file" 2>/dev/null || true
|
|
2579
|
+
|
|
2580
|
+
echo ""
|
|
2581
|
+
done <<< "$archives"
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
# Build markdown section listing files changed since last run.
|
|
2585
|
+
_build_changed_files_section() {
|
|
2586
|
+
local changed_files
|
|
2587
|
+
changed_files=$(_get_changed_files_since_last_run)
|
|
2588
|
+
[[ -z "$changed_files" ]] && return 0
|
|
2589
|
+
|
|
2590
|
+
local file_count
|
|
2591
|
+
file_count=$(echo "$changed_files" | wc -l | tr -d ' ')
|
|
2592
|
+
|
|
2593
|
+
echo ""
|
|
2594
|
+
echo "### Files Changed Since Last Run ($file_count file$([ "$file_count" -ne 1 ] && echo "s"))"
|
|
2595
|
+
echo ""
|
|
2596
|
+
echo "PRIORITIZE testing these files — they are most likely to have new vulnerabilities."
|
|
2597
|
+
echo ""
|
|
2598
|
+
echo "$changed_files"
|
|
2599
|
+
}
|
|
2600
|
+
|
|
1978
2601
|
# ============================================================================
|
|
1979
2602
|
# HELPERS
|
|
1980
2603
|
# ============================================================================
|
|
@@ -2002,6 +2625,32 @@ _inject_prompt_context() {
|
|
|
2002
2625
|
echo "Read \`.ralph/config.json\` for URLs and directories." >> "$prompt_file"
|
|
2003
2626
|
fi
|
|
2004
2627
|
|
|
2628
|
+
# Inject prior run history (what was already tested)
|
|
2629
|
+
_build_archive_summary >> "$prompt_file"
|
|
2630
|
+
|
|
2631
|
+
# Inject changed files (what to focus on)
|
|
2632
|
+
_build_changed_files_section >> "$prompt_file"
|
|
2633
|
+
|
|
2634
|
+
# "Do Not Repeat" instruction block
|
|
2635
|
+
local has_history=false
|
|
2636
|
+
[[ -d "$UAT_MODE_DIR/archive" ]] && \
|
|
2637
|
+
[[ -n "$(find "$UAT_MODE_DIR/archive" -name 'plan-*.json' -type f 2>/dev/null | head -1)" ]] && \
|
|
2638
|
+
has_history=true
|
|
2639
|
+
|
|
2640
|
+
if [[ "$has_history" == "true" ]]; then
|
|
2641
|
+
cat >> "$prompt_file" << 'DO_NOT_REPEAT'
|
|
2642
|
+
|
|
2643
|
+
### Focus: New Ground Only
|
|
2644
|
+
|
|
2645
|
+
You have access to prior run history above. Follow these rules:
|
|
2646
|
+
- Do NOT repeat tests that already passed in prior runs
|
|
2647
|
+
- PRIORITIZE files changed since the last run
|
|
2648
|
+
- Go DEEPER — find new attack vectors, edge cases, and cross-feature interactions
|
|
2649
|
+
- If prior runs tested a feature superficially, test it more thoroughly
|
|
2650
|
+
- Focus on interactions BETWEEN features (e.g., auth + forms, navigation + data)
|
|
2651
|
+
DO_NOT_REPEAT
|
|
2652
|
+
fi
|
|
2653
|
+
|
|
2005
2654
|
# Inject signs
|
|
2006
2655
|
_inject_signs >> "$prompt_file"
|
|
2007
2656
|
}
|
package/ralph/utils.sh
CHANGED
|
@@ -859,7 +859,9 @@ find_all_migration_tools() {
|
|
|
859
859
|
done
|
|
860
860
|
|
|
861
861
|
# Return unique tools
|
|
862
|
-
|
|
862
|
+
if [[ ${#tools[@]} -gt 0 ]]; then
|
|
863
|
+
printf '%s\n' "${tools[@]}" | sort -u
|
|
864
|
+
fi
|
|
863
865
|
}
|
|
864
866
|
|
|
865
867
|
# Validate batch assignments in a PRD file
|
|
@@ -1027,3 +1029,15 @@ run_migrations_if_needed() {
|
|
|
1027
1029
|
return 1
|
|
1028
1030
|
fi
|
|
1029
1031
|
}
|
|
1032
|
+
|
|
1033
|
+
# Detect the available Docker Compose command.
|
|
1034
|
+
# Returns "docker compose" (v2) or "docker-compose" (v1), or empty string if neither.
|
|
1035
|
+
_detect_compose_cmd() {
|
|
1036
|
+
if docker compose version &>/dev/null; then
|
|
1037
|
+
echo "docker compose"
|
|
1038
|
+
elif command -v docker-compose &>/dev/null; then
|
|
1039
|
+
echo "docker-compose"
|
|
1040
|
+
else
|
|
1041
|
+
echo ""
|
|
1042
|
+
fi
|
|
1043
|
+
}
|