From 32261ca95a50dc6e38448b467c57a9fc3344b8dd Mon Sep 17 00:00:00 2001 From: Doogie201 Date: Sun, 1 Mar 2026 23:20:12 -0500 Subject: [PATCH] fix(gui): harden nlx runtime integrity and diagnose handling --- .pre-commit-config.yaml | 7 +- .../__tests__/nlxErrorSanitizer.test.ts | 6 +- .../src/engine/__tests__/nlxService.test.ts | 86 ++++++++ dashboard/src/engine/nlxErrorSanitizer.ts | 2 +- dashboard/src/engine/nlxService.ts | 24 ++- scripts/dev-setup.sh | 48 ++++- scripts/env-integrity.sh | 183 ++++++++++++++++++ 7 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 dashboard/src/engine/__tests__/nlxService.test.ts create mode 100755 scripts/env-integrity.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e6b9567..5b2e07a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,11 +45,12 @@ repos: files: "^scripts/.*\\.sh$" # only format our scripts/ exclude: "^docker/" # avoid parsing docker/orchestrate.sh for now - - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.11.0 + - repo: local hooks: - id: shellcheck - args: ["--severity", "warning"] + name: ShellCheck (system) + entry: shellcheck --severity warning + language: system files: "^scripts/.*\\.sh$" exclude: "^docker/" diff --git a/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts b/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts index b05ecdc..397e2f5 100644 --- a/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts +++ b/dashboard/src/engine/__tests__/nlxErrorSanitizer.test.ts @@ -51,7 +51,7 @@ describe("sanitizeNlxError", () => { expect(result.originalSuppressed).toBe(true); expect(result.message).toContain("nlx is not installed"); expect(result.message).toContain("git worktree"); - expect(result.fixCommand).toBe("bash scripts/dev-setup.sh"); + expect(result.fixCommand).toBe("bash scripts/dev-setup.sh --repair-env"); expect(result.context.isWorktree).toBe(true); }); @@ -65,7 +65,7 @@ describe("sanitizeNlxError", () => { expect(result.originalSuppressed).toBe(true); expect(result.message).toContain("Python error"); - expect(result.message).toContain("bash scripts/dev-setup.sh"); + expect(result.message).toContain("bash scripts/dev-setup.sh --repair-env"); expect(result.message).not.toContain("Traceback"); expect(result.message).not.toContain("File \"/usr/lib"); }); @@ -89,6 +89,6 @@ describe("sanitizeNlxError", () => { const result = sanitizeNlxError("missing_nlx", "", NON_WORKTREE_SHELL); expect(result.message).not.toContain("worktree"); - expect(result.message).toContain("bash scripts/dev-setup.sh"); + expect(result.message).toContain("bash scripts/dev-setup.sh --repair-env"); }); }); diff --git a/dashboard/src/engine/__tests__/nlxService.test.ts b/dashboard/src/engine/__tests__/nlxService.test.ts new file mode 100644 index 0000000..25a5995 --- /dev/null +++ b/dashboard/src/engine/__tests__/nlxService.test.ts @@ -0,0 +1,86 @@ +import { runAllowlistedNlxCommand } from "../nlxService"; +import { runCommandArgv } from "../runner"; + +vi.mock("../runner", () => ({ + runCommandArgv: vi.fn(), +})); + +vi.mock("../nlxErrorSanitizer", () => ({ + sanitizeNlxError: vi.fn((_errorType: string, stderr: string) => ({ + message: stderr, + fixCommand: "bash scripts/dev-setup.sh --repair-env", + context: { + cwd: "/tmp", + gitTopLevel: null, + isWorktree: false, + interpreterPath: null, + nlxAvailable: false, + }, + originalSuppressed: false, + })), +})); + +describe("nlxService diagnose handling", () => { + const mockedRunner = vi.mocked(runCommandArgv); + + beforeEach(() => { + mockedRunner.mockReset(); + }); + + it("coerces diagnose nonzero health output into a structured response", async () => { + mockedRunner + .mockResolvedValueOnce({ + argv: ["nlx", "diagnose"], + stdout: "", + stderr: "missing", + exitCode: 127, + timedOut: false, + aborted: false, + errorType: "missing_nlx", + }) + .mockResolvedValueOnce({ + argv: ["poetry", "run", "nlx", "diagnose"], + stdout: + 'DNS_MODE=local-private RESOLVER=192.168.64.2 PIHOLE=running PIHOLE_UPSTREAM=host.docker.internal#5053 CLOUDFLARED=down PLAINTEXT_DNS=no NOTES="cloudflared-down"', + stderr: "", + exitCode: 1, + timedOut: false, + aborted: false, + errorType: "nonzero_exit", + }); + + const result = await runAllowlistedNlxCommand("diagnose"); + + expect(result.ok).toBe(true); + expect(result.errorType).toBe("none"); + expect(result.diagnose?.badge).toBe("DEGRADED"); + expect(mockedRunner).toHaveBeenCalledTimes(2); + }); + + it("keeps diagnose as failure when output cannot be parsed", async () => { + mockedRunner + .mockResolvedValueOnce({ + argv: ["nlx", "diagnose"], + stdout: "not a diagnose line", + stderr: "", + exitCode: 1, + timedOut: false, + aborted: false, + errorType: "nonzero_exit", + }) + .mockResolvedValueOnce({ + argv: ["poetry", "run", "nlx", "diagnose"], + stdout: "still not valid", + stderr: "", + exitCode: 1, + timedOut: false, + aborted: false, + errorType: "nonzero_exit", + }); + + const result = await runAllowlistedNlxCommand("diagnose"); + + expect(result.ok).toBe(false); + expect(result.diagnose).toBeUndefined(); + }); +}); diff --git a/dashboard/src/engine/nlxErrorSanitizer.ts b/dashboard/src/engine/nlxErrorSanitizer.ts index e21bacf..236733f 100644 --- a/dashboard/src/engine/nlxErrorSanitizer.ts +++ b/dashboard/src/engine/nlxErrorSanitizer.ts @@ -10,7 +10,7 @@ export const TRACEBACK_PATTERNS: RegExp[] = [ /No module named/, ]; -const CANONICAL_FIX = "bash scripts/dev-setup.sh"; +const CANONICAL_FIX = "bash scripts/dev-setup.sh --repair-env"; export function containsTraceback(stderr: string): boolean { return TRACEBACK_PATTERNS.some((pattern) => pattern.test(stderr)); diff --git a/dashboard/src/engine/nlxService.ts b/dashboard/src/engine/nlxService.ts index 3db120d..b787b76 100644 --- a/dashboard/src/engine/nlxService.ts +++ b/dashboard/src/engine/nlxService.ts @@ -117,7 +117,13 @@ export async function runAllowlistedNlxCommand( response.taskNames = parseTaskNamesFromListTasks(response.stdout); } - if (spec.commandId === "diagnose" && response.ok) { + if (spec.commandId === "diagnose") { + const shouldCoerceDiagnoseHealth = + !response.ok && + response.errorType === "nonzero_exit" && + response.stderr.trim().length === 0 && + response.stdout.trim().length > 0; + try { const firstLine = response.stdout.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ""; const summary = parseDiagnoseLine(firstLine); @@ -125,12 +131,18 @@ export async function runAllowlistedNlxCommand( summary, badge: classifyDiagnose(summary), }; + if (shouldCoerceDiagnoseHealth) { + response.ok = true; + response.errorType = "none"; + } } catch { - response.ok = false; - response.errorType = "spawn_error"; - response.stderr = response.stderr - ? `${response.stderr}\nDiagnose output parsing failed.` - : "Diagnose output parsing failed."; + if (response.ok) { + response.ok = false; + response.errorType = "spawn_error"; + response.stderr = response.stderr + ? `${response.stderr}\nDiagnose output parsing failed.` + : "Diagnose output parsing failed."; + } } } diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 3735b75..868e07c 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -5,14 +5,17 @@ # Installs Python (Poetry) and dashboard (npm) dependencies. # # Usage: -# bash scripts/dev-setup.sh # full setup (network required for first run) -# bash scripts/dev-setup.sh --offline # skip network-dependent steps +# bash scripts/dev-setup.sh # full setup (network required for first run) +# bash scripts/dev-setup.sh --offline # skip network-dependent steps +# bash scripts/dev-setup.sh --repair-env # force clean venv rebuild set -euo pipefail OFFLINE=false +REPAIR_ENV=false for arg in "$@"; do case "$arg" in --offline) OFFLINE=true ;; + --repair-env) REPAIR_ENV=true ;; esac done @@ -34,6 +37,39 @@ echo "=== NextLevelApex dev-setup ===" echo "Repo root: $REPO_ROOT" echo "Worktree: $IS_WORKTREE" echo "Offline: $OFFLINE" +echo "Repair env: $REPAIR_ENV" + +# 0) Scrub AppleDouble metadata from generated directories. +echo "" +echo "[0/2] Scrubbing generated artifact metadata..." +bash "$REPO_ROOT/scripts/env-integrity.sh" scrub + +# Decide whether the local virtualenv must be rebuilt. +VENV_REBUILD_REQUIRED=false +if [ "$REPAIR_ENV" = true ]; then + VENV_REBUILD_REQUIRED=true +fi + +if [ -d "$REPO_ROOT/.venv" ]; then + if find "$REPO_ROOT/.venv" -type f -name '._*' -print -quit 2>/dev/null | grep -q .; then + echo "[dev-setup] detected AppleDouble metadata in .venv; scheduling rebuild." + VENV_REBUILD_REQUIRED=true + fi + + if ! (cd "$REPO_ROOT" && ./.venv/bin/python -I -W ignore -c 'import sys; print(sys.prefix)' >/dev/null 2>&1); then + echo "[dev-setup] .venv isolated startup failed; scheduling rebuild." + VENV_REBUILD_REQUIRED=true + fi +fi + +if [ "$VENV_REBUILD_REQUIRED" = true ]; then + if [ "$OFFLINE" = true ]; then + echo "[dev-setup] cannot rebuild .venv in --offline mode." >&2 + exit 3 + fi + echo "[dev-setup] rebuilding .venv..." + rm -rf "$REPO_ROOT/.venv" +fi # 1) Poetry: install Python deps + register nlx entrypoint echo "" @@ -56,6 +92,14 @@ else echo " Dashboard install complete." fi +echo "" +echo "[post] Scrubbing generated artifact metadata..." +bash "$REPO_ROOT/scripts/env-integrity.sh" scrub + +echo "" +echo "[post] Validating environment integrity..." +bash "$REPO_ROOT/scripts/env-integrity.sh" check + # Summary echo "" echo "=== Setup complete ===" diff --git a/scripts/env-integrity.sh b/scripts/env-integrity.sh new file mode 100755 index 0000000..0fe0bea --- /dev/null +++ b/scripts/env-integrity.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +MODE="${1:-check}" + +GENERATED_DIRS=( + ".venv" + "dashboard/node_modules" + "dashboard/.next" +) + +SOURCE_METADATA_GLOBS=( + "--hidden" + "-g" "._*" + "-g" "!.git/**" + "-g" "!artifacts/**" + "-g" "!.agent_state/**" + "-g" "!dashboard/node_modules/**" + "-g" "!dashboard/.next/**" + "-g" "!.venv/**" +) + +count_appledouble() { + local rel_dir="$1" + local abs_dir="$REPO_ROOT/$rel_dir" + + if [[ ! -d "$abs_dir" ]]; then + echo "0" + return 0 + fi + + find "$abs_dir" -type f -name '._*' 2>/dev/null | wc -l | tr -d ' ' +} + +first_appledouble() { + local rel_dir="$1" + local abs_dir="$REPO_ROOT/$rel_dir" + + if [[ ! -d "$abs_dir" ]]; then + return 0 + fi + + find "$abs_dir" -type f -name '._*' -print -quit 2>/dev/null || true +} + +scrub_appledouble() { + local rel_dir="$1" + local abs_dir="$REPO_ROOT/$rel_dir" + + if [[ ! -d "$abs_dir" ]]; then + echo "[env-integrity] skip scrub: $rel_dir (missing)" + return 0 + fi + + local before_count + before_count="$(count_appledouble "$rel_dir")" + if [[ "$before_count" == "0" ]]; then + echo "[env-integrity] scrub: $rel_dir clean" + return 0 + fi + + find "$abs_dir" -type f -name '._*' -delete 2>/dev/null + local after_count + after_count="$(count_appledouble "$rel_dir")" + local removed_count=$(( before_count - after_count )) + echo "[env-integrity] scrub: $rel_dir removed=$removed_count remaining=$after_count" +} + +check_venv_startup() { + if ! (cd "$REPO_ROOT" && ./.venv/bin/python -I -W ignore -c 'import sys; print(sys.prefix)' >/dev/null 2>&1); then + echo "[env-integrity] failure: .venv isolated Python startup failed" + return 1 + fi + + return 0 +} + +check_poetry_runtime() { + if ! (cd "$REPO_ROOT" && poetry run python -I -W ignore -c 'import pydantic, typer' >/dev/null 2>&1); then + echo "[env-integrity] failure: Poetry runtime smoke failed (pydantic/typer import)." + return 1 + fi + + return 0 +} + +check_repo_metadata() { + local first_match="" + if ! first_match="$(cd "$REPO_ROOT" && rg --files "${SOURCE_METADATA_GLOBS[@]}" | head -n 1)"; then + first_match="" + fi + if [[ -n "$first_match" ]]; then + echo "[env-integrity] failure: AppleDouble metadata detected in active source scope (example: $first_match)" + return 1 + fi + + echo "[env-integrity] check: active source scope clean" + return 0 +} + +scrub_repo_metadata() { + local matches="" + if ! matches="$(cd "$REPO_ROOT" && rg --files "${SOURCE_METADATA_GLOBS[@]}")"; then + matches="" + fi + if [[ -z "$matches" ]]; then + echo "[env-integrity] scrub: active source scope clean" + return 0 + fi + + local removed=0 + while IFS= read -r rel_path; do + [[ -z "$rel_path" ]] && continue + rm -f "$REPO_ROOT/$rel_path" + removed=$((removed + 1)) + done <<< "$matches" + echo "[env-integrity] scrub: active source scope removed=$removed" +} + +run_check() { + local status=0 + + for rel_dir in "${GENERATED_DIRS[@]}"; do + local first_match + first_match="$(first_appledouble "$rel_dir")" + if [[ -n "$first_match" ]]; then + local rel_path="${first_match#"$REPO_ROOT"/}" + if [[ "$rel_dir" == ".venv" ]]; then + echo "[env-integrity] failure: AppleDouble metadata detected under $rel_dir (example: $rel_path)" + status=1 + else + echo "[env-integrity] warning: AppleDouble metadata detected under $rel_dir (example: $rel_path)" + fi + else + echo "[env-integrity] check: $rel_dir clean" + fi + done + + if [[ -x "$REPO_ROOT/.venv/bin/python" ]]; then + if ! check_venv_startup; then + status=1 + else + echo "[env-integrity] check: .venv isolated startup ok" + fi + else + echo "[env-integrity] check: .venv/bin/python missing (ok: using Poetry-managed env)" + fi + + if ! check_poetry_runtime; then + status=1 + else + echo "[env-integrity] check: poetry runtime smoke ok" + fi + + if ! check_repo_metadata; then + status=1 + fi + + return "$status" +} + +run_scrub() { + for rel_dir in "${GENERATED_DIRS[@]}"; do + scrub_appledouble "$rel_dir" + done + scrub_repo_metadata +} + +case "$MODE" in + check) + run_check + ;; + scrub) + run_scrub + ;; + *) + echo "usage: scripts/env-integrity.sh [check|scrub]" >&2 + exit 2 + ;; +esac