diff --git a/docs/manage-sandboxes/update-sandboxes.mdx b/docs/manage-sandboxes/update-sandboxes.mdx index 62be822eff5..6892737be79 100644 --- a/docs/manage-sandboxes/update-sandboxes.mdx +++ b/docs/manage-sandboxes/update-sandboxes.mdx @@ -78,9 +78,10 @@ During the automatic upgrade path, the installer prepares the current NemoClaw C If any sandbox is skipped or fails, the installer exits before it changes the gateway. After backup, the installer requires any existing OpenShell executable it will use to report a version, then compares that version with the supported range declared by the prepared current source. It retires the running gateway before replacing an out-of-range OpenShell installation, keeps the gateway when the installed version is supported, and stops without retiring it when the installed version or supported range cannot be validated. -On Linux, failed OpenShell lifecycle commands cause the installer to check a verified NemoClaw-managed gateway PID file for any configured gateway port. -For the default gateway on port `8080`, the installer first checks a verified active `nemoclaw-openshell-gateway.service`, then checks the PID file. -The service path requires an active `nemoclaw-openshell-gateway.service` with a trusted unit path, NemoClaw-managed marker, file owner, and `ExecStart` binary. +If OpenShell lifecycle commands cannot retire the gateway, the installer can use verified service or PID-file evidence on Linux and macOS. +For the default gateway on port `8080`, Linux first checks verified systemd service evidence for `nemoclaw-openshell-gateway.service`, while macOS first checks verified Homebrew gateway service evidence. +On either platform, the installer can then use a verified NemoClaw-managed gateway PID file for the configured gateway port. +If macOS cannot obtain a conclusive `lsof` listener observation for a stale PID, the installer stops retirement and preserves the PID file, OpenShell registration, and every sandbox backup. After the service or PID-file check confirms the gateway process is stopped, the installer tries to remove the selected OpenShell registration. If registration removal fails, the installer warns that onboarding will replace the stale registration. If neither fallback can verify and stop the process, the installer stops after backup, preserves every sandbox backup, and reports the failed operation. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d4d2fbed1c3..3413425ddda 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -673,14 +673,19 @@ When existing sandboxes were created with OpenShell earlier than `0.0.37`, the i For scripted installs, set `NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE=1` to allow the automatic path to prepare the current CLI without replacing OpenShell, back up every registered sandbox with the current state manifest, retire an installed gateway whose OpenShell version is outside the current release's supported range, install the supported OpenShell release, and recover the existing sandboxes. The installer reads that supported range from the prepared current source and stops without retiring the gateway if the installed version is unknown or the range is missing or invalid. When the installed OpenShell version is already supported, the installer keeps the running gateway through the host update. -On Linux, if installed OpenShell lifecycle commands cannot retire the gateway, the installer checks a verified NemoClaw-managed gateway PID file for any configured gateway port. -For the default gateway on port `8080`, the installer first checks a verified active `nemoclaw-openshell-gateway.service`, then checks the PID file. +On Linux and macOS, if installed OpenShell lifecycle commands cannot retire the gateway, the installer uses verified NemoClaw-managed service or PID-file evidence for the supported recovery path. +For the default gateway on port `8080`, Linux recovery first checks a verified active `nemoclaw-openshell-gateway.service`, while macOS recovery first checks a verified active Homebrew gateway service. +Both paths then check a verified NemoClaw-managed gateway PID file for any configured gateway port. +When a macOS PID file names a process that is no longer running, the installer removes the stale PID file only after `lsof` reports no listener and no diagnostic output. +If `lsof` is unavailable, reports permission or other diagnostics, or produces inconsistent listener results, the installer stops before retiring the gateway and preserves the PID file, OpenShell registration, and sandbox backups. After either fallback confirms the gateway process is stopped, the installer tries to remove the selected OpenShell registration and warns if onboarding must replace a stale registration. If neither fallback can verify and stop the process, the installer stops after backup with every sandbox backup preserved. If any registered sandbox cannot be backed up, the installer aborts before it changes the gateway. After the automatic path retires an out-of-range gateway, it forces installation of the OpenShell version pinned by the prepared source before recovery. This mandatory installation applies to source and managed install modes and cannot remain deferred after gateway retirement. If the forced installation fails, the installer does not stage a gateway service or start recovery, preserves the backups, and tells you to rerun with `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1`. +On macOS, if the verified Homebrew installation does not resolve executable OpenShell CLI and gateway binaries, the installer also stops before recovery and preserves the backups and prepared recovery state. +It does not fall back to a stale user-local OpenShell binary. When the registry contains a pre-fingerprint OpenClaw or Hermes entry with no recorded custom-image evidence, an interactive install asks you to confirm that the listed sandbox used a NemoClaw-managed image. For a non-interactive install, set `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` to the JSON array of names printed by the installer, such as `["my-assistant","preserve-hermes"]`, only after verifying every named sandbox used a managed image. The confirmation permits those legacy entries to recover onto the current managed image, but it does not override recorded custom-image evidence. diff --git a/handoff-issue-contract.md b/handoff-issue-contract.md deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index ac4531422ee..5a9bb52ed20 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -168,6 +168,21 @@ fi HOMEBREW_TAP="nvidia/openshell" HOMEBREW_FORMULA_NAME="openshell" +MACOS_INSTALL_METHOD="${_NEMOCLAW_OPENSHELL_INSTALL_METHOD:-auto}" +case "$MACOS_INSTALL_METHOD" in + auto) ;; + homebrew) + [ "$OS" = "Darwin" ] || fail "The Homebrew OpenShell installation method is valid only on macOS." + command -v brew >/dev/null 2>&1 \ + || fail "The selected Homebrew OpenShell installation method became unavailable before installation." + ;; + standalone) + [ "$OS" = "Darwin" ] || fail "The standalone macOS OpenShell installation method is valid only on macOS." + ! command -v brew >/dev/null 2>&1 \ + || fail "Homebrew appeared after standalone OpenShell installation was selected; refusing an ambiguous installation method." + ;; + *) fail "The internal macOS OpenShell installation method is invalid." ;; +esac # Honour the TS installer's blueprint-derived env overrides only on the stable # channel — the dev channel installs from the `dev` tag and uses DEV_MIN_VERSION diff --git a/scripts/install.sh b/scripts/install.sh index 7a51b57638c..db711ee17de 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1447,9 +1447,46 @@ refresh_path() { fi } +prefer_homebrew_openshell() { + [[ "$(uname -s)" == "Darwin" ]] || return 1 + command -v brew >/dev/null 2>&1 || return 1 + if [[ "${1:-}" != "verified-install" ]]; then + macos_openshell_homebrew_gateway_service_installed || return 1 + fi + + local brew_prefix openshell_bin gateway_bin + brew_prefix="$(brew --prefix 2>/dev/null)" || return 1 + [ -n "$brew_prefix" ] || return 1 + openshell_bin="${brew_prefix%/}/bin/openshell" + gateway_bin="${brew_prefix%/}/bin/openshell-gateway" + [ -x "$openshell_bin" ] && [ -x "$gateway_bin" ] || return 1 + export NEMOCLAW_OPENSHELL_BIN="$openshell_bin" + export NEMOCLAW_OPENSHELL_GATEWAY_BIN="$gateway_bin" + export PATH="${brew_prefix%/}/bin:$PATH" +} + +observed_macos_openshell_install_method() { + if command -v brew >/dev/null 2>&1; then + printf '%s\n' "homebrew" + else + printf '%s\n' "standalone" + fi +} + prefer_user_local_openshell() { local local_bin="${XDG_BIN_HOME:-${HOME}/.local/bin}" local openshell_bin="${local_bin}/openshell" + local gateway_bin="${local_bin}/openshell-gateway" + if [[ "${1:-}" == "verified-install" ]]; then + [[ "$local_bin" == /* ]] || return 1 + [[ -x "$openshell_bin" && -x "$gateway_bin" ]] || return 1 + export NEMOCLAW_OPENSHELL_BIN="$openshell_bin" + export NEMOCLAW_OPENSHELL_GATEWAY_BIN="$gateway_bin" + if [[ ":$PATH:" != *":$local_bin:"* ]]; then + export PATH="$local_bin:$PATH" + fi + return 0 + fi if [[ -x "$openshell_bin" ]]; then export NEMOCLAW_OPENSHELL_BIN="$openshell_bin" if [[ ":$PATH:" != *":$local_bin:"* ]]; then @@ -1954,21 +1991,23 @@ NODE maybe_install_openshell_during_install() { local mode="${1:-force}" local explicit_openshell_bin="${NEMOCLAW_OPENSHELL_BIN:-}" + local platform macos_install_method="" observed_install_method="" + platform="$(uname -s)" || return 1 if truthy_env "${NEMOCLAW_DEFER_OPENSHELL_INSTALL:-}"; then info "Deferring OpenShell CLI installation until after pre-upgrade backup." return 0 fi if [[ "$mode" == "if-missing" ]] && command_exists openshell; then - if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1 \ + if [[ "$platform" == "Darwin" ]] && command -v brew >/dev/null 2>&1 \ && ! macos_openshell_homebrew_gateway_service_installed; then info "OpenShell CLI exists but the macOS Homebrew gateway service is missing." else - if [[ "$(uname -s)" == "Darwin" ]] && ! command -v brew >/dev/null 2>&1; then + if [[ "$platform" == "Darwin" ]] && ! command -v brew >/dev/null 2>&1; then warn "Homebrew is not installed; using the standalone OpenShell gateway without reboot persistence." fi - prefer_user_local_openshell - # Service discovery still uses the user-local PATH, but a source-checkout - # caller's executable selection remains authoritative for onboarding. + prefer_homebrew_openshell || prefer_user_local_openshell + # Service discovery uses the selected OpenShell PATH, but a source-checkout + # caller's explicit executable selection remains authoritative for onboarding. if [[ "$explicit_openshell_bin" == /* && -f "$explicit_openshell_bin" && -x "$explicit_openshell_bin" ]]; then export NEMOCLAW_OPENSHELL_BIN="$explicit_openshell_bin" fi @@ -1976,10 +2015,36 @@ maybe_install_openshell_during_install() { return 0 fi fi - if ! spin "Installing OpenShell CLI" bash "${NEMOCLAW_SOURCE_ROOT}/scripts/install-openshell.sh"; then + if [[ "$platform" == "Darwin" ]]; then + macos_install_method="$(observed_macos_openshell_install_method)" || return 1 + fi + if ! _NEMOCLAW_OPENSHELL_INSTALL_METHOD="$macos_install_method" \ + spin "Installing OpenShell CLI" bash "${NEMOCLAW_SOURCE_ROOT}/scripts/install-openshell.sh"; then return 1 fi - prefer_user_local_openshell + if [[ "$platform" == "Darwin" ]]; then + observed_install_method="$(observed_macos_openshell_install_method)" || return 1 + [[ "$observed_install_method" == "$macos_install_method" ]] \ + || error "The macOS OpenShell installation method changed while installation was running. The installer stopped before selecting OpenShell binaries or recovering the gateway." + case "$macos_install_method" in + homebrew) + # install-openshell.sh returned success only after verifying and installing + # the pinned formula, so this does not re-read the untrusted tap formula. + prefer_homebrew_openshell verified-install \ + || error "The verified Homebrew OpenShell installation did not provide executable CLI and gateway binaries. The installer stopped before gateway recovery." + ;; + standalone) + prefer_user_local_openshell verified-install \ + || error "The verified standalone OpenShell installation did not provide trusted executable user-local CLI and gateway binaries. The installer stopped before gateway recovery." + warn "Homebrew is not installed; using the verified standalone OpenShell gateway without reboot persistence." + ;; + *) + error "The macOS OpenShell installation method could not be verified. The installer stopped before selecting OpenShell binaries or recovering the gateway." + ;; + esac + else + prefer_user_local_openshell + fi install_nemoclaw_openshell_gateway_user_service } @@ -3166,10 +3231,105 @@ EOF esac } +trusted_macos_openshell_gateway_process() { + [ "$(uname -s)" = "Darwin" ] || return 1 + + local pid="$1" gateway_port="$2" expected_generation="${3:-}" + local gateway_name gateway_command gateway_exe gateway_lsof_output + local process_generation_before process_generation_after + local observation_diagnostics_file observation_status observation_valid + local user_bin_home brew_prefix trusted_brew_gateway + command_exists ps || return 1 + command_exists lsof || return 1 + + gateway_name="$(nemoclaw_gateway_name)" || return 1 + observation_diagnostics_file="$(mktemp "${TMPDIR:-/tmp}/nemoclaw-process-observation-XXXXXX" 2>/dev/null)" \ + || return 1 + _cleanup_files+=("$observation_diagnostics_file") + observation_valid=true + + observation_status=0 + process_generation_before="$(ps -p "$pid" -o lstart= 2>"$observation_diagnostics_file")" \ + || observation_status=$? + if [ "$observation_status" -ne 0 ] || [ -s "$observation_diagnostics_file" ] || [ -z "$process_generation_before" ]; then + observation_valid=false + fi + + if $observation_valid; then + : >"$observation_diagnostics_file" + observation_status=0 + gateway_command="$(ps -p "$pid" -o args= 2>"$observation_diagnostics_file")" \ + || observation_status=$? + if [ "$observation_status" -ne 0 ] || [ -s "$observation_diagnostics_file" ] \ + || [ "$gateway_command" != "openshell-gateway[nemoclaw=${gateway_name};port=${gateway_port}]" ]; then + observation_valid=false + fi + fi + + if $observation_valid; then + : >"$observation_diagnostics_file" + observation_status=0 + gateway_lsof_output="$(lsof -a -p "$pid" -d txt -Fn 2>"$observation_diagnostics_file")" \ + || observation_status=$? + if [ "$observation_status" -ne 0 ] || [ -s "$observation_diagnostics_file" ] \ + || [[ "$gateway_lsof_output" != "p${pid}"$'\n'* ]]; then + observation_valid=false + else + gateway_exe="$(printf '%s\n' "$gateway_lsof_output" | sed -n '/^n\// { s/^n//; p; }' | head -1)" + [ -n "$gateway_exe" ] || observation_valid=false + fi + fi + + if $observation_valid; then + : >"$observation_diagnostics_file" + observation_status=0 + process_generation_after="$(ps -p "$pid" -o lstart= 2>"$observation_diagnostics_file")" \ + || observation_status=$? + if [ "$observation_status" -ne 0 ] || [ -s "$observation_diagnostics_file" ] \ + || [ "$process_generation_after" != "$process_generation_before" ]; then + observation_valid=false + fi + fi + + rm -f "$observation_diagnostics_file" + _cleanup_files=("${_cleanup_files[@]/$observation_diagnostics_file/}") + $observation_valid || return 1 + if [ -n "$expected_generation" ] && [ "$process_generation_before" != "$expected_generation" ]; then + return 1 + fi + + user_bin_home="${XDG_BIN_HOME:-${HOME}/.local/bin}" + if [ "${user_bin_home#/}" = "$user_bin_home" ]; then + user_bin_home="${HOME}/.local/bin" + fi + case "$gateway_exe" in + "${user_bin_home%/}/openshell-gateway" | /usr/local/bin/openshell-gateway | /usr/bin/openshell-gateway) + printf '%s\n' "$process_generation_before" + return 0 + ;; + esac + + command_exists brew || return 1 + brew_prefix="$(brew --prefix 2>/dev/null || true)" + [ -n "$brew_prefix" ] || return 1 + trusted_brew_gateway="${brew_prefix%/}/opt/openshell/bin/openshell-gateway" + [ -e "$trusted_brew_gateway" ] || return 1 + trusted_brew_gateway="$(cd -P "$(dirname "$trusted_brew_gateway")" 2>/dev/null && printf '%s/%s\n' "$PWD" "$(basename "$trusted_brew_gateway")")" \ + || return 1 + [ "$gateway_exe" = "$trusted_brew_gateway" ] || return 1 + printf '%s\n' "$process_generation_before" +} + stop_legacy_openshell_gateway_process() { - [ "$(uname -s)" = "Linux" ] || return 1 + local platform + platform="$(uname -s)" + case "$platform" in + Darwin | Linux) ;; + *) return 1 ;; + esac - local gateway_port runtime_dir pid_file pid gateway_exe attempt + local gateway_port runtime_dir pid_file pid gateway_exe listener_pids attempt process_generation + local listener_diagnostics_file listener_status listener_observation_valid gateway_port="$(resolve_nemoclaw_gateway_port)" || return 2 if [ -n "${NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR:-}" ]; then runtime_dir="${NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR}" @@ -3188,13 +3348,49 @@ stop_legacy_openshell_gateway_process() { [[ "$pid" =~ ^[1-9][0-9]*$ ]] \ || error "Refusing to retire the legacy OpenShell gateway from an invalid PID file: ${pid_file}" if ! kill -0 "$pid" 2>/dev/null; then + if [ "$platform" = "Darwin" ]; then + command_exists lsof \ + || error "Could not verify gateway port ${gateway_port} because lsof is unavailable. The PID file, OpenShell registration, and sandbox backups were preserved." + listener_diagnostics_file="$(mktemp "${TMPDIR:-/tmp}/nemoclaw-lsof-XXXXXX" 2>/dev/null)" \ + || error "Could not prepare gateway port verification. The PID file, OpenShell registration, and sandbox backups were preserved." + _cleanup_files+=("$listener_diagnostics_file") + listener_status=0 + listener_pids="$(lsof -nP -iTCP:"${gateway_port}" -sTCP:LISTEN -t 2>"$listener_diagnostics_file")" \ + || listener_status=$? + listener_observation_valid=false + # lsof uses status 1 for both no matches and some operational failures. + if [ ! -s "$listener_diagnostics_file" ]; then + case "$listener_status" in + 0) [ -n "$listener_pids" ] && listener_observation_valid=true ;; + 1) [ -z "$listener_pids" ] && listener_observation_valid=true ;; + esac + fi + rm -f "$listener_diagnostics_file" + _cleanup_files=("${_cleanup_files[@]/$listener_diagnostics_file/}") + $listener_observation_valid \ + || error "Could not verify gateway port ${gateway_port} because lsof did not produce a conclusive listener observation. The PID file, OpenShell registration, and sandbox backups were preserved." + [ -z "$listener_pids" ] \ + || error "Refusing to retire the legacy OpenShell gateway from stale PID file ${pid_file}: gateway port ${gateway_port} still has listener PID(s): $(printf '%s' "$listener_pids" | tr '\n' ' '). Stop the listener or restore the correct owned PID file, then retry; sandbox backups were preserved." + fi rm -f "$pid_file" return 0 fi - gateway_exe="$(readlink "/proc/${pid}/exe" 2>/dev/null || true)" - [ "${gateway_exe##*/}" = "openshell-gateway" ] \ - || error "Refusing to stop PID ${pid}: the recorded process is not openshell-gateway." + if [ "$platform" = "Darwin" ]; then + process_generation="$(trusted_macos_openshell_gateway_process "$pid" "$gateway_port")" \ + || error "Refusing to stop PID ${pid}: the recorded macOS process is not the expected NemoClaw OpenShell gateway." + if ! trusted_macos_openshell_gateway_process "$pid" "$gateway_port" "$process_generation" >/dev/null; then + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$pid_file" + return 0 + fi + error "Refusing to stop PID ${pid}: the recorded macOS process changed or could not be verified immediately before signaling. The PID file, OpenShell registration, and sandbox backups were preserved." + fi + else + gateway_exe="$(readlink "/proc/${pid}/exe" 2>/dev/null || true)" + [ "${gateway_exe##*/}" = "openshell-gateway" ] \ + || error "Refusing to stop PID ${pid}: the recorded process is not openshell-gateway." + fi kill "$pid" 2>/dev/null \ || error "Could not stop the recorded legacy OpenShell gateway process ${pid}." @@ -3203,6 +3399,14 @@ stop_legacy_openshell_gateway_process() { sleep 0.2 done if kill -0 "$pid" 2>/dev/null; then + if [ "$platform" = "Darwin" ] \ + && ! trusted_macos_openshell_gateway_process "$pid" "$gateway_port" "$process_generation" >/dev/null; then + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$pid_file" + return 0 + fi + error "Refusing to forcibly terminate PID ${pid}: the recorded macOS process changed or could not be verified after SIGTERM. The PID file, OpenShell registration, and sandbox backups were preserved." + fi kill -KILL "$pid" 2>/dev/null \ || error "Could not terminate the recorded legacy OpenShell gateway process ${pid}." for attempt in {1..10}; do @@ -3215,6 +3419,45 @@ stop_legacy_openshell_gateway_process() { rm -f "$pid_file" } +stop_macos_openshell_gateway_user_service() { + [ "$(uname -s)" = "Darwin" ] || return 1 + + local gateway_port service_label service_path service_domain service_program + local brew_prefix expected_program active_service active_program + gateway_port="$(resolve_nemoclaw_gateway_port)" || return 1 + [ "$gateway_port" -eq 8080 ] || return 1 + command_exists brew || return 1 + command_exists launchctl || return 1 + command_exists plutil || return 1 + + service_label="homebrew.mxcl.openshell" + service_path="${HOME}/Library/LaunchAgents/${service_label}.plist" + [ -f "$service_path" ] || return 1 + if [ -L "$service_path" ] || ! [ -O "$service_path" ]; then + error "Refusing to retire the OpenShell gateway from an untrusted macOS user service: ${service_path}" + fi + + service_program="$(plutil -extract ProgramArguments.0 raw -o - "$service_path" 2>/dev/null || true)" + [ "$(plutil -extract Label raw -o - "$service_path" 2>/dev/null || true)" = "$service_label" ] \ + || error "Refusing to retire an OpenShell gateway from a macOS user service with an unexpected label: ${service_path}" + brew_prefix="$(brew --prefix 2>/dev/null || true)" + [ -n "$brew_prefix" ] || return 1 + expected_program="${brew_prefix%/}/opt/openshell/libexec/openshell-gateway-homebrew-service" + [ "$service_program" = "$expected_program" ] && [ -x "$service_program" ] \ + || error "Refusing to retire an OpenShell gateway from a macOS user service with an untrusted executable: ${service_program:-}" + + service_domain="gui/$(id -u)/${service_label}" + active_service="$(launchctl print "$service_domain" 2>/dev/null)" || return 1 + active_program="$(printf '%s\n' "$active_service" | sed -n 's/^[[:space:]]*program = //p' | head -1)" + [ "$active_program" = "$expected_program" ] \ + || error "Refusing to retire an OpenShell gateway from an active macOS user service with an untrusted executable: ${active_program:-}" + launchctl bootout "$service_domain" >/dev/null 2>&1 \ + || error "Could not stop the trusted OpenShell Homebrew gateway user service. Run 'launchctl print ${service_domain}' for details." + launchctl print "$service_domain" >/dev/null 2>&1 \ + && error "The trusted OpenShell Homebrew gateway user service remained active after the stop command." + return 0 +} + stop_nemoclaw_openshell_gateway_user_service() { [ "$(uname -s)" = "Linux" ] || return 1 @@ -3326,6 +3569,7 @@ preinstall_backup_and_retire_legacy_gateway() { openshell gateway destroy -g "$gateway_name" >/dev/null 2>&1 \ || openshell gateway destroy >/dev/null 2>&1 \ || { { stop_nemoclaw_openshell_gateway_user_service \ + || stop_macos_openshell_gateway_user_service \ || stop_legacy_openshell_gateway_process; } \ && { openshell gateway remove "$gateway_name" >/dev/null 2>&1 \ || warn "The legacy gateway process stopped, but its OpenShell registration could not be removed; onboarding will replace the stale registration."; }; } \ @@ -3333,6 +3577,7 @@ preinstall_backup_and_retire_legacy_gateway() { else openshell gateway destroy -g "$gateway_name" >/dev/null 2>&1 \ || { { stop_nemoclaw_openshell_gateway_user_service \ + || stop_macos_openshell_gateway_user_service \ || stop_legacy_openshell_gateway_process; } \ && { openshell gateway remove "$gateway_name" >/dev/null 2>&1 \ || warn "Legacy gateway ${gateway_name} stopped, but its OpenShell registration could not be removed; onboarding will replace only that stale registration."; }; } \ diff --git a/src/lib/readiness/gateway-production.test.ts b/src/lib/readiness/gateway-production.test.ts index 1488f44b83e..7d33ed27c12 100644 --- a/src/lib/readiness/gateway-production.test.ts +++ b/src/lib/readiness/gateway-production.test.ts @@ -199,12 +199,6 @@ describe("managed gateway port readiness (#7411)", () => { "linux", ], ["no executable evidence", "openshell-gateway[nemoclaw=nemoclaw;port=8080]", null, "linux"], - [ - "a macOS direct listener", - "openshell-gateway[nemoclaw=nemoclaw;port=8080]", - "/opt/openshell/bin/openshell-gateway", - "darwin", - ], ] as const)( "rejects the owned gateway tag with %s (#8755)", (_case, identity, executable, platform) => { @@ -223,19 +217,29 @@ describe("managed gateway port readiness (#7411)", () => { }, ); - it("rejects trusted-looking argv on macOS without package-service identity", () => { + it("accepts target-bound macOS argv only with matching executable vnode identity (#10369)", () => { const trusted = "/opt/homebrew/opt/openshell/bin/openshell-gateway"; - const spoofedArgv = `${trusted} --name nemoclaw-readiness-test --port 8080`; + const targetBound = "openshell-gateway[nemoclaw=nemoclaw-18080;port=18080]"; expect( gatewayProcessIdentityMatchesTrustedBinary( - spoofedArgv, + targetBound, trusted, - "nemoclaw-readiness-test", - 8080, + "nemoclaw-18080", + 18080, trusted, "darwin", ), + ).toBe(true); + expect( + gatewayProcessIdentityMatchesTrustedBinary( + targetBound, + trusted, + "nemoclaw-18080", + 18080, + "/tmp/foreign-gateway", + "darwin", + ), ).toBe(false); }); diff --git a/src/lib/readiness/gateway-production.ts b/src/lib/readiness/gateway-production.ts index 78f0eff097d..5da494f7f08 100644 --- a/src/lib/readiness/gateway-production.ts +++ b/src/lib/readiness/gateway-production.ts @@ -207,7 +207,7 @@ function resolveTrustedGatewayBinary(openshell: string | null): string | null { return null; } -/** Require the trusted Linux executable plus a trusted path or owned target tag. */ +/** Require the trusted executable plus a trusted path or owned target tag. */ export function gatewayProcessIdentityMatchesTrustedBinary( identity: string, trustedGatewayBin: string | null, @@ -216,10 +216,9 @@ export function gatewayProcessIdentityMatchesTrustedBinary( actualExecutablePath: string | null = null, platform: NodeJS.Platform = process.platform, ): boolean { - // Linux procfs supplies a kernel-backed executable identity for direct - // processes. On macOS, argv0 is user-controlled, so direct listeners remain - // untrusted and only the positively identified Homebrew service is eligible. - if (!trustedGatewayBin || platform !== "linux") return false; + // Linux procfs and the first macOS lsof text vnode supply independent, + // kernel-backed executable identity. argv0 alone never establishes trust. + if (!trustedGatewayBin || (platform !== "linux" && platform !== "darwin")) return false; const argv0 = cleanGatewayProcessToken(identity.trim().split(/\s+/, 1)[0] ?? ""); const expected = normalizeExecutablePath(trustedGatewayBin); if ( @@ -595,9 +594,14 @@ export function createProductionGatewayReadinessDependencies( const trustedTargetBoundPids = new Set(); function observeDirectGatewayBinary(pid: number): string | null { - if (process.platform !== "linux" || !trustedGatewayBin) return null; - const generationBefore = readLinuxProcessStartTime(pid); - const executableBefore = readLinuxProcessExecutable(pid); + if ((process.platform !== "linux" && process.platform !== "darwin") || !trustedGatewayBin) { + return null; + } + const generationBefore = process.platform === "linux" ? readLinuxProcessStartTime(pid) : null; + const executableBefore = + process.platform === "linux" + ? readLinuxProcessExecutable(pid) + : readDarwinProcessExecutable(pid, probeEnv); let targetBoundIdentity = false; const exactTrustedBinary = isDockerDriverGatewayProcessIdentity({ pid, @@ -622,24 +626,33 @@ export function createProductionGatewayReadinessDependencies( } return matches; }, - requireDockerDriverEnv: true, + requireDockerDriverEnv: process.platform === "linux", hasDockerDriverGatewayEnv: (candidatePid) => hasDockerDriverGatewayEnvironment( readDockerDriverGatewayProcessEnvironment(candidatePid), getDockerDriverGatewayEndpoint(gatewayPort), ), }); - const executableAfter = readLinuxProcessExecutable(pid); - const generationAfter = readLinuxProcessStartTime(pid); + const executableAfter = + process.platform === "linux" + ? readLinuxProcessExecutable(pid) + : readDarwinProcessExecutable(pid, probeEnv); + const generationAfter = process.platform === "linux" ? readLinuxProcessStartTime(pid) : null; const stableTrustedBinary = exactTrustedBinary && - gatewayProcessSamplesMatchTrustedBinary( - generationBefore, - generationAfter, - executableBefore, - executableAfter, - trustedGatewayBin, - ); + (process.platform === "linux" + ? gatewayProcessSamplesMatchTrustedBinary( + generationBefore, + generationAfter, + executableBefore, + executableAfter, + trustedGatewayBin, + ) + : gatewayExecutableSamplesMatchTrustedBinary( + executableBefore, + executableAfter, + trustedGatewayBin, + )); if (!stableTrustedBinary) return null; if (targetBoundIdentity) trustedTargetBoundPids.add(pid); return trustedGatewayBin; diff --git a/test/helpers/openshell-installer-template.ts b/test/helpers/openshell-installer-template.ts new file mode 100644 index 00000000000..2d1e3f362c7 --- /dev/null +++ b/test/helpers/openshell-installer-template.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const MACOS_METHOD_START = `MACOS_INSTALL_METHOD="\${_NEMOCLAW_OPENSHELL_INSTALL_METHOD:-auto}"`; +const MACOS_METHOD_END = "esac\n"; + +export function installerReleaseTemplate(source: string, version: string): string { + if (version === "0.0.106") return source; + const start = source.indexOf(MACOS_METHOD_START); + const end = source.indexOf(MACOS_METHOD_END, start); + if (start === -1 || end === -1 || source.indexOf(MACOS_METHOD_START, start + 1) !== -1) { + throw new Error("Expected one macOS install-method binding"); + } + return `${source.slice(0, start)}${source.slice(end + MACOS_METHOD_END.length)}`.replaceAll( + "test/install/", + "test/", + ); +} diff --git a/test/install/install-openshell-macos-upgrade.test.ts b/test/install/install-openshell-macos-upgrade.test.ts new file mode 100644 index 00000000000..71244520ae7 --- /dev/null +++ b/test/install/install-openshell-macos-upgrade.test.ts @@ -0,0 +1,672 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const INSTALLER_PAYLOAD = path.join(import.meta.dirname, "../..", "scripts", "install.sh"); + +function writeExecutable(target: string, contents: string): void { + fs.writeFileSync(target, contents, { mode: 0o755 }); +} + +function runDarwinGatewayProcessStop( + options: { + lsofDiagnostic?: string; + psDiagnostic?: string; + reusePidBeforeKill?: boolean; + trustedExecutable?: boolean; + trustedIdentity?: boolean; + } = {}, +) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-darwin-gateway-stop-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const runtimeDir = path.join(tmp, "runtime"); + const gatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const foreignGatewayBin = path.join(tmp, "foreign-gateway"); + const pidReused = path.join(tmp, "pid-reused"); + const signalLog = path.join(tmp, "signal.log"); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + writeExecutable(gatewayBin, "#!/usr/bin/env bash\nexec sleep 60\n"); + writeExecutable(foreignGatewayBin, "#!/usr/bin/env bash\nexec sleep 60\n"); + writeExecutable(path.join(bin, "uname"), "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n"); + writeExecutable( + path.join(bin, "ps"), + `#!/usr/bin/env bash +managed_pid="$(cat '${runtimeDir}/openshell-gateway.pid')" || exit 1 +[ "\${2:-}" = "$managed_pid" ] || exit 1 +case "\${4:-}" in + args=) + printf '%s\n' '${ + options.trustedIdentity === false + ? "python" + : "openshell-gateway[nemoclaw=nemoclaw-20369;port=20369]" + }' + [ -z '${options.psDiagnostic ?? ""}' ] || { + printf '%s\n' '${options.psDiagnostic ?? ""}' >&2 + exit 2 + } + ;; + lstart=) + if [ -f '${pidReused}' ]; then + printf '%s\n' 'Fri Aug 28 13:00:01 2026' + else + printf '%s\n' 'Fri Aug 28 13:00:00 2026' + fi + ;; + *) exit 2 ;; +esac +`, + ); + writeExecutable( + path.join(bin, "lsof"), + `#!/usr/bin/env bash +managed_pid="$(cat '${runtimeDir}/openshell-gateway.pid')" || exit 1 +[ "\${3:-}" = "$managed_pid" ] || exit 1 +printf 'p%s\nn%s\n' "$managed_pid" '${options.trustedExecutable === false ? foreignGatewayBin : gatewayBin}' +[ -z '${options.lsofDiagnostic ?? ""}' ] || { + printf '%s\n' '${options.lsofDiagnostic ?? ""}' >&2 + exit 2 +} +`, + ); + + const rejectsProcess = + options.trustedIdentity === false || + options.trustedExecutable === false || + options.psDiagnostic !== undefined || + options.lsofDiagnostic !== undefined; + const pidReuseScript = `trap 'command kill "$gateway_pid" 2>/dev/null || true' EXIT +source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +HOME="${home}" +NEMOCLAW_GATEWAY_PORT=20369 +NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="${runtimeDir}" +"${gatewayBin}" 60 & +gateway_pid=$! +sleep 0.1 +printf '%s\n' "$gateway_pid" >"${runtimeDir}/openshell-gateway.pid" +kill() { + case "\${1:-}" in + -0) return 0 ;; + -KILL) printf 'KILL\n' >>'${signalLog}'; return 0 ;; + *) printf 'TERM\n' >>'${signalLog}'; touch '${pidReused}'; return 0 ;; + esac +} +sleep() { :; } +if (stop_legacy_openshell_gateway_process); then exit 9; fi +grep -Fx 'TERM' '${signalLog}' >/dev/null +if grep -Fx 'KILL' '${signalLog}' >/dev/null; then exit 8; fi +test -e "${runtimeDir}/openshell-gateway.pid" +command kill "$gateway_pid" +wait "$gateway_pid" 2>/dev/null || true +trap - EXIT`; + const rejectedProcessScript = `trap 'kill "$gateway_pid" 2>/dev/null || true' EXIT +source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +HOME="${home}" +NEMOCLAW_GATEWAY_PORT=20369 +NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="${runtimeDir}" +"${gatewayBin}" 60 & +gateway_pid=$! +sleep 0.1 +printf '%s\n' "$gateway_pid" >"${runtimeDir}/openshell-gateway.pid" +if (stop_legacy_openshell_gateway_process); then exit 9; fi +kill -0 "$gateway_pid" +test -e "${runtimeDir}/openshell-gateway.pid" +kill "$gateway_pid" +wait "$gateway_pid" 2>/dev/null || true +trap - EXIT`; + const successfulStopScript = `trap 'kill "$gateway_pid" 2>/dev/null || true' EXIT +source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +HOME="${home}" +NEMOCLAW_GATEWAY_PORT=20369 +NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="${runtimeDir}" +"${gatewayBin}" 60 & +gateway_pid=$! +sleep 0.1 +kill -0 "$gateway_pid" +printf '%s\n' "$gateway_pid" >"${runtimeDir}/openshell-gateway.pid" +stop_legacy_openshell_gateway_process +wait "$gateway_pid" 2>/dev/null || true +if kill -0 "$gateway_pid" 2>/dev/null; then exit 9; fi +test ! -e "${runtimeDir}/openshell-gateway.pid"`; + const script = options.reusePidBeforeKill + ? pidReuseScript + : rejectsProcess + ? rejectedProcessScript + : successfulStopScript; + + return spawnSync("bash", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_BIN_HOME: "", + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }); +} + +function runDarwinGatewayPidFile( + contents: string, + options: { lsofDiagnostic?: string; listenerPid?: string; symlink?: boolean } = {}, +) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-darwin-gateway-pid-file-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const runtimeDir = path.join(tmp, "runtime"); + const pidFile = path.join(runtimeDir, "openshell-gateway.pid"); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + writeExecutable(path.join(bin, "uname"), "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n"); + writeExecutable( + path.join(bin, "lsof"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "-nP" ] && [ "\${2:-}" = "-iTCP:8080" ] && [ "\${3:-}" = "-sTCP:LISTEN" ] && [ "\${4:-}" = "-t" ]; then + [ -z '${options.lsofDiagnostic ?? ""}' ] || { + printf '%s\\n' '${options.lsofDiagnostic ?? ""}' >&2 + exit 1 + } + [ -z '${options.listenerPid ?? ""}' ] || { + printf '%s\\n' '${options.listenerPid ?? ""}' + exit 0 + } + exit 1 +fi +exit 2 +`, + ); + const writePidFile = options.symlink + ? () => { + const target = path.join(tmp, "pid-target"); + fs.writeFileSync(target, contents); + fs.symlinkSync(target, pidFile); + } + : () => fs.writeFileSync(pidFile, contents); + writePidFile(); + + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +HOME="${home}" +NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="${runtimeDir}" +stop_legacy_openshell_gateway_process`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }, + ); + return { result, pidFile }; +} + +function runVerifiedHomebrewInstallSelection( + options: { brewPrefixFailure?: boolean; missingGateway?: boolean } = {}, +) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-homebrew-install-selection-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const brewPrefix = path.join(tmp, "homebrew"); + const openshellBin = path.join(brewPrefix, "bin", "openshell"); + const gatewayBin = path.join(brewPrefix, "bin", "openshell-gateway"); + const staleOpenshellBin = path.join(home, ".local", "bin", "openshell"); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.dirname(openshellBin), { recursive: true }); + fs.mkdirSync(path.dirname(staleOpenshellBin), { recursive: true }); + writeExecutable(openshellBin, "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable( + options.missingGateway ? `${gatewayBin}.missing` : gatewayBin, + "#!/usr/bin/env bash\nexit 0\n", + ); + writeExecutable(staleOpenshellBin, "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable(path.join(bin, "uname"), "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n"); + writeExecutable( + path.join(bin, "brew"), + options.brewPrefixFailure + ? `#!/usr/bin/env bash +[ "\${1:-}" = "--prefix" ] && printf '%s\n' '${brewPrefix}' +exit 2 +` + : `#!/usr/bin/env bash +[ "\${1:-}" = "--prefix" ] && printf '%s\n' '${brewPrefix}' +`, + ); + + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +spin() { [ "\${_NEMOCLAW_OPENSHELL_INSTALL_METHOD:-}" = "homebrew" ]; } +install_nemoclaw_openshell_gateway_user_service() { return 0; } +maybe_install_openshell_during_install force +printf 'openshell=%s\ngateway=%s\npath=%s\n' "$NEMOCLAW_OPENSHELL_BIN" "$NEMOCLAW_OPENSHELL_GATEWAY_BIN" "$(command -v openshell)"`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }, + ); + return { gatewayBin, openshellBin, result, staleOpenshellBin }; +} + +function runVerifiedStandaloneInstallSelection( + options: { ambiguousMethod?: boolean; missingGateway?: boolean } = {}, +) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-standalone-install-selection-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const localBin = path.join(home, ".local", "bin"); + const openshellBin = path.join(localBin, "openshell"); + const gatewayBin = path.join(localBin, "openshell-gateway"); + const brewBin = path.join(bin, "brew"); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(localBin, { recursive: true }); + writeExecutable(openshellBin, "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable( + options.missingGateway ? `${gatewayBin}.missing` : gatewayBin, + "#!/usr/bin/env bash\nexit 0\n", + ); + writeExecutable(path.join(bin, "uname"), "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n"); + + const changeInstallMethod = options.ambiguousMethod + ? `printf '#!/usr/bin/env bash\\nexit 0\\n' >'${brewBin}' +chmod +x '${brewBin}'` + : ":"; + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +spin() { + [ "\${_NEMOCLAW_OPENSHELL_INSTALL_METHOD:-}" = "standalone" ] || return 1 + ${changeInstallMethod} +} +install_nemoclaw_openshell_gateway_user_service() { return 0; } +maybe_install_openshell_during_install force +printf 'openshell=%s\ngateway=%s\npath=%s\n' "$NEMOCLAW_OPENSHELL_BIN" "$NEMOCLAW_OPENSHELL_GATEWAY_BIN" "$(command -v openshell)"`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }, + ); + return { gatewayBin, openshellBin, result }; +} + +function runDarwinGatewayServiceStop( + options: { + trustedActiveProgram?: boolean; + trustedLabel?: boolean; + trustedProgram?: boolean; + } = {}, +) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-darwin-gateway-service-stop-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const brewPrefix = path.join(tmp, "homebrew"); + const serviceLabel = "homebrew.mxcl.openshell"; + const servicePath = path.join(home, "Library", "LaunchAgents", `${serviceLabel}.plist`); + const serviceProgram = path.join( + brewPrefix, + "opt", + "openshell", + "libexec", + "openshell-gateway-homebrew-service", + ); + const active = path.join(tmp, "active"); + const launchctlLog = path.join(tmp, "launchctl.log"); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + fs.mkdirSync(path.dirname(serviceProgram), { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(servicePath, "test plist\n"); + fs.writeFileSync(active, "active\n"); + writeExecutable(serviceProgram, "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable(path.join(bin, "uname"), "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n"); + writeExecutable( + path.join(bin, "brew"), + `#!/usr/bin/env bash +[ "\${1:-}" = "--prefix" ] && printf '%s\n' '${brewPrefix}' +`, + ); + writeExecutable( + path.join(bin, "plutil"), + `#!/usr/bin/env bash +case "\${2:-}" in + Label) printf '%s\n' '${options.trustedLabel === false ? "other.service" : serviceLabel}' ;; + ProgramArguments.0) printf '%s\n' '${ + options.trustedProgram === false ? path.join(tmp, "foreign-gateway") : serviceProgram + }' ;; +esac +`, + ); + writeExecutable( + path.join(bin, "launchctl"), + `#!/usr/bin/env bash +printf '%s\n' "$*" >>'${launchctlLog}' +case "\${1:-}" in + print) + [ -f '${active}' ] || exit 1 + printf 'program = %s\\n' '${ + options.trustedActiveProgram === false + ? path.join(tmp, "active-foreign-gateway") + : serviceProgram + }' + ;; + bootout) rm -f '${active}' ;; +esac +`, + ); + + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +HOME="${home}" +NEMOCLAW_GATEWAY_PORT=8080 +stop_macos_openshell_gateway_user_service`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }, + ); + return { + result, + launchctlLog: fs.existsSync(launchctlLog) ? fs.readFileSync(launchctlLog, "utf-8") : "", + }; +} + +function runDarwinRetirementFallback() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-darwin-retirement-fallback-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const stateDir = path.join(tmp, "state"); + const runtimeDir = path.join(tmp, "runtime"); + const gatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const openshellLog = path.join(tmp, "openshell.log"); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "{}\n"); + writeExecutable(gatewayBin, "#!/usr/bin/env bash\nexec sleep 60\n"); + writeExecutable(path.join(bin, "uname"), "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n"); + writeExecutable( + path.join(bin, "openshell"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >>'${openshellLog}' +[ "\${1:-}" = "gateway" ] && [ "\${2:-}" = "remove" ] +`, + ); + writeExecutable( + path.join(bin, "ps"), + `#!/usr/bin/env bash +managed_pid="$(cat '${runtimeDir}/openshell-gateway.pid')" || exit 1 +[ "\${2:-}" = "$managed_pid" ] || exit 1 +printf '%s\\n' 'openshell-gateway[nemoclaw=nemoclaw-20369;port=20369]' +`, + ); + writeExecutable( + path.join(bin, "lsof"), + `#!/usr/bin/env bash +managed_pid="$(cat '${runtimeDir}/openshell-gateway.pid')" || exit 1 +[ "\${3:-}" = "$managed_pid" ] || exit 1 +printf 'p%s\\nn%s\\n' "$managed_pid" '${gatewayBin}' +`, + ); + + const result = spawnSync( + "bash", + [ + "-c", + `trap 'kill "$gateway_pid" 2>/dev/null || true' EXIT +source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +nemoclaw_state_dir() { printf '%s\\n' '${stateDir}'; } +nemoclaw_gateway_name() { printf '%s\\n' 'nemoclaw-20369'; } +registered_sandbox_count() { printf '1\\n'; } +require_openshell_compatible_sandbox_names() { :; } +confirm_legacy_managed_image_recovery() { :; } +run_preupgrade_backup() { :; } +installed_openshell_version() { printf '0.0.85\\n'; } +legacy_openshell_gateway_upgrade_needed() { return 1; } +resolve_current_openshell_version_range() { printf '0.0.106 0.0.106\\n'; } +version_gte() { return 1; } +stop_nemoclaw_openshell_gateway_user_service() { return 1; } +HOME="${home}" +NEMOCLAW_GATEWAY_PORT=20369 +NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="${runtimeDir}" +"${gatewayBin}" 60 & +gateway_pid=$! +sleep 0.1 +printf '%s\\n' "$gateway_pid" >"${runtimeDir}/openshell-gateway.pid" +preinstall_backup_and_retire_legacy_gateway +wait "$gateway_pid" 2>/dev/null || true +if kill -0 "$gateway_pid" 2>/dev/null; then exit 9; fi +test ! -e "${runtimeDir}/openshell-gateway.pid" +test "$_OPENSHELL_INSTALL_REQUIRED_BEFORE_RECOVERY" = true +test "$NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE" = 1 +grep -F 'gateway destroy -g nemoclaw-20369' '${openshellLog}' >/dev/null +grep -F 'gateway remove nemoclaw-20369' '${openshellLog}' >/dev/null`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_BIN_HOME: "", + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }, + ); + return { result, openshellLog }; +} + +describe("install.sh macOS OpenShell upgrade recovery", () => { + it("retires the trusted PID-file gateway through the complete fallback chain (#10369)", () => { + const { result, openshellLog } = runDarwinRetirementFallback(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(fs.readFileSync(openshellLog, "utf-8")).toContain("gateway remove nemoclaw-20369"); + }); + + it("stops only the gateway process with matching owned state and process identity (#10369)", () => { + const result = runDarwinGatewayProcessStop(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + }); + + it("rejects a PID when the process identity does not match (#10369)", () => { + const result = runDarwinGatewayProcessStop({ trustedIdentity: false }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + }); + + it("rejects a PID when lsof reports a foreign executable (#10369)", () => { + const result = runDarwinGatewayProcessStop({ trustedExecutable: false }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + }); + + it("rejects partial process identity when ps reports an observation error (#10369)", () => { + const result = runDarwinGatewayProcessStop({ psDiagnostic: "permission denied" }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stderr).not.toContain("permission denied"); + }); + + it("rejects partial executable identity when lsof reports an observation error (#10369)", () => { + const result = runDarwinGatewayProcessStop({ lsofDiagnostic: "permission denied" }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stderr).not.toContain("permission denied"); + }); + + it("does not send SIGKILL after the recorded macOS PID is reused (#10369)", () => { + const result = runDarwinGatewayProcessStop({ reusePidBeforeKill: true }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stderr).toContain("recorded macOS process changed or could not be verified"); + expect(result.stderr).toContain( + "PID file, OpenShell registration, and sandbox backups were preserved", + ); + }); + + it("clears a stale owned macOS gateway PID file only when the port has no listener (#10369)", () => { + const { result, pidFile } = runDarwinGatewayPidFile("999999999\n"); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it("preserves registration recovery when a stale PID file has an active listener (#10369)", () => { + const { result, pidFile } = runDarwinGatewayPidFile("999999999\n", { + listenerPid: "4242", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("gateway port 8080 still has listener PID(s): 4242"); + expect(result.stderr).toContain("sandbox backups were preserved"); + expect(fs.existsSync(pidFile)).toBe(true); + }); + + it("preserves stale PID recovery when lsof cannot verify the gateway port (#10369)", () => { + const { result, pidFile } = runDarwinGatewayPidFile("999999999\n", { + lsofDiagnostic: "permission denied", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("lsof did not produce a conclusive listener observation"); + expect(result.stderr).toContain( + "PID file, OpenShell registration, and sandbox backups were preserved", + ); + expect(result.stderr).not.toContain("permission denied"); + expect(fs.existsSync(pidFile)).toBe(true); + }); + + it("rejects malformed or symlinked macOS gateway PID files (#10369)", () => { + const malformed = runDarwinGatewayPidFile("not-a-pid\n"); + const symlinked = runDarwinGatewayPidFile("999999999\n", { symlink: true }); + + expect(malformed.result.status).toBe(1); + expect(malformed.result.stderr).toContain("invalid PID file"); + expect(symlinked.result.status).toBe(1); + expect(symlinked.result.stderr).toContain("untrusted PID file"); + }); + + it("stops only the active trusted OpenShell Homebrew user service (#10369)", () => { + const { result, launchctlLog } = runDarwinGatewayServiceStop(); + const serviceDomain = `gui/${process.getuid?.()}/homebrew.mxcl.openshell`; + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(launchctlLog.trim().split(/\r?\n/)).toEqual([ + `print ${serviceDomain}`, + `bootout ${serviceDomain}`, + `print ${serviceDomain}`, + ]); + }); + + it("refuses to stop a Homebrew user service with an unexpected label (#10369)", () => { + const { result, launchctlLog } = runDarwinGatewayServiceStop({ trustedLabel: false }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("macOS user service with an unexpected label"); + expect(launchctlLog).toBe(""); + }); + + it("refuses to stop a Homebrew user service with an unexpected executable (#10369)", () => { + const { result, launchctlLog } = runDarwinGatewayServiceStop({ trustedProgram: false }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("macOS user service with an untrusted executable"); + expect(launchctlLog).toBe(""); + }); + + it("refuses to stop a trusted plist when the active launchd job has a foreign executable (#10369)", () => { + const { result, launchctlLog } = runDarwinGatewayServiceStop({ trustedActiveProgram: false }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("active macOS user service with an untrusted executable"); + expect(launchctlLog.trim().split(/\r?\n/)).toEqual([ + `print gui/${process.getuid?.()}/homebrew.mxcl.openshell`, + ]); + }); + + it("selects Homebrew binaries after a verified formula install (#10386)", () => { + const { gatewayBin, openshellBin, result } = runVerifiedHomebrewInstallSelection(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain(`openshell=${openshellBin}`); + expect(result.stdout).toContain(`gateway=${gatewayBin}`); + expect(result.stdout).toContain(`path=${openshellBin}`); + }); + + it("selects both verified standalone binaries on macOS without Homebrew (#10369)", () => { + const { gatewayBin, openshellBin, result } = runVerifiedStandaloneInstallSelection(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain(`openshell=${openshellBin}`); + expect(result.stdout).toContain(`gateway=${gatewayBin}`); + expect(result.stdout).toContain(`path=${openshellBin}`); + expect(result.stdout).toContain("without reboot persistence"); + }); + + it("stops after a verified standalone install when the gateway binary is missing (#10369)", () => { + const { result } = runVerifiedStandaloneInstallSelection({ missingGateway: true }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("verified standalone OpenShell installation"); + }); + + it("stops when the macOS OpenShell installation method changes during install (#10369)", () => { + const { result } = runVerifiedStandaloneInstallSelection({ ambiguousMethod: true }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("installation method changed while installation was running"); + }); + + it("stops after a verified Homebrew install when brew cannot resolve its prefix (#10386)", () => { + const { result, staleOpenshellBin } = runVerifiedHomebrewInstallSelection({ + brewPrefixFailure: true, + }); + + expect(fs.existsSync(staleOpenshellBin)).toBe(true); + expect(result.status).toBe(1); + expect(result.stderr).toContain("verified Homebrew OpenShell installation"); + }); + + it("stops after a verified Homebrew install when the gateway binary is missing (#10386)", () => { + const { result, staleOpenshellBin } = runVerifiedHomebrewInstallSelection({ + missingGateway: true, + }); + + expect(fs.existsSync(staleOpenshellBin)).toBe(true); + expect(result.status).toBe(1); + expect(result.stderr).toContain("verified Homebrew OpenShell installation"); + }); +}); diff --git a/test/install/install-openshell-upgrade-prompt.test.ts b/test/install/install-openshell-upgrade-prompt.test.ts index 88505b844a8..eea7d1d0899 100644 --- a/test/install/install-openshell-upgrade-prompt.test.ts +++ b/test/install/install-openshell-upgrade-prompt.test.ts @@ -9,14 +9,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const INSTALLER_PAYLOAD = path.join(import.meta.dirname, "../..", "scripts", "install.sh"); -const UPDATE_SANDBOXES_DOCS = path.join( - import.meta.dirname, - "../..", - "docs", - "manage-sandboxes", - "update-sandboxes.mdx", -); -const COMMANDS_DOCS = path.join(import.meta.dirname, "../..", "docs", "reference", "commands.mdx"); function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); @@ -306,7 +298,7 @@ describe("install.sh OpenShell gateway upgrade guard", () => { const runtimeDir = path.join(tmp, "runtime"); const gatewayBin = path.join(tmp, "openshell-gateway"); fs.mkdirSync(runtimeDir, { recursive: true }); - fs.copyFileSync("/bin/sleep", gatewayBin); + fs.copyFileSync(process.execPath, gatewayBin); fs.chmodSync(gatewayBin, 0o755); const result = spawnSync( @@ -315,7 +307,7 @@ describe("install.sh OpenShell gateway upgrade guard", () => { "-c", `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR="${runtimeDir}" -"${gatewayBin}" 60 & +"${gatewayBin}" -e 'setTimeout(() => {}, 60000)' & gateway_pid=$! printf '%s\\n' "$gateway_pid" >"${runtimeDir}/openshell-gateway.pid" stop_legacy_openshell_gateway_process @@ -609,18 +601,6 @@ esac`, expect(openshellLog).toBe(""); }); - it("documents the selected gateway port for a manually prepared upgrade", () => { - const preparedUpgradeCommand = - "curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_GATEWAY_PORT= NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1 bash"; - const retryInstruction = - "If the installation fails, rerun the same install-pipeline command to preserve `NEMOCLAW_GATEWAY_PORT` and `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED`."; - - expect(fs.readFileSync(UPDATE_SANDBOXES_DOCS, "utf-8")).toContain(preparedUpgradeCommand); - expect(fs.readFileSync(COMMANDS_DOCS, "utf-8")).toContain(preparedUpgradeCommand); - expect(fs.readFileSync(UPDATE_SANDBOXES_DOCS, "utf-8")).toContain(retryInstruction); - expect(fs.readFileSync(COMMANDS_DOCS, "utf-8")).toContain(retryInstruction); - }); - it("requires separate managed-image confirmation before preparing a backup (#6114)", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard({ NON_INTERACTIVE: "1", diff --git a/test/install/installer-hash-check.test.ts b/test/install/installer-hash-check.test.ts index 01e0526ff75..4beafc434c2 100644 --- a/test/install/installer-hash-check.test.ts +++ b/test/install/installer-hash-check.test.ts @@ -25,6 +25,7 @@ import { V00106_SANDBOX_BUILD_DIGESTS, V00106_SUPERVISOR_MANIFEST_DIGEST, } from "../helpers/openshell-release-fixtures"; +import { installerReleaseTemplate } from "../helpers/openshell-installer-template"; const REPO_ROOT = path.join(import.meta.dirname, "../.."); const INSTALLER_TEMPLATE = fs.readFileSync( @@ -709,7 +710,7 @@ function removeV00106OperationalTrust(source: string): string { } function renderInstallerTemplate(openshellVersion: string, pinFunction: string): string { - const selected = INSTALLER_TEMPLATE.replaceAll("test/install/", "test/") + const selected = installerReleaseTemplate(INSTALLER_TEMPLATE, openshellVersion) .replace(/^MIN_VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/m, `MIN_VERSION="${openshellVersion}"`) .replace(/^MAX_VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/m, `MAX_VERSION="${openshellVersion}"`) .replace( diff --git a/test/install/installer-homebrew-formula-reuse-trust.test.ts b/test/install/installer-homebrew-formula-reuse-trust.test.ts index 1e3345866c2..6c5aedf4fdf 100644 --- a/test/install/installer-homebrew-formula-reuse-trust.test.ts +++ b/test/install/installer-homebrew-formula-reuse-trust.test.ts @@ -56,6 +56,14 @@ esac`; return source.replace(marker, methodBinding); } +function unbindMacosInstallMethod(source: string): string { + const marker = `HOMEBREW_TAP="nvidia/openshell" +HOMEBREW_FORMULA_NAME="openshell"`; + const boundMarker = bindMacosInstallMethod(marker); + expect(source.split(boundMarker), "bound Homebrew install-method marker").toHaveLength(2); + return source.replace(boundMarker, marker); +} + function runTrustCheck(source: string) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-homebrew-reuse-trust-")); const installer = path.join(tempDir, "install-openshell.sh"); @@ -98,20 +106,20 @@ function expectTrustedTemplate(source: string, digest: string): void { } describe("installer Homebrew formula reuse trust", () => { - const methodBoundTemplate = bindMacosInstallMethod(INSTALLER_SOURCE); + const unboundTemplate = unbindMacosInstallMethod(INSTALLER_SOURCE); const trustedTemplates = [ - ["dev MUSL sandbox", INSTALLER_SOURCE, TRUSTED_V00106_TEMPLATE_DIGESTS[0]], + ["dev MUSL sandbox", unboundTemplate, TRUSTED_V00106_TEMPLATE_DIGESTS[0]], [ "dev MUSL sandbox with flat test paths", - restoreFlatInstallTestPaths(INSTALLER_SOURCE), + restoreFlatInstallTestPaths(unboundTemplate), TRUSTED_V00106_TEMPLATE_DIGESTS[1], ], - ["method-bound dev MUSL sandbox", methodBoundTemplate, TRUSTED_V00106_TEMPLATE_DIGESTS[2]], + ["method-bound dev MUSL sandbox", INSTALLER_SOURCE, TRUSTED_V00106_TEMPLATE_DIGESTS[2]], ] as const; const untrustedTemplates = [ [ "method-bound dev MUSL sandbox with flat test paths", - bindMacosInstallMethod(restoreFlatInstallTestPaths(INSTALLER_SOURCE)), + bindMacosInstallMethod(restoreFlatInstallTestPaths(unboundTemplate)), ], [ "mutated dev MUSL sandbox",