diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 796f75fe9..f01d5a2d3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -183,10 +183,12 @@ server-side update execution surfaces. owns the run-scoped `HOT_DEV_VERIFY_LOCK_FILE` handoff so overlapping browser proof cannot reuse stale first-run credentials. The managed and foreground hot-dev entrypoints must share one network-default - contract: Vite binds `FRONTEND_DEV_HOST=0.0.0.0` by default so LAN devices can - open the dev browser shell, while local browser/proof URLs normalize wildcard - binds back to `127.0.0.1` and advertise the detected LAN browser entrypoint - separately. + contract: local dev binds frontend and backend traffic to loopback by default + so installed LAN agents cannot accidentally treat a developer laptop as the + active Pulse control plane. LAN exposure for agent/mobile testing must be an + explicit `PULSE_DEV_LAN=true` opt-in; only that mode may bind Vite/backend + listeners to `0.0.0.0`, include LAN origins, or advertise the detected LAN + browser entrypoint. The hot-dev supervisor must also recover its managed PID file from a live `hot-dev-bg.sh supervise` process before treating the runtime as unmanaged. Backend health monitoring must distinguish HTTP startup grace from a missing diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index a7414801b..6045ce0f5 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -5,7 +5,7 @@ import path from 'path'; import { URL } from 'node:url'; import { configDefaults } from 'vitest/config'; -const frontendDevHost = process.env.FRONTEND_DEV_HOST ?? '0.0.0.0'; +const frontendDevHost = process.env.FRONTEND_DEV_HOST ?? '127.0.0.1'; const frontendDevPort = Number( process.env.FRONTEND_DEV_PORT ?? process.env.VITE_PORT ?? process.env.PORT ?? 5173, ); @@ -48,7 +48,7 @@ export default defineConfig({ }, server: { port: frontendDevPort, - host: frontendDevHost, // Listen on all interfaces for remote access + host: frontendDevHost, strictPort: true, proxy: { '/ws': { diff --git a/scripts/hot-dev-bg.sh b/scripts/hot-dev-bg.sh index 2ac750cb5..62f306253 100755 --- a/scripts/hot-dev-bg.sh +++ b/scripts/hot-dev-bg.sh @@ -39,9 +39,15 @@ fail() { http_status_code() { local url="$1" - local code - code="$(curl -sS -m 2 -o /dev/null -w '%{http_code}' "${url}" 2>/dev/null || true)" - [[ -n "${code}" ]] || code="000" + local output code + output="$(curl -sS -m 2 -o /dev/null -w $'\n%{http_code}' "${url}" 2>&1 || true)" + code="${output##*$'\n'}" + if [[ ! "${code}" =~ ^[0-9][0-9][0-9]$ ]]; then + code="000" + fi + if [[ "${code}" == "000" && "${output}" == *"Operation not permitted"* ]]; then + code="blocked" + fi printf "%s\n" "${code}" } @@ -50,6 +56,11 @@ http_status_ok() { [[ "${code}" =~ ^2[0-9][0-9]$ || "${code}" =~ ^3[0-9][0-9]$ ]] } +http_status_blocked() { + local code="$1" + [[ "${code}" == "blocked" ]] +} + is_port_listening() { local port="$1" lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 @@ -75,6 +86,41 @@ process_parent_id() { ps -o ppid= -p "${pid}" 2>/dev/null | tr -d '[:space:]' } +process_probe_denied() { + local output="$1" + [[ "${output}" == *"Operation not permitted"* || "${output}" == *"operation not permitted"* || "${output}" == *"not permitted"* ]] +} + +pid_signal_probe() { + local pid="${1:-}" + local output + + [[ -n "${pid}" ]] || return 1 + if output="$(kill -0 "${pid}" 2>&1)"; then + return 0 + fi + if process_probe_denied "${output}"; then + return 2 + fi + return 1 +} + +pid_may_be_running() { + local status + pid_signal_probe "${1:-}" + status=$? + [[ "${status}" -eq 0 || "${status}" -eq 2 ]] +} + +process_inspection_restricted() { + local output + + if output="$(ps -o pid= -p "$$" 2>&1 >/dev/null)"; then + return 1 + fi + process_probe_denied "${output}" +} + discover_managed_supervisor_pid() { local pid command @@ -99,7 +145,7 @@ recover_managed_pid_file() { [[ "${PID_FILE}" == "${DEFAULT_HOT_DEV_BG_PID_FILE}" ]] || return 1 discovered_pid="$(discover_managed_supervisor_pid || true)" [[ -n "${discovered_pid}" ]] || return 1 - kill -0 "${discovered_pid}" 2>/dev/null || return 1 + pid_may_be_running "${discovered_pid}" || return 1 mkdir -p "$(dirname "${PID_FILE}")" printf "%s\n" "${discovered_pid}" > "${PID_FILE}" return 0 @@ -209,6 +255,10 @@ port_has_unmanaged_listener() { local session_pid="$2" local listener_pid + if [[ -n "${session_pid}" ]] && process_inspection_restricted; then + return 1 + fi + while IFS= read -r listener_pid; do [[ -n "${listener_pid}" ]] || continue if ! pid_is_managed "${listener_pid}" "${session_pid}"; then @@ -259,7 +309,7 @@ is_running() { fi local pid pid="$(cat "${PID_FILE}" 2>/dev/null || true)" - if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + if [[ -n "${pid}" ]] && pid_may_be_running "${pid}"; then return 0 fi rm -f "${PID_FILE}" @@ -285,6 +335,8 @@ describe_listener() { owner="unmanaged" if pid_is_managed "${listener_pid}" "${session_pid}"; then owner="managed" + elif [[ -n "${session_pid}" ]] && process_inspection_restricted; then + owner="unknown" fi command="$(process_command "${listener_pid}")" log "Port ${port}: ${owner} listener pid=${listener_pid} pgid=${pgid:-unknown} cmd=${command:-unknown}" @@ -730,6 +782,10 @@ status_bg() { if http_status_ok "${frontend_code}" && http_status_ok "${proxy_code}" && http_status_ok "${backend_code}"; then log "Runtime summary: frontend shell, proxy, and backend are healthy. Use ${browser_url} in the browser." + elif process_inspection_restricted && [[ "${frontend_code}" == "000" && "${backend_code}" == "000" ]] && is_port_listening "${FRONTEND_DEV_PORT}" && is_port_listening "${PULSE_DEV_API_PORT}"; then + log "Runtime summary: local HTTP probes are unavailable from this restricted shell, but frontend and backend listeners are present. Use ${browser_url} in the browser or rerun status outside the restricted shell." + elif http_status_blocked "${frontend_code}" || http_status_blocked "${proxy_code}" || http_status_blocked "${backend_code}"; then + log "Runtime summary: local HTTP probes are blocked by this shell. Use ${browser_url} in the browser or rerun status outside the restricted shell." elif http_status_ok "${frontend_code}" && ! http_status_ok "${proxy_code}" && http_status_ok "${backend_code}"; then log "Runtime summary: frontend shell is up and the backend is healthy, but the frontend proxy path is unhealthy." elif http_status_ok "${frontend_code}" && ! http_status_ok "${backend_code}"; then diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index a34ff308f..557b4091e 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -17,6 +17,7 @@ # PULSE_DATA_DIR=/path Override data directory # PULSE_DEV_API_PORT=7655 Backend API port (default: 7655) # FRONTEND_DEV_PORT=5173 Frontend dev server port (default: 5173) +# PULSE_DEV_LAN=true Expose frontend/backend on the LAN for agent/mobile testing (default: false) # LOG_LEVEL=debug Opt into verbose backend logs (default: info) # PULSE_DEV_DISABLE_BACKGROUND_AI=false # Allow automatic Patrol/discovery/alert AI in dev diff --git a/scripts/lib/hot-dev-runtime.sh b/scripts/lib/hot-dev-runtime.sh index f5355b834..8c0ee6e68 100644 --- a/scripts/lib/hot-dev-runtime.sh +++ b/scripts/lib/hot-dev-runtime.sh @@ -43,6 +43,23 @@ hot_dev_detect_all_ipv4s() { hostname -I 2>/dev/null || true } +hot_dev_truthy() { + local value + value="$(printf '%s\n' "${1:-}" | tr '[:upper:]' '[:lower:]')" + + case "${value}" in + 1|true|yes|y|on) + return 0 + ;; + esac + + return 1 +} + +hot_dev_lan_enabled() { + hot_dev_truthy "${PULSE_DEV_LAN:-false}" +} + hot_dev_configure_network_defaults() { FRONTEND_PORT="${FRONTEND_PORT:-${PORT:-5173}}" PORT="${PORT:-${FRONTEND_PORT}}" @@ -52,9 +69,16 @@ hot_dev_configure_network_defaults() { fi LAN_IP="${LAN_IP:-0.0.0.0}" - FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST:-0.0.0.0}" + if hot_dev_lan_enabled; then + FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST:-0.0.0.0}" + PULSE_DEV_API_HOST="${PULSE_DEV_API_HOST:-${LAN_IP}}" + BIND_ADDRESS="${BIND_ADDRESS:-0.0.0.0}" + else + FRONTEND_DEV_HOST="${FRONTEND_DEV_HOST:-127.0.0.1}" + PULSE_DEV_API_HOST="${PULSE_DEV_API_HOST:-127.0.0.1}" + BIND_ADDRESS="${BIND_ADDRESS:-127.0.0.1}" + fi FRONTEND_DEV_PORT="${FRONTEND_DEV_PORT:-${FRONTEND_PORT}}" - PULSE_DEV_API_HOST="${PULSE_DEV_API_HOST:-${LAN_IP}}" PULSE_DEV_API_PORT="${PULSE_DEV_API_PORT:-7655}" if [[ -z "${PULSE_DEV_API_URL:-}" ]]; then @@ -80,21 +104,23 @@ hot_dev_configure_network_defaults() { ALLOWED_ORIGINS="${ALLOWED_ORIGINS},http://0.0.0.0:${FRONTEND_DEV_PORT:-7655},http://0.0.0.0:5173" fi - local ip - for ip in ${ALL_IPS:-}; do - if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - if [[ "${ip}" != "127.0.0.1" ]]; then - ALLOWED_ORIGINS="${ALLOWED_ORIGINS},http://${ip}:${FRONTEND_DEV_PORT:-7655}" - ALLOWED_ORIGINS="${ALLOWED_ORIGINS},http://${ip}:5173" + if hot_dev_lan_enabled; then + local ip + for ip in ${ALL_IPS:-}; do + if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "${ip}" != "127.0.0.1" ]]; then + ALLOWED_ORIGINS="${ALLOWED_ORIGINS},http://${ip}:${FRONTEND_DEV_PORT:-7655}" + ALLOWED_ORIGINS="${ALLOWED_ORIGINS},http://${ip}:5173" + fi fi - fi - done + done + fi export FRONTEND_PORT PORT export LAN_IP ALL_IPS export FRONTEND_DEV_HOST FRONTEND_DEV_PORT export PULSE_DEV_API_HOST PULSE_DEV_API_PORT PULSE_DEV_API_URL PULSE_DEV_WS_URL - export ALLOWED_ORIGINS + export BIND_ADDRESS ALLOWED_ORIGINS } hot_dev_normalize_host() { @@ -233,6 +259,13 @@ hot_dev_reconcile_agent_bind_address() { hot_dev_host_is_loopback "${host}" && continue hot_dev_host_matches_local_interface "${host}" || continue + if ! hot_dev_lan_enabled; then + if declare -F log_warn >/dev/null 2>&1; then + log_warn "${label}=${url} points at this machine, but hot-dev is local-only by default. Leaving BIND_ADDRESS=${bind_address}; set PULSE_DEV_LAN=true to expose this dev backend to installed agents." + fi + return 0 + fi + BIND_ADDRESS="0.0.0.0" export BIND_ADDRESS diff --git a/scripts/tests/test-hot-dev-bg.sh b/scripts/tests/test-hot-dev-bg.sh index 32c9f7b91..3847c1ace 100755 --- a/scripts/tests/test-hot-dev-bg.sh +++ b/scripts/tests/test-hot-dev-bg.sh @@ -210,7 +210,7 @@ EOF assert_contains "default verify proof keeps chromium project pin" "${output}" "--project=chromium" } -test_managed_wrapper_defaults_to_lan_capable_frontend() { +test_managed_wrapper_defaults_to_local_only_runtime() { local output output="$( LAN_IP=192.168.50.10 \ @@ -220,15 +220,17 @@ test_managed_wrapper_defaults_to_lan_capable_frontend() { source "${HOT_DEV_BG_PATH}" printf "frontend_host=%s\n" "${FRONTEND_DEV_HOST}" printf "api_host=%s\n" "${PULSE_DEV_API_HOST}" + printf "bind=%s\n" "${BIND_ADDRESS}" printf "browser=%s\n" "$(hot_dev_local_browser_url "${FRONTEND_DEV_HOST}" "${FRONTEND_DEV_PORT}")" printf "lan_browser=%s\n" "$(hot_dev_lan_browser_url "${FRONTEND_DEV_HOST}" "${FRONTEND_DEV_PORT}" "${LAN_IP}")" ' )" - assert_contains "managed wrapper exposes Vite on all interfaces by default" "${output}" "frontend_host=0.0.0.0" - assert_contains "managed wrapper uses the shared LAN API default" "${output}" "api_host=192.168.50.10" + assert_contains "managed wrapper keeps Vite on loopback by default" "${output}" "frontend_host=127.0.0.1" + assert_contains "managed wrapper uses the loopback API default" "${output}" "api_host=127.0.0.1" + assert_contains "managed wrapper binds the backend to loopback by default" "${output}" "bind=127.0.0.1" assert_contains "managed wrapper keeps local browser entrypoint on loopback" "${output}" "browser=http://127.0.0.1:5173" - assert_contains "managed wrapper advertises a LAN browser entrypoint" "${output}" "lan_browser=http://192.168.50.10:5173" + assert_contains "managed wrapper does not advertise a LAN browser entrypoint by default" "${output}" "lan_browser=" } test_verify_bg_holds_runtime_lock_for_proof_duration() { @@ -752,18 +754,28 @@ test_hot_dev_reconciles_agent_reachable_bind_address() { ALL_IPS="192.168.50.10 10.10.10.5" BIND_ADDRESS=127.0.0.1 + unset PULSE_DEV_LAN PULSE_PUBLIC_URL=http://192.168.50.10:7655 unset PULSE_AGENT_CONNECT_URL PULSE_AGENT_URL hot_dev_reconcile_agent_bind_address - printf "lan_bind=%s\n" "${BIND_ADDRESS}" + printf "default_lan_bind=%s\n" "${BIND_ADDRESS}" BIND_ADDRESS=127.0.0.1 + PULSE_DEV_LAN=true + PULSE_PUBLIC_URL=http://192.168.50.10:7655 + unset PULSE_AGENT_CONNECT_URL PULSE_AGENT_URL + hot_dev_reconcile_agent_bind_address + printf "opt_in_lan_bind=%s\n" "${BIND_ADDRESS}" + + BIND_ADDRESS=127.0.0.1 + PULSE_DEV_LAN=true PULSE_PUBLIC_URL=https://pulse.example.com unset PULSE_AGENT_CONNECT_URL PULSE_AGENT_URL hot_dev_reconcile_agent_bind_address printf "external_bind=%s\n" "${BIND_ADDRESS}" BIND_ADDRESS=0.0.0.0 + PULSE_DEV_LAN=true PULSE_AGENT_CONNECT_URL=http://192.168.50.10:7655 unset PULSE_PUBLIC_URL PULSE_AGENT_URL hot_dev_reconcile_agent_bind_address @@ -771,7 +783,8 @@ test_hot_dev_reconciles_agent_reachable_bind_address() { ' )" - assert_contains "hot-dev exposes backend for LAN public URL" "${output}" "lan_bind=0.0.0.0" + assert_contains "hot-dev stays local-only for LAN public URL by default" "${output}" "default_lan_bind=127.0.0.1" + assert_contains "hot-dev exposes backend for LAN public URL when explicitly opted in" "${output}" "opt_in_lan_bind=0.0.0.0" assert_contains "hot-dev leaves external public URLs on loopback" "${output}" "external_bind=127.0.0.1" assert_contains "hot-dev preserves already exposed backend bind" "${output}" "already_exposed_bind=0.0.0.0" } @@ -871,6 +884,100 @@ test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor() { assert_contains "managed_session_pid returns recovered supervisor pid" "${output}" "managed_pid=4242" } +test_hot_dev_bg_preserves_pid_file_when_signal_probe_is_denied() { + local test_dir output + test_dir="$(mktemp -d)" + temp_dirs+=("${test_dir}") + + output="$( + HOT_DEV_BG_PATH="${HOT_DEV_BG}" \ + bash -lc ' + source "${HOT_DEV_BG_PATH}" + PID_FILE="'"${test_dir}"'/hot-dev-bg.pid" + printf "4242\n" > "${PID_FILE}" + kill() { + if [[ "${1:-}" == "-0" && "${2:-}" == "4242" ]]; then + printf "kill: 4242: Operation not permitted\n" >&2 + return 1 + fi + return 1 + } + recover_managed_pid_file() { + printf "recover_called=yes\n" + return 1 + } + + if is_running; then + printf "running=yes\n" + else + printf "running=no\n" + fi + if [[ -f "${PID_FILE}" ]]; then + printf "pid_file=%s\n" "$(cat "${PID_FILE}")" + else + printf "pid_file=missing\n" + fi + ' + )" + + assert_contains "permission-denied process probes are treated as alive" "${output}" "running=yes" + assert_contains "permission-denied process probes do not delete the pid file" "${output}" "pid_file=4242" + assert_not_contains "permission-denied process probes do not trigger recovery" "${output}" "recover_called=yes" +} + +test_hot_dev_bg_status_degrades_ownership_when_process_inspection_is_restricted() { + local test_dir output + test_dir="$(mktemp -d)" + temp_dirs+=("${test_dir}") + + output="$( + HOT_DEV_BG_PATH="${HOT_DEV_BG}" \ + bash -lc ' + source "${HOT_DEV_BG_PATH}" + PID_FILE="'"${test_dir}"'/hot-dev-bg.pid" + printf "4242\n" > "${PID_FILE}" + kill() { + if [[ "${1:-}" == "-0" && "${2:-}" == "4242" ]]; then + return 0 + fi + return 1 + } + process_inspection_restricted(){ return 0; } + is_port_listening(){ return 0; } + listener_pids(){ printf "14111\n"; } + process_group_id(){ return 1; } + process_command(){ return 1; } + http_status_code(){ printf "000\n"; } + + status_bg + ' + )" + + assert_contains "restricted status keeps the managed runtime pid" "${output}" "[hot-dev-bg] Running (pid: 4242)" + assert_contains "restricted status marks listener ownership as unknown" "${output}" "[hot-dev-bg] Port 5173: unknown listener pid=14111" + assert_not_contains "restricted status does not accuse split ownership" "${output}" "Detected split ownership" + assert_contains "restricted status reports unavailable health probes without claiming a crash" "${output}" "local HTTP probes are unavailable from this restricted shell" +} + +test_hot_dev_bg_http_status_reports_blocked_local_probe() { + local output + + output="$( + HOT_DEV_BG_PATH="${HOT_DEV_BG}" \ + bash -lc ' + source "${HOT_DEV_BG_PATH}" + curl() { + printf "curl: (7) Failed to connect to 127.0.0.1 port 5173: Operation not permitted\n" >&2 + printf "000\n" + return 7 + } + printf "code=%s\n" "$(http_status_code "http://127.0.0.1:5173/")" + ' + )" + + assert_contains "local network sandbox denial is reported distinctly" "${output}" "code=blocked" +} + test_hot_dev_bg_usage_prefers_managed_wrappers() { local output output="$("${HOT_DEV_BG}" 2>&1 || true)" @@ -1403,7 +1510,7 @@ main() { test_cli_parses_takeover_flag test_verify_command_injects_managed_runtime_env test_default_verify_command_runs_runtime_and_layout_proofs - test_managed_wrapper_defaults_to_lan_capable_frontend + test_managed_wrapper_defaults_to_local_only_runtime test_verify_bg_holds_runtime_lock_for_proof_duration test_managed_dev_runtime_restarts_existing_session_for_verification test_takeover_avoids_killing_current_shell_lineage @@ -1424,6 +1531,9 @@ main() { test_hot_dev_health_monitor_probes_api_health test_hot_dev_bg_script_advertises_managed_entrypoint test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor + test_hot_dev_bg_preserves_pid_file_when_signal_probe_is_denied + test_hot_dev_bg_status_degrades_ownership_when_process_inspection_is_restricted + test_hot_dev_bg_http_status_reports_blocked_local_probe test_hot_dev_bg_usage_prefers_managed_wrappers test_integration_readme_uses_managed_backend_restart_wrapper test_integration_readme_documents_retired_trial_start_contract @@ -1452,4 +1562,6 @@ main() { echo "hot-dev-bg ownership diagnostics smoke tests passed." } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/tests/test-hot-dev-runtime.sh b/scripts/tests/test-hot-dev-runtime.sh index fb0f5ad76..9e6fcf971 100755 --- a/scripts/tests/test-hot-dev-runtime.sh +++ b/scripts/tests/test-hot-dev-runtime.sh @@ -138,36 +138,57 @@ test_hot_dev_avoids_self_killing_npm_wrapper() { assert_not_contains "hot-dev no longer broad-kills npm dev wrappers" "${output}" 'pkill -f "npm run dev"' } -test_hot_dev_network_defaults_are_lan_capable() { +test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in() { local output output="$( HOT_DEV_RUNTIME_LIB="${HOT_DEV_RUNTIME_LIB}" \ bash -c ' set -euo pipefail source "${HOT_DEV_RUNTIME_LIB}" - unset FRONTEND_PORT PORT FRONTEND_DEV_HOST FRONTEND_DEV_PORT + unset FRONTEND_PORT PORT FRONTEND_DEV_HOST FRONTEND_DEV_PORT BIND_ADDRESS PULSE_DEV_LAN unset PULSE_DEV_API_HOST PULSE_DEV_API_PORT PULSE_DEV_API_URL PULSE_DEV_WS_URL unset ALLOWED_ORIGINS LAN_IP=192.168.50.10 ALL_IPS="192.168.50.10 10.10.10.5" hot_dev_configure_network_defaults - printf "frontend_host=%s\n" "${FRONTEND_DEV_HOST}" - printf "frontend_port=%s\n" "${FRONTEND_DEV_PORT}" - printf "api_host=%s\n" "${PULSE_DEV_API_HOST}" - printf "api_url=%s\n" "${PULSE_DEV_API_URL}" - printf "ws_url=%s\n" "${PULSE_DEV_WS_URL}" - printf "origins=%s\n" "${ALLOWED_ORIGINS}" + printf "default_frontend_host=%s\n" "${FRONTEND_DEV_HOST}" + printf "default_frontend_port=%s\n" "${FRONTEND_DEV_PORT}" + printf "default_api_host=%s\n" "${PULSE_DEV_API_HOST}" + printf "default_api_url=%s\n" "${PULSE_DEV_API_URL}" + printf "default_ws_url=%s\n" "${PULSE_DEV_WS_URL}" + printf "default_bind=%s\n" "${BIND_ADDRESS}" + printf "default_origins=%s\n" "${ALLOWED_ORIGINS}" + + unset FRONTEND_PORT PORT FRONTEND_DEV_HOST FRONTEND_DEV_PORT BIND_ADDRESS + unset PULSE_DEV_API_HOST PULSE_DEV_API_PORT PULSE_DEV_API_URL PULSE_DEV_WS_URL + unset ALLOWED_ORIGINS + PULSE_DEV_LAN=true + hot_dev_configure_network_defaults + printf "lan_frontend_host=%s\n" "${FRONTEND_DEV_HOST}" + printf "lan_api_host=%s\n" "${PULSE_DEV_API_HOST}" + printf "lan_api_url=%s\n" "${PULSE_DEV_API_URL}" + printf "lan_ws_url=%s\n" "${PULSE_DEV_WS_URL}" + printf "lan_bind=%s\n" "${BIND_ADDRESS}" + printf "lan_origins=%s\n" "${ALLOWED_ORIGINS}" ' )" - assert_contains "network defaults expose the frontend on all interfaces" "${output}" "frontend_host=0.0.0.0" - assert_contains "network defaults keep the canonical frontend dev port" "${output}" "frontend_port=5173" - assert_contains "network defaults target the detected LAN API host" "${output}" "api_host=192.168.50.10" - assert_contains "network defaults derive the API URL" "${output}" "api_url=http://192.168.50.10:7655" - assert_contains "network defaults derive the websocket URL" "${output}" "ws_url=ws://192.168.50.10:7655" - assert_contains "network defaults allow the detected LAN frontend origin" "${output}" "http://192.168.50.10:5173" + assert_contains "network defaults keep hot-dev frontend loopback-only" "${output}" "default_frontend_host=127.0.0.1" + assert_contains "network defaults keep the canonical frontend dev port" "${output}" "default_frontend_port=5173" + assert_contains "network defaults target loopback API host" "${output}" "default_api_host=127.0.0.1" + assert_contains "network defaults derive the loopback API URL" "${output}" "default_api_url=http://127.0.0.1:7655" + assert_contains "network defaults derive the loopback websocket URL" "${output}" "default_ws_url=ws://127.0.0.1:7655" + assert_contains "network defaults bind backend to loopback" "${output}" "default_bind=127.0.0.1" assert_contains "network defaults retain loopback frontend origins" "${output}" "http://127.0.0.1:5173" - assert_contains "network defaults allow the wildcard dev origin Electron may report" "${output}" "http://0.0.0.0:5173" + assert_not_contains "network defaults do not allow detected LAN frontend origins by default" "${output}" "default_origins=http://192.168.50.10" + assert_not_contains "network defaults do not allow wildcard dev origin by default" "${output}" "default_origins=http://0.0.0.0:5173" + assert_contains "LAN opt-in exposes the frontend on all interfaces" "${output}" "lan_frontend_host=0.0.0.0" + assert_contains "LAN opt-in targets the detected LAN API host" "${output}" "lan_api_host=192.168.50.10" + assert_contains "LAN opt-in derives the API URL" "${output}" "lan_api_url=http://192.168.50.10:7655" + assert_contains "LAN opt-in derives the websocket URL" "${output}" "lan_ws_url=ws://192.168.50.10:7655" + assert_contains "LAN opt-in binds backend to all interfaces" "${output}" "lan_bind=0.0.0.0" + assert_contains "LAN opt-in allows the detected LAN frontend origin" "${output}" "lan_origins=http://192.168.50.10" + assert_contains "LAN opt-in allows the wildcard dev origin Electron may report" "${output}" "http://0.0.0.0:5173" } test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts() { @@ -195,7 +216,7 @@ test_pulse_process_count_counts_matching_processes test_hot_dev_uses_resilient_backend_process_count test_hot_dev_keeps_backend_launch_errors_in_debug_log test_hot_dev_avoids_self_killing_npm_wrapper -test_hot_dev_network_defaults_are_lan_capable +test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts if (( failures > 0 )); then