Make hot-dev local-only by default

Keep the managed dev runtime on loopback unless PULSE_DEV_LAN=true is set, so installed LAN agents cannot accidentally attach to a developer session. Harden managed status checks for restricted local shells. Refs architecture post-RC canonicalization follow-up.
This commit is contained in:
rcourtman
2026-05-21 15:26:53 +01:00
parent bc2adac8b5
commit a77420e88a
7 changed files with 271 additions and 46 deletions
@@ -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
+2 -2
View File
@@ -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': {
+61 -5
View File
@@ -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
+1
View File
@@ -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
+44 -11
View File
@@ -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
+120 -8
View File
@@ -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
+37 -16
View File
@@ -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