diff --git a/scripts/install.sh b/scripts/install.sh index d570a573406..3b55adff5a0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1681,14 +1681,21 @@ upstream_openshell_gateway_user_service_installed() { } resolve_openshell_gateway_bin_for_user_service() { - local service_name="${1:-}" exec_start gateway_bin + local service_name="${1:-}" exec_start gateway_bin service_output service_status local -a gateway_bins=() case "$service_name" in openshell-gateway.service | "${NEMOCLAW_GATEWAY_SERVICE_NAME}.service") ;; *) return 1 ;; esac - exec_start="$(systemctl --user show "$service_name" --property=ExecStart --value 2>/dev/null)" \ - || return 1 + if service_output="$(LC_ALL=C systemctl --user show "$service_name" \ + --property=ExecStart --value 2>&1)"; then + exec_start="$service_output" + else + service_status=$? + printf 'Could not inspect the systemd user service executable for %s: %s\n' \ + "$service_name" "${service_output:-systemctl exited with status ${service_status}}" >&2 + return 2 + fi while IFS= read -r gateway_bin; do gateway_bins+=("$gateway_bin") done < <( @@ -1722,6 +1729,23 @@ systemd_user_manager_unavailable_diagnostic() { [[ "$recognized" -eq 1 ]] } +# Return 0 only for active, 1 only for confirmed inactive, and 2 when service state is unknown. +systemd_user_service_is_active() { + local service_name="${1:-}" service_output service_status + [ -n "$service_name" ] || return 2 + if service_output="$(LC_ALL=C systemctl --user is-active --quiet "$service_name" 2>&1)"; then + return 0 + else + service_status=$? + fi + if [ "$service_status" -eq 3 ] && [ -z "$service_output" ]; then + return 1 + fi + printf 'Could not inspect the systemd user service state for %s: %s\n' \ + "$service_name" "${service_output:-systemctl exited with status ${service_status}}" >&2 + return 2 +} + trusted_upstream_openshell_gateway_unit_for_service() { case "${1:-}" in /usr/local/lib/systemd/user/openshell-gateway.service | \ @@ -1746,12 +1770,22 @@ trusted_upstream_openshell_gateway_bin_for_service() { esac } +supported_openshell_gateway_user_service_candidate_exists() { + upstream_openshell_gateway_user_service_installed && return 0 + local service_path + service_path="$(openshell_user_config_home)/systemd/user/${NEMOCLAW_GATEWAY_SERVICE_NAME}.service" \ + || return 1 + [ -e "$service_path" ] || [ -L "$service_path" ] +} + inspect_upstream_openshell_gateway_user_service() { + local mode="${1:-inspection}" local service_output service_status line fragment_path="" exec_start="" gateway_bin local fragment_count=0 exec_start_count=0 local -a gateway_bins=() UPSTREAM_OPENSHELL_GATEWAY_SERVICE_BIN="" UPSTREAM_OPENSHELL_GATEWAY_SERVICE_ERROR="" + upstream_openshell_gateway_user_service_installed || return 1 if service_output="$(LC_ALL=C systemctl --user show openshell-gateway.service \ --property=FragmentPath --property=ExecStart 2>&1)"; then @@ -1759,7 +1793,8 @@ inspect_upstream_openshell_gateway_user_service() { else service_status=$? UPSTREAM_OPENSHELL_GATEWAY_SERVICE_ERROR="systemctl --user show openshell-gateway.service failed: ${service_output:-exit ${service_status}}" - if systemd_user_manager_unavailable_diagnostic "$service_output"; then + if systemd_user_manager_unavailable_diagnostic "$service_output" \ + || [ "$mode" = "service-control" ]; then return 2 fi return 1 @@ -3900,40 +3935,63 @@ stop_legacy_openshell_gateway_process() { rm -f "$pid_file" } +inspect_macos_openshell_homebrew_gateway_user_service() { + local service_label="${1:-}" output_variable="${2:-}" + local gateway_port brew_prefix service_path trusted_program service_program + [ "$(uname -s)" = "Darwin" ] || return 1 + [[ "$output_variable" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || return 1 + case "$service_label" in + sh.brew.openshell | homebrew.mxcl.openshell) ;; + *) return 1 ;; + esac + gateway_port="$(resolve_nemoclaw_gateway_port)" || return 1 + [ "$gateway_port" -eq 8080 ] || return 1 + command_exists brew || return 1 + command_exists plutil || return 1 + + brew_prefix="$(brew --prefix 2>/dev/null || true)" + [ -n "$brew_prefix" ] || return 1 + trusted_program="${brew_prefix%/}/opt/openshell/libexec/openshell-gateway-homebrew-service" + service_path="${HOME}/Library/LaunchAgents/${service_label}.plist" + [ -f "$service_path" ] \ + || error "Refusing to control the OpenShell gateway without its expected macOS user service file: ${service_path}" + if [ -L "$service_path" ] || ! [ -O "$service_path" ]; then + error "Refusing to control the OpenShell gateway through 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 control the OpenShell gateway through a macOS user service with an unexpected label: ${service_path}" + if [ "$service_program" != "$trusted_program" ] || ! [ -x "$service_program" ]; then + error "Refusing to control the OpenShell gateway through a macOS user service with an untrusted executable: ${service_program:-}" + fi + printf -v "$output_variable" '%s' "$trusted_program" +} + stop_macos_openshell_gateway_user_service() { [ "$(uname -s)" = "Darwin" ] || return 1 - local gateway_port service_domain="" - local brew_prefix expected_program - local candidate_label candidate_path candidate_program candidate_domain candidate_service + local selection_variable="${1:-}" gateway_port service_domain="" selected_label="" + local expected_program + local candidate_label candidate_domain candidate_service local candidate_active_program candidate_state + if [ -n "$selection_variable" ] \ + && ! [[ "$selection_variable" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + return 1 + fi 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 - brew_prefix="$(brew --prefix 2>/dev/null || true)" - [ -n "$brew_prefix" ] || return 1 - expected_program="${brew_prefix%/}/opt/openshell/libexec/openshell-gateway-homebrew-service" for candidate_label in sh.brew.openshell homebrew.mxcl.openshell; do candidate_domain="gui/$(id -u)/${candidate_label}" candidate_service="$(launchctl print "$candidate_domain" 2>/dev/null)" || continue candidate_state="$(printf '%s\n' "$candidate_service" | sed -n 's/^[[:space:]]*state = //p' | head -1)" [ "$candidate_state" = "running" ] || continue - candidate_path="${HOME}/Library/LaunchAgents/${candidate_label}.plist" - [ -f "$candidate_path" ] \ - || error "Refusing to retire the active OpenShell gateway without its expected macOS user service file: ${candidate_path}" - if [ -L "$candidate_path" ] || ! [ -O "$candidate_path" ]; then - error "Refusing to retire the OpenShell gateway from an untrusted macOS user service: ${candidate_path}" - fi - - candidate_program="$(plutil -extract ProgramArguments.0 raw -o - "$candidate_path" 2>/dev/null || true)" - [ "$(plutil -extract Label raw -o - "$candidate_path" 2>/dev/null || true)" = "$candidate_label" ] \ - || error "Refusing to retire an OpenShell gateway from a macOS user service with an unexpected label: ${candidate_path}" - if [ "$candidate_program" != "$expected_program" ] || ! [ -x "$candidate_program" ]; then - error "Refusing to retire an OpenShell gateway from a macOS user service with an untrusted executable: ${candidate_program:-}" - fi + inspect_macos_openshell_homebrew_gateway_user_service "$candidate_label" expected_program \ + || return 1 candidate_active_program="$(printf '%s\n' "$candidate_service" | sed -n 's/^[[:space:]]*program = //p' | head -1)" [ "$candidate_active_program" = "$expected_program" ] \ @@ -3942,19 +4000,34 @@ stop_macos_openshell_gateway_user_service() { error "Refusing to retire an OpenShell gateway because multiple trusted Homebrew user services are active: ${service_domain} and ${candidate_domain}. Inspect both with 'launchctl print ${service_domain}' and 'launchctl print ${candidate_domain}', stop the obsolete service, then rerun the installer." fi service_domain="$candidate_domain" + selected_label="$candidate_label" done [ -n "$service_domain" ] || return 1 + + inspect_macos_openshell_homebrew_gateway_user_service "$selected_label" expected_program \ + || return 1 + candidate_service="$(launchctl print "$service_domain" 2>/dev/null)" \ + || error "Refusing to stop the trusted OpenShell Homebrew gateway user service because it is no longer active: ${service_domain}" + candidate_state="$(printf '%s\n' "$candidate_service" | sed -n 's/^[[:space:]]*state = //p' | head -1)" + [ "$candidate_state" = "running" ] \ + || error "Refusing to stop the trusted OpenShell Homebrew gateway user service because it is no longer running: ${service_domain}" + candidate_active_program="$(printf '%s\n' "$candidate_service" | sed -n 's/^[[:space:]]*program = //p' | head -1)" + [ "$candidate_active_program" = "$expected_program" ] \ + || error "Refusing to stop the trusted OpenShell Homebrew gateway user service because its active executable changed: ${candidate_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." + if [ -n "$selection_variable" ]; then + printf -v "$selection_variable" '%s' "$selected_label" + fi return 0 } -stop_nemoclaw_openshell_gateway_user_service() { +inspect_nemoclaw_openshell_gateway_user_service() { [ "$(uname -s)" = "Linux" ] || return 1 - local gateway_port service_name service_path fragment_path gateway_bin + local mode="${1:-}" gateway_port service_name service_path fragment_path gateway_bin inspect_status gateway_port="$(resolve_nemoclaw_gateway_port)" || return 1 [ "$gateway_port" -eq 8080 ] || return 1 command_exists systemctl || return 1 @@ -3963,28 +4036,199 @@ stop_nemoclaw_openshell_gateway_user_service() { service_path="$(openshell_user_config_home)/systemd/user/${service_name}" [ -f "$service_path" ] || return 1 if [ -L "$service_path" ] || ! [ -O "$service_path" ]; then - error "Refusing to retire the OpenShell gateway from an untrusted user service: ${service_path}" + error "Refusing to control the OpenShell gateway through an untrusted user service: ${service_path}" fi is_nemoclaw_openshell_gateway_user_service "$service_path" \ - || error "Refusing to retire the OpenShell gateway from a non-NemoClaw user service: ${service_path}" - systemctl --user is-active --quiet "$service_name" 2>/dev/null || return 1 + || error "Refusing to control the OpenShell gateway through a non-NemoClaw user service: ${service_path}" + if [ "$mode" = "active" ]; then + if systemd_user_service_is_active "$service_name"; then + : + else + inspect_status=$? + return "$inspect_status" + fi + fi - fragment_path="$(systemctl --user show "$service_name" --property=FragmentPath --value 2>/dev/null)" \ - || return 1 + if fragment_path="$(LC_ALL=C systemctl --user show "$service_name" \ + --property=FragmentPath --value 2>&1)"; then + : + else + inspect_status=$? + printf 'Could not inspect the systemd user service unit path for %s: %s\n' \ + "$service_name" "${fragment_path:-systemctl exited with status ${inspect_status}}" >&2 + return 2 + fi [ "$fragment_path" = "$service_path" ] \ - || error "Refusing to retire the OpenShell gateway because the active user service does not match ${service_path}." - gateway_bin="$(resolve_openshell_gateway_bin_for_user_service "$service_name")" \ - || return 1 + || error "Refusing to control the OpenShell gateway because the user service does not match ${service_path}." + if gateway_bin="$(resolve_openshell_gateway_bin_for_user_service "$service_name")"; then + : + else + inspect_status=$? + [ "$inspect_status" -ne 2 ] || return 2 + error "Refusing to control the OpenShell gateway because the user service executable metadata is invalid: ${service_name}" + fi trusted_openshell_gateway_bin_for_service "$gateway_bin" \ - || error "Refusing to retire an OpenShell gateway user service with an untrusted binary: ${gateway_bin}" + || error "Refusing to control an OpenShell gateway user service with an untrusted binary: ${gateway_bin}" +} + +stop_nemoclaw_openshell_gateway_user_service() { + local service_name inspect_status + inspect_nemoclaw_openshell_gateway_user_service active || return $? + service_name="${NEMOCLAW_GATEWAY_SERVICE_NAME}.service" systemctl --user stop "$service_name" \ || error "Could not stop the trusted NemoClaw OpenShell gateway user service. Run 'systemctl --user status ${service_name}' for details." - systemctl --user is-active --quiet "$service_name" 2>/dev/null \ - && error "The trusted NemoClaw OpenShell gateway user service remained active after the stop command." + if systemd_user_service_is_active "$service_name"; then + error "The trusted NemoClaw OpenShell gateway user service remained active after the stop command." + else + inspect_status=$? + [ "$inspect_status" -eq 1 ] || return "$inspect_status" + fi return 0 } +stop_active_openshell_gateway_user_service() { + local selection_variable="${1:-}" platform inspect_status macos_service_label + [[ "$selection_variable" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || return 1 + platform="$(uname -s)" || return 1 + + if [ "$platform" = "Darwin" ]; then + if stop_macos_openshell_gateway_user_service macos_service_label; then + printf -v "$selection_variable" '%s' "homebrew:${macos_service_label}" + return 0 + fi + return 1 + fi + [ "$platform" = "Linux" ] || return 1 + command_exists systemctl || return 1 + systemctl --user show-environment >/dev/null || return 2 + if ! supported_openshell_gateway_user_service_candidate_exists; then + printf -v "$selection_variable" '%s' "unavailable" + return 0 + fi + + if inspect_upstream_openshell_gateway_user_service service-control; then + if systemd_user_service_is_active openshell-gateway.service; then + systemctl --user stop openshell-gateway.service \ + || error "Could not stop the trusted upstream OpenShell gateway user service. Run 'systemctl --user status openshell-gateway.service' for details." + if systemd_user_service_is_active openshell-gateway.service; then + error "The trusted upstream OpenShell gateway user service remained active after the stop command." + else + inspect_status=$? + [ "$inspect_status" -eq 1 ] || return "$inspect_status" + fi + printf -v "$selection_variable" '%s' "systemd:openshell-gateway.service" + return 0 + else + inspect_status=$? + [ "$inspect_status" -eq 1 ] || return "$inspect_status" + fi + else + inspect_status=$? + if [ "$inspect_status" -eq 2 ]; then + printf 'Could not inspect the effective upstream OpenShell gateway user service: %s\n' \ + "$UPSTREAM_OPENSHELL_GATEWAY_SERVICE_ERROR" >&2 + return 2 + fi + fi + + if stop_nemoclaw_openshell_gateway_user_service; then + printf -v "$selection_variable" '%s' "systemd:${NEMOCLAW_GATEWAY_SERVICE_NAME}.service" + return 0 + else + inspect_status=$? + [ "$inspect_status" -eq 1 ] || return "$inspect_status" + fi + return 1 +} + +restart_selected_openshell_gateway_user_service() { + local selection="${1:-}" inspect_status service_name macos_service_label expected_program + local macos_service_domain_prefix macos_service_domain macos_service_path macos_service_output + local macos_service_state macos_service_program + case "$selection" in + systemd:openshell-gateway.service) + if inspect_upstream_openshell_gateway_user_service service-control; then + service_name="openshell-gateway.service" + else + inspect_status=$? + if [ "$inspect_status" -eq 2 ]; then + printf 'Could not inspect the effective upstream OpenShell gateway user service: %s\n' \ + "$UPSTREAM_OPENSHELL_GATEWAY_SERVICE_ERROR" >&2 + return 2 + fi + return 1 + fi + ;; + "systemd:${NEMOCLAW_GATEWAY_SERVICE_NAME}.service") + service_name="${NEMOCLAW_GATEWAY_SERVICE_NAME}.service" + if inspect_nemoclaw_openshell_gateway_user_service; then + : + else + inspect_status=$? + return "$inspect_status" + fi + ;; + homebrew:sh.brew.openshell | homebrew:homebrew.mxcl.openshell) + macos_service_label="${selection#homebrew:}" + macos_openshell_homebrew_gateway_service_installed || return 1 + command_exists launchctl || return 1 + inspect_macos_openshell_homebrew_gateway_user_service "$macos_service_label" expected_program \ + || return 1 + macos_service_domain_prefix="gui/$(id -u)" + macos_service_domain="${macos_service_domain_prefix}/${macos_service_label}" + macos_service_path="${HOME}/Library/LaunchAgents/${macos_service_label}.plist" + launchctl bootstrap "$macos_service_domain_prefix" "$macos_service_path" >/dev/null 2>&1 \ + || error "Could not restart the trusted OpenShell Homebrew gateway user service. Run 'launchctl bootstrap ${macos_service_domain_prefix} ${macos_service_path}' for details." + if macos_service_output="$(launchctl print "$macos_service_domain" 2>/dev/null)"; then + : + else + launchctl bootout "$macos_service_domain" >/dev/null 2>&1 || true + error "The trusted OpenShell Homebrew gateway user service could not be verified after restart: ${macos_service_domain}" + fi + macos_service_state="$(printf '%s\n' "$macos_service_output" | sed -n 's/^[[:space:]]*state = //p' | head -1)" + macos_service_program="$(printf '%s\n' "$macos_service_output" | sed -n 's/^[[:space:]]*program = //p' | head -1)" + if [ "$macos_service_state" != "running" ] \ + || [ "$macos_service_program" != "$expected_program" ]; then + launchctl bootout "$macos_service_domain" >/dev/null 2>&1 || true + error "The trusted OpenShell Homebrew gateway user service did not restart with its expected identity: ${macos_service_domain}" + fi + return 0 + ;; + *) return 1 ;; + esac + + systemctl --user daemon-reload + systemctl --user restart "$service_name" + if systemd_user_service_is_active "$service_name"; then + return 0 + else + inspect_status=$? + fi + if [ "$inspect_status" -eq 1 ]; then + printf 'The trusted OpenShell gateway user service did not become active after restart: %s\n' \ + "$service_name" >&2 + fi + return "$inspect_status" +} + +stop_openshell_gateway_for_upgrade_retirement() { + local stop_status + if stop_nemoclaw_openshell_gateway_user_service; then + return 0 + else + stop_status=$? + [ "$stop_status" -eq 1 ] || return "$stop_status" + fi + if stop_macos_openshell_gateway_user_service; then + return 0 + else + stop_status=$? + [ "$stop_status" -eq 1 ] || return "$stop_status" + fi + stop_legacy_openshell_gateway_process +} + preinstall_backup_and_retire_legacy_gateway() { local reg_file gateway_name reg_file="$(nemoclaw_state_dir)/sandboxes.json" @@ -4061,17 +4305,13 @@ preinstall_backup_and_retire_legacy_gateway() { if [ "$gateway_name" = "nemoclaw" ]; then 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; } \ + || { { stop_openshell_gateway_for_upgrade_retirement; } \ && { 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."; }; } \ || error "Could not retire the legacy OpenShell gateway after backup. Installed OpenShell lifecycle commands failed, and no trusted active user service or PID-file gateway could be stopped. The installer stopped with the sandbox backups preserved." 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; } \ + || { { stop_openshell_gateway_for_upgrade_retirement; } \ && { 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."; }; } \ || error "Could not retire legacy gateway ${gateway_name} after backup. Installed OpenShell lifecycle commands failed, and no trusted active user service or PID-file gateway could be stopped. The installer stopped with the sandbox backups preserved." diff --git a/test/e2e/fixtures/phases/lifecycle.ts b/test/e2e/fixtures/phases/lifecycle.ts index 1dd9121af53..57a24ad1302 100644 --- a/test/e2e/fixtures/phases/lifecycle.ts +++ b/test/e2e/fixtures/phases/lifecycle.ts @@ -50,8 +50,14 @@ const NEMOCLAW_OPENSHELL_INSTALLER = fileURLToPath( new URL("../../../../scripts/install-openshell.sh", import.meta.url), ); const USER_SERVICE_STAGE_RESULT_PREFIX = "NEMOCLAW_E2E_GATEWAY_USER_SERVICE="; +const USER_SERVICE_STOP_RESULT_PREFIX = "NEMOCLAW_E2E_STOPPED_GATEWAY_USER_SERVICE="; type UserServiceStageResult = "upstream" | "existing" | "staged"; +type UserServiceSelection = + | "homebrew:homebrew.mxcl.openshell" + | "homebrew:sh.brew.openshell" + | "systemd:nemoclaw-openshell-gateway.service" + | "systemd:openshell-gateway.service"; export function buildOpenShellGatewayUserServiceStageScript(): string { return [ @@ -123,29 +129,42 @@ export function buildOpenShellGatewayUserServiceRemovalScript(): string { ].join("\n"); } -export function buildOpenShellGatewayUserServiceRestartScript(): string { +export function buildOpenShellGatewayUserServiceStopScript(): string { return [ "set -eu", - 'if [ "$(uname -s)" = Darwin ] && command -v brew >/dev/null 2>&1 && brew list --formula openshell >/dev/null 2>&1; then', - ' brew info --json=v2 openshell | grep -Eq \'"tap"[[:space:]]*:[[:space:]]*"nvidia/openshell"\' || exit 1', - " brew services restart openshell", - " exit 0", + "installer=$1", + 'if [ ! -f "$installer" ] || [ -L "$installer" ]; then', + ' printf "NemoClaw installer is unavailable: %s\\n" "$installer" >&2', + " exit 1", "fi", - `if ! command -v systemctl >/dev/null 2>&1; then exit ${USER_SERVICE_UNAVAILABLE_EXIT}; fi`, - "service=openshell-gateway", - 'if ! systemctl --user cat "$service" >/dev/null 2>&1; then', - ' case "${XDG_CONFIG_HOME:-}" in', - ' /*) config_home="$XDG_CONFIG_HOME" ;;', - ' *) config_home="$HOME/.config" ;;', + 'source "$installer"', + "selection=", + "if stop_active_openshell_gateway_user_service selection; then", + ' case "$selection" in', + " homebrew:homebrew.mxcl.openshell|homebrew:sh.brew.openshell|systemd:nemoclaw-openshell-gateway.service|systemd:openshell-gateway.service|unavailable) ;;", + " *) exit 1 ;;", " esac", - ' unit="$config_home/systemd/user/nemoclaw-openshell-gateway.service"', - ` if [ ! -f "$unit" ]; then exit ${USER_SERVICE_UNAVAILABLE_EXIT}; fi`, - ` grep -Fxq '${NEMOCLAW_OPENSHELL_GATEWAY_USER_SERVICE_MARKER_LINE}' "$unit" || exit ${USER_SERVICE_UNAVAILABLE_EXIT}`, - " service=nemoclaw-openshell-gateway", + ` printf '%s%s\\n' '${USER_SERVICE_STOP_RESULT_PREFIX}' "$selection"`, + " exit 0", + "else", + " status=$?", + ` if [ "$status" -eq 1 ]; then exit ${USER_SERVICE_UNAVAILABLE_EXIT}; fi`, + ' exit "$status"', "fi", - 'systemctl --user is-enabled "$service" >/dev/null', - "systemctl --user daemon-reload", - 'systemctl --user restart "$service"', + ].join("\n"); +} + +export function buildOpenShellGatewayUserServiceRestartScript(): string { + return [ + "set -eu", + "installer=$1", + "selection=$2", + 'if [ ! -f "$installer" ] || [ -L "$installer" ]; then', + ' printf "NemoClaw installer is unavailable: %s\\n" "$installer" >&2', + " exit 1", + "fi", + 'source "$installer"', + 'restart_selected_openshell_gateway_user_service "$selection"', ].join("\n"); } @@ -230,6 +249,7 @@ function instanceName(instance: NemoClawInstance | string): string { export class LifecyclePhaseFixture { private postRebootUserServiceStage: UserServiceStageResult | undefined; private readonly runtimeProvider: RuntimeProviderPrerequisite; + private stoppedOpenShellGatewayUserService: UserServiceSelection | null = null; constructor( private readonly host: HostCliClient, @@ -417,6 +437,7 @@ export class LifecyclePhaseFixture { * created one); * - `docker start` the labeled container so the sandbox returns * to a usable state for any teardown that expects it live; + * - restart a gateway user service when normal recovery did not; * - remove a user service staged only for this source-checkout * fixture after the sandbox cleanup has used it. */ @@ -596,6 +617,8 @@ export class LifecyclePhaseFixture { timeoutMs: 30_000, }, ); + if (await this.stopOpenShellGatewayUserService()) return runtime; + await this.host.command( "sh", ["-lc", "command -v openshell >/dev/null 2>&1 && openshell gateway stop -g nemoclaw || true"], @@ -664,6 +687,49 @@ export class LifecyclePhaseFixture { return runtime; } + private async stopOpenShellGatewayUserService(): Promise { + const pendingSelection = this.stoppedOpenShellGatewayUserService; + const result = await this.host.command( + "bash", + [ + "-c", + buildOpenShellGatewayUserServiceStopScript(), + "stop-openshell-gateway-user-service", + NEMOCLAW_INSTALLER, + ], + { + artifactName: "lifecycle-gateway-user-service-stop", + env: buildAvailabilityProbeEnv(), + timeoutMs: 120_000, + }, + ); + if (result.exitCode === 0) { + const match = result.stdout.match( + new RegExp( + `(?:^|\\n)${USER_SERVICE_STOP_RESULT_PREFIX}` + + `(homebrew:(?:homebrew\\.mxcl|sh\\.brew)\\.openshell|systemd:nemoclaw-openshell-gateway\\.service|systemd:openshell-gateway\\.service|unavailable)(?:\\n|$)`, + "u", + ), + ); + if (!match) { + throw new Error("OpenShell gateway user service stop did not report its selection."); + } + if (match[1] === "unavailable") return pendingSelection !== null; + const selection = match[1] as UserServiceSelection; + this.stoppedOpenShellGatewayUserService = selection; + this.cleanup.add(`lifecycle.gateway-user-service-restart:${selection}`, async () => { + if (this.stoppedOpenShellGatewayUserService !== selection) return; + await this.startOpenShellGatewayUserService({ requireAvailable: true }); + }); + return true; + } + if (result.exitCode === USER_SERVICE_UNAVAILABLE_EXIT) return pendingSelection !== null; + throw new Error( + `OpenShell gateway user service stop failed during lifecycle qualification: ` + + `${result.stderr || result.stdout || `exit ${String(result.exitCode)}`}`, + ); + } + async startGatewayRuntime( previousRuntime: HostGatewayRuntime | null, options: { requireUserService?: boolean; sandboxName?: string } = {}, @@ -703,16 +769,32 @@ export class LifecyclePhaseFixture { private async startOpenShellGatewayUserService(options: { requireAvailable?: boolean; }): Promise { + if (!this.stoppedOpenShellGatewayUserService) { + if (!options.requireAvailable) return null; + throw new Error( + `OpenShell gateway user service is not available for reboot lifecycle recovery.`, + ); + } + const selection = this.stoppedOpenShellGatewayUserService; const result = await this.host.command( - "sh", - ["-lc", buildOpenShellGatewayUserServiceRestartScript()], + "bash", + [ + "-c", + buildOpenShellGatewayUserServiceRestartScript(), + "restart-openshell-gateway-user-service", + NEMOCLAW_INSTALLER, + selection, + ], { artifactName: "lifecycle-gateway-user-service-restart", env: buildAvailabilityProbeEnv(), timeoutMs: 120_000, }, ); - if (result.exitCode === 0) return result; + if (result.exitCode === 0) { + this.stoppedOpenShellGatewayUserService = null; + return result; + } if (result.exitCode === USER_SERVICE_UNAVAILABLE_EXIT && !options.requireAvailable) { return null; } diff --git a/test/e2e/support/e2e-phase-lifecycle.test.ts b/test/e2e/support/e2e-phase-lifecycle.test.ts index d4da4324c14..03d909c60df 100644 --- a/test/e2e/support/e2e-phase-lifecycle.test.ts +++ b/test/e2e/support/e2e-phase-lifecycle.test.ts @@ -40,6 +40,9 @@ interface CleanupCall { run: () => Promise | void; } +const stoppedGatewayUserService = + "NEMOCLAW_E2E_STOPPED_GATEWAY_USER_SERVICE=systemd:nemoclaw-openshell-gateway.service\n"; + function shellResult(exitCode: number, output = ""): ShellProbeResult { return { command: [], @@ -244,17 +247,14 @@ describe("LifecyclePhaseFixture.preparePostReboot", () => { }); describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", () => { - it("stops the labeled container, restarts the gateway service, then runs status", async () => { + it("uses non-login helpers to stop and restart the gateway service before status (#10947)", async () => { const runner = new FakeRunner(); const cleanup = new FakeCleanup(); const prepared = await preparedPostRebootFixture(runner, cleanup, "staged"); runner.enqueue(shellResult(0, "openshell-cluster-e2e-cloud-oc\n")); // discover runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop - runner.enqueue(shellResult(0)); // gateway stop - runner.enqueue(shellResult(0)); // pid stop - runner.enqueue(shellResult(0, "gateway-id\topenshell-cluster-nemoclaw\n")); // discover - runner.enqueue(shellResult(0)); // container stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(0)); // boot-owned docker start @@ -278,11 +278,8 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", "docker container ps --all --filter label=openshell.ai/sandbox-name=e2e-cloud-oc --format {{.Names}}", "docker container stop openshell-cluster-e2e-cloud-oc", "sh -lc command -v openshell >/dev/null 2>&1 && openshell forward stop 18789 || true", - "sh -lc command -v openshell >/dev/null 2>&1 && openshell gateway stop -g nemoclaw || true", - expect.stringContaining("sh -lc pid_file="), - "docker container ps --format {{.ID}}\t{{.Names}}", - "docker container stop gateway-id", - expect.stringContaining('systemctl --user cat "$service"'), + expect.stringContaining("stop_active_openshell_gateway_user_service"), + expect.stringContaining("restart_selected_openshell_gateway_user_service"), "openshell status", "docker container start openshell-cluster-e2e-cloud-oc", "openshell sandbox list", @@ -291,7 +288,45 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", expect(cleanup.calls.map((call) => call.name)).toEqual([ "lifecycle.remove-staged-gateway-user-service", "lifecycle.runtime-start:openshell-cluster-e2e-cloud-oc", + "lifecycle.gateway-user-service-restart:systemd:nemoclaw-openshell-gateway.service", ]); + const userServiceCommands = runner.calls.filter((call) => + ["lifecycle-gateway-user-service-stop", "lifecycle-gateway-user-service-restart"].includes( + call.options?.artifactName ?? "", + ), + ); + expect(userServiceCommands.map((call) => call.args[0])).toEqual(["-c", "-c"]); + }); + + it("restarts the selected gateway service during cleanup after recovery fails (#10947)", async () => { + const runner = new FakeRunner(); + const cleanup = new FakeCleanup(); + const prepared = await preparedPostRebootFixture(runner, cleanup); + runner.enqueue(shellResult(0, "container-1\n")); // discover + runner.enqueue(shellResult(0)); // docker stop + runner.enqueue(shellResult(0)); // forward stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop + runner.enqueue(shellResult(1, "restart failed")); // normal user service restart + + await expect(prepared.simulate("post-reboot-recovery", instance())).rejects.toThrow( + /user service restart failed.*restart failed/, + ); + + const serviceCleanup = cleanup.calls.find((call) => + call.name.startsWith("lifecycle.gateway-user-service-restart:"), + ); + expect(serviceCleanup?.name).toBe( + "lifecycle.gateway-user-service-restart:systemd:nemoclaw-openshell-gateway.service", + ); + runner.enqueue(shellResult(0)); // cleanup user service restart + await serviceCleanup!.run(); + await serviceCleanup!.run(); + + expect( + runner.calls.filter( + (call) => call.options?.artifactName === "lifecycle-gateway-user-service-restart", + ), + ).toHaveLength(2); }); it("fails when status cannot prove post-reboot recovery", async () => { @@ -301,9 +336,7 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", runner.enqueue(shellResult(0, "container-1\n")); // discover runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop - runner.enqueue(shellResult(0)); // gateway stop - runner.enqueue(shellResult(0)); // pid stop - runner.enqueue(shellResult(0)); // container stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(0)); // boot-owned docker start @@ -322,9 +355,7 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", runner.enqueue(shellResult(0, "container-1\n")); // discover runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop - runner.enqueue(shellResult(0)); // gateway stop - runner.enqueue(shellResult(0)); // pid stop - runner.enqueue(shellResult(0)); // container stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(0)); // boot-owned docker start @@ -384,9 +415,7 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", runner.enqueue(shellResult(0, "container-1\n")); // discover runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // forward stop - runner.enqueue(shellResult(0)); // gateway stop - runner.enqueue(shellResult(0)); // pid stop - runner.enqueue(shellResult(0)); // container stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop runner.enqueue(shellResult(75, "")); // no managed user service available await expect(prepared.simulate("post-reboot-recovery", instance())).rejects.toThrow( @@ -394,7 +423,9 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (stop-original)", ); expect(runner.calls.map((call) => `${call.command} ${call.args.join(" ")}`)).toEqual( - expect.arrayContaining([expect.stringContaining('systemctl --user cat "$service"')]), + expect.arrayContaining([ + expect.stringContaining("restart_selected_openshell_gateway_user_service"), + ]), ); }); }); @@ -408,9 +439,7 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (rename-to-gpu-bac runner.enqueue(shellResult(0)); // docker stop runner.enqueue(shellResult(0)); // docker rename runner.enqueue(shellResult(0)); // forward stop - runner.enqueue(shellResult(0)); // gateway stop - runner.enqueue(shellResult(0)); // pid stop - runner.enqueue(shellResult(0)); // container stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop runner.enqueue(shellResult(0)); // user service restart runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // openshell status runner.enqueue(shellResult(0)); // boot-owned docker start @@ -439,10 +468,11 @@ describe("LifecyclePhaseFixture.simulate post-reboot-recovery (rename-to-gpu-bac }), ); - // Cleanup queue now has both docker-start and docker-rename-back. + // Cleanup queue also restores the user service if normal recovery does not. expect(cleanup.calls.map((call) => call.name.split(":")[0])).toEqual([ "lifecycle.runtime-start", "lifecycle.runtime-rename-back", + "lifecycle.gateway-user-service-restart", ]); }); }); @@ -497,17 +527,17 @@ describe("LifecyclePhaseFixture rebuild helpers", () => { }); describe("LifecyclePhaseFixture gateway runtime restart helpers", () => { - it("stops PID/container runtimes, starts the previous runtime shape, and polls health", async () => { + it("falls back to PID/container controls when the selected user service is inactive (#10947)", async () => { const runner = new FakeRunner(); runner.enqueue(shellResult(0, "12345\n")); // resolveHostRuntime pid probe runner.enqueue(shellResult(0)); // forward stop + runner.enqueue(shellResult(75)); // selected user service is inactive runner.enqueue(shellResult(0)); // gateway stop runner.enqueue(shellResult(0)); // pid stop runner.enqueue(shellResult(0)); // container stop runner.enqueue(shellResult(1, "")); // expectHostRuntimeStopped pid probe runner.enqueue(shellResult(0, "")); // expectHostRuntimeStopped container probe runner.enqueue(shellResult(0)); // lifecycle-gateway-stopped true artifact - runner.enqueue(shellResult(75, "")); // no user service available runner.enqueue(shellResult(0, "status recovered\n")); // start through nemoclaw status runner.enqueue(shellResult(0, "Connected to nemoclaw\n")); // waitForGatewayConnected const cleanup = new FakeCleanup(); @@ -524,13 +554,13 @@ describe("LifecyclePhaseFixture gateway runtime restart helpers", () => { expect(runner.calls.map((call) => `${call.command} ${call.args.join(" ")}`)).toEqual([ expect.stringContaining("sh -lc pid_file="), "sh -lc command -v openshell >/dev/null 2>&1 && openshell forward stop 18789 || true", + expect.stringContaining("bash -c set -eu"), "sh -lc command -v openshell >/dev/null 2>&1 && openshell gateway stop -g nemoclaw || true", expect.stringContaining("sh -lc pid_file="), "docker container ps --format {{.ID}}\t{{.Names}}", expect.stringContaining("sh -lc pid_file="), "docker container ps --format {{.ID}}\t{{.Names}}", "true ", - expect.stringContaining("sh -lc set -eu"), "nemoclaw status", "openshell status", ]); @@ -562,6 +592,7 @@ describe("LifecyclePhaseFixture gateway runtime restart helpers", () => { it("stops only the exact gateway container when a sandbox has the gateway-name prefix", async () => { const runner = new FakeRunner(); runner.enqueue(shellResult(0)); // forward stop + runner.enqueue(shellResult(0, "NEMOCLAW_E2E_STOPPED_GATEWAY_USER_SERVICE=unavailable\n")); runner.enqueue(shellResult(0)); // gateway stop runner.enqueue(shellResult(0)); // pid stop runner.enqueue(shellResult(0, "gateway-id\topenshell-cluster-nemoclaw\n")); // discover @@ -580,6 +611,74 @@ describe("LifecyclePhaseFixture gateway runtime restart helpers", () => { expect(discovery?.args).toEqual(["container", "ps", "--format", "{{.ID}}\t{{.Names}}"]); }); + it("stops a supported user service without invoking legacy runtime controls (#10947)", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0)); // forward stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // user service stop + + await fixture(runner, new FakeCleanup()).stopGatewayRuntime(); + + expect(runner.calls.map((call) => call.options?.artifactName)).toEqual([ + "lifecycle-gateway-forward-stop", + "lifecycle-gateway-user-service-stop", + ]); + }); + + it.each(["homebrew:homebrew.mxcl.openshell", "homebrew:sh.brew.openshell"])( + "passes the exact Homebrew user-service selection to restart: %s (#10947)", + async (selection) => { + const runner = new FakeRunner(); + const cleanup = new FakeCleanup(); + runner.enqueue(shellResult(0)); // forward stop + runner.enqueue(shellResult(0, `NEMOCLAW_E2E_STOPPED_GATEWAY_USER_SERVICE=${selection}\n`)); // user service stop + const fx = fixture(runner, cleanup); + + await fx.stopGatewayRuntime(); + + expect(cleanup.calls.map((call) => call.name)).toEqual([ + `lifecycle.gateway-user-service-restart:${selection}`, + ]); + runner.enqueue(shellResult(0)); // selected user service restart + await cleanup.calls[0]!.run(); + const restart = runner.calls.find( + (call) => call.options?.artifactName === "lifecycle-gateway-user-service-restart", + ); + expect(restart?.args.at(-1)).toBe(selection); + }, + ); + + it("preserves a pending user-service restart when a repeated stop finds it inactive (#10947)", async () => { + const runner = new FakeRunner(); + const cleanup = new FakeCleanup(); + const fx = fixture(runner, cleanup); + runner.enqueue(shellResult(0)); // first forward stop + runner.enqueue(shellResult(0, stoppedGatewayUserService)); // first user service stop + runner.enqueue(shellResult(0)); // repeated forward stop + runner.enqueue(shellResult(75)); // stopped service is inactive + + await fx.stopGatewayRuntime(); + await fx.stopGatewayRuntime(); + + expect(cleanup.calls.map((call) => call.name)).toEqual([ + "lifecycle.gateway-user-service-restart:systemd:nemoclaw-openshell-gateway.service", + ]); + expect(runner.calls).toHaveLength(4); + runner.enqueue(shellResult(0)); // pending user service restart + await cleanup.calls[0]!.run(); + expect(runner.calls.at(-1)?.args.at(-1)).toBe("systemd:nemoclaw-openshell-gateway.service"); + }); + + it("reports a user-service stop failure without invoking legacy controls (#10947)", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0)); // forward stop + runner.enqueue(shellResult(1, "Failed to connect to bus")); + + await expect(fixture(runner, new FakeCleanup()).stopGatewayRuntime()).rejects.toThrow( + /user service stop failed.*Failed to connect to bus/, + ); + expect(runner.calls).toHaveLength(2); + }); + it("can recover a PID runtime through sandbox-specific status", async () => { const runner = new FakeRunner(); runner.enqueue(shellResult(0, "status recovered\n")); diff --git a/test/e2e/support/lifecycle-user-service.test.ts b/test/e2e/support/lifecycle-user-service.test.ts index 17b4d2a6581..c3a63fcac6e 100644 --- a/test/e2e/support/lifecycle-user-service.test.ts +++ b/test/e2e/support/lifecycle-user-service.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -14,9 +14,169 @@ import { buildOpenShellGatewayUserServiceRemovalScript, buildOpenShellGatewayUserServiceRestartScript, buildOpenShellGatewayUserServiceStageScript, + buildOpenShellGatewayUserServiceStopScript, } from "../fixtures/phases/lifecycle.ts"; const installer = fileURLToPath(new URL("../../../scripts/install.sh", import.meta.url)); +const upstreamServiceShow = + "--user show openshell-gateway.service --property=FragmentPath --property=ExecStart"; +const stoppedServicePrefix = "NEMOCLAW_E2E_STOPPED_GATEWAY_USER_SERVICE="; + +function runStopScript(installerPath: string, env: NodeJS.ProcessEnv) { + return spawnSync( + "bash", + ["-c", buildOpenShellGatewayUserServiceStopScript(), "stop-service", installerPath], + { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, + ); +} + +function runRestartScript(installerPath: string, selection: string, env: NodeJS.ProcessEnv) { + return spawnSync( + "bash", + [ + "-c", + buildOpenShellGatewayUserServiceRestartScript(), + "restart-service", + installerPath, + selection, + ], + { encoding: "utf8", env, killSignal: "SIGKILL", timeout: 30_000 }, + ); +} + +function writeTrustedInstaller( + root: string, + trustedUnit: string, + trustedGatewayBin: string, +): string { + const trustedInstaller = path.join(root, "trusted-installer.sh"); + fs.writeFileSync( + trustedInstaller, + [ + `source ${JSON.stringify(installer)}`, + `trusted_upstream_openshell_gateway_unit_for_service() { [ "$1" = ${JSON.stringify(trustedUnit)} ]; }`, + `trusted_upstream_openshell_gateway_bin_for_service() { [ "$1" = ${JSON.stringify(trustedGatewayBin)} ]; }`, + "upstream_openshell_gateway_user_service_installed() { return 0; }", + "supported_openshell_gateway_user_service_candidate_exists() { return 0; }", + ].join("\n"), + ); + return trustedInstaller; +} + +function writeCandidateInstaller(root: string, exists: boolean): string { + const candidateInstaller = path.join(root, "candidate-installer.sh"); + fs.writeFileSync( + candidateInstaller, + [ + `source ${JSON.stringify(installer)}`, + `upstream_openshell_gateway_user_service_installed() { return ${exists ? "0" : "1"}; }`, + `supported_openshell_gateway_user_service_candidate_exists() { return ${exists ? "0" : "1"}; }`, + ].join("\n"), + ); + return candidateInstaller; +} + +function writeNoUpstreamInstaller(root: string): string { + const candidateInstaller = path.join(root, "no-upstream-installer.sh"); + fs.writeFileSync( + candidateInstaller, + [ + `source ${JSON.stringify(installer)}`, + "upstream_openshell_gateway_user_service_installed() { return 1; }", + ].join("\n"), + ); + return candidateInstaller; +} + +function writeMacServiceStubs( + root: string, + trustedProgram: boolean, + serviceLabel = "sh.brew.openshell", + trustedRestartedProgram = true, +) { + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const brewPrefix = path.join(root, "homebrew"); + const serviceDomain = `gui/501/${serviceLabel}`; + const servicePath = path.join(home, "Library", "LaunchAgents", `${serviceLabel}.plist`); + const serviceProgram = path.join( + brewPrefix, + "opt", + "openshell", + "libexec", + "openshell-gateway-homebrew-service", + ); + const selectedProgram = trustedProgram ? serviceProgram : path.join(root, "foreign-gateway"); + const active = path.join(root, "homebrew-active"); + const restarted = path.join(root, "homebrew-restarted"); + const launchctlLog = path.join(root, "launchctl.log"); + const brewLog = path.join(root, "brew.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(serviceProgram, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(active, "active\n"); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Darwin\\n'\n", { mode: 0o755 }); + fs.writeFileSync(path.join(bin, "id"), "#!/bin/sh\nprintf '501\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "brew"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(brewLog)}`, + `if [ "$*" = "--prefix" ]; then printf '%s\\n' ${JSON.stringify(brewPrefix)}; exit 0; fi`, + `if [ "$*" = "list --formula openshell" ]; then exit 0; fi`, + `if [ "$*" = "info --json=v2 openshell" ]; then printf '%s\\n' '{"formulae":[{"tap":"nvidia/openshell"}]}'; exit 0; fi`, + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(bin, "plutil"), + [ + "#!/bin/sh", + `if [ "$2" = "Label" ]; then printf '%s\\n' ${JSON.stringify(serviceLabel)}; exit 0; fi`, + `if [ "$2" = "ProgramArguments.0" ]; then printf '%s\\n' ${JSON.stringify(selectedProgram)}; exit 0; fi`, + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(bin, "launchctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(launchctlLog)}`, + `if [ "$1" = "print" ] && [ "$2" = ${JSON.stringify(serviceDomain)} ]; then`, + ` test -f ${JSON.stringify(active)} || exit 1`, + " printf 'state = running\\n'", + ` if [ -f ${JSON.stringify(restarted)} ]; then`, + ` printf 'program = %s\\n' ${JSON.stringify( + trustedRestartedProgram ? serviceProgram : path.join(root, "foreign-restarted-gateway"), + )}`, + " else", + ` printf 'program = %s\\n' ${JSON.stringify(serviceProgram)}`, + " fi", + " exit 0", + "fi", + `if [ "$1" = "bootout" ] && [ "$2" = ${JSON.stringify(serviceDomain)} ]; then rm -f ${JSON.stringify(active)}; exit 0; fi`, + `if [ "$1" = "bootstrap" ] && [ "$2" = "gui/501" ] && [ "$3" = ${JSON.stringify(servicePath)} ]; then touch ${JSON.stringify(active)} ${JSON.stringify(restarted)}; exit 0; fi`, + "exit 1", + ].join("\n"), + { mode: 0o755 }, + ); + + return { + active, + bin, + brewLog, + home, + launchctlLog, + serviceDomain, + serviceLabel, + servicePath, + }; +} describe("reboot lifecycle OpenShell gateway user-service fixture", () => { it("stages, enables, and removes the repository service without installer cleanup", () => { @@ -196,28 +356,158 @@ describe("reboot lifecycle OpenShell gateway user-service fixture", () => { }); describe("managed OpenShell gateway user-service restart", () => { - it("selects a marked unit from an absolute custom XDG config root", () => { + it("restarts the marked service and reports an immediately inactive restart (#10947)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-service-")); const home = path.join(root, "home"); const configHome = path.join(root, "config"); const bin = path.join(root, "bin"); const log = path.join(root, "systemctl.log"); const unitDir = path.join(configHome, "systemd", "user"); + const unit = path.join(unitDir, "nemoclaw-openshell-gateway.service"); + const gatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const upstreamUnit = path.join( + root, + "usr", + "lib", + "systemd", + "user", + "openshell-gateway.service", + ); + const upstreamGatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const active = path.join(root, "managed-active"); + const restartActivates = path.join(root, "restart-activates"); + const trustedInstaller = writeTrustedInstaller(root, upstreamUnit, upstreamGatewayBin); fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(bin, { recursive: true }); fs.mkdirSync(unitDir, { recursive: true }); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.mkdirSync(path.dirname(upstreamGatewayBin), { recursive: true }); + fs.writeFileSync(unit, "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"); + fs.writeFileSync(gatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(upstreamGatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(active, "active\n"); + fs.writeFileSync(restartActivates, "yes\n"); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); fs.writeFileSync( - path.join(unitDir, "nemoclaw-openshell-gateway.service"), - "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n", + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf 'FragmentPath=%s\\n' ${JSON.stringify(upstreamUnit)}`, + ` printf 'ExecStart={ path=%s ; argv[]=%s ; }\\n' ${JSON.stringify(upstreamGatewayBin)} ${JSON.stringify(upstreamGatewayBin)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user is-active --quiet openshell-gateway.service" ]; then exit 3; fi', + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value" ]; then printf '%s\\n' ${JSON.stringify(unit)}; exit 0; fi`, + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value" ]; then printf '{ path=%s ; argv[]=%s ; }\\n' ${JSON.stringify(gatewayBin)} ${JSON.stringify(gatewayBin)}; exit 0; fi`, + `if [ "$*" = "--user is-active --quiet nemoclaw-openshell-gateway.service" ]; then test -f ${JSON.stringify(active)} && exit 0; exit 3; fi`, + `if [ "$*" = "--user stop nemoclaw-openshell-gateway.service" ]; then rm -f ${JSON.stringify(active)}; exit 0; fi`, + `if [ "$*" = "--user restart nemoclaw-openshell-gateway.service" ]; then [ ! -f ${JSON.stringify(restartActivates)} ] || touch ${JSON.stringify(active)}; exit 0; fi`, + 'if [ "$*" = "--user show-environment" ] || [ "$*" = "--user daemon-reload" ]; then exit 0; fi', + "exit 97", + ].join("\n"), + { mode: 0o755 }, ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const stopped = runStopScript(trustedInstaller, env); + expect(stopped.status, stopped.stdout + stopped.stderr).toBe(0); + expect(stopped.stdout).toContain( + `${stoppedServicePrefix}systemd:nemoclaw-openshell-gateway.service`, + ); + + const restarted = runRestartScript( + trustedInstaller, + "systemd:nemoclaw-openshell-gateway.service", + env, + ); + expect(restarted.status, restarted.stdout + restarted.stderr).toBe(0); + + expect(env.XDG_CONFIG_HOME).toBe(configHome); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + "--user is-active --quiet openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + "--user stop nemoclaw-openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + "--user daemon-reload", + "--user restart nemoclaw-openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + ]); + expect(fs.existsSync(active)).toBe(true); + + fs.rmSync(active); + fs.rmSync(restartActivates); + fs.writeFileSync(log, ""); + const inactiveRestart = runRestartScript( + trustedInstaller, + "systemd:nemoclaw-openshell-gateway.service", + env, + ); + expect(inactiveRestart.status).toBe(1); + expect(inactiveRestart.stderr).toContain( + "The trusted OpenShell gateway user service did not become active after restart: " + + "nemoclaw-openshell-gateway.service", + ); + expect(fs.existsSync(active)).toBe(false); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + "--user daemon-reload", + "--user restart nemoclaw-openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); +}); + +describe("managed OpenShell gateway user-service stop", () => { + it("stops a marked NemoClaw unit from an absolute custom XDG config root (#10947)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-service-")); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unitDir = path.join(configHome, "systemd", "user"); + const unit = path.join(unitDir, "nemoclaw-openshell-gateway.service"); + const gatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const active = path.join(root, "managed-active"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(unitDir, { recursive: true }); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync(unit, "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"); + fs.writeFileSync(gatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(active, "active\n"); fs.writeFileSync( path.join(bin, "systemctl"), [ "#!/bin/sh", `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, - 'if [ "$*" = "--user cat openshell-gateway" ]; then exit 1; fi', - "exit 0", + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then exit 1; fi`, + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value" ]; then printf '%s\\n' ${JSON.stringify(unit)}; exit 0; fi`, + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value" ]; then printf '{ path=%s ; argv[]=%s ; }\\n' ${JSON.stringify(gatewayBin)} ${JSON.stringify(gatewayBin)}; exit 0; fi`, + `if [ "$*" = "--user is-active --quiet nemoclaw-openshell-gateway.service" ]; then test -f ${JSON.stringify(active)} && exit 0; exit 3; fi`, + `if [ "$*" = "--user stop nemoclaw-openshell-gateway.service" ]; then rm -f ${JSON.stringify(active)}; exit 0; fi`, + "exit 97", ].join("\n"), { mode: 0o755 }, ); @@ -227,22 +517,900 @@ describe("managed OpenShell gateway user-service restart", () => { HOME: home, PATH: `${bin}:/usr/bin:/bin`, XDG_CONFIG_HOME: configHome, + NEMOCLAW_GATEWAY_PORT: "8080", }); - execFileSync("sh", ["-lc", buildOpenShellGatewayUserServiceRestartScript()], { - env, - killSignal: "SIGKILL", - timeout: 30_000, + const result = runStopScript(writeNoUpstreamInstaller(root), env); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain( + `${stoppedServicePrefix}systemd:nemoclaw-openshell-gateway.service`, + ); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + "--user stop nemoclaw-openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("stops and restarts an active but disabled upstream OpenShell user service (#10947)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-")); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unit = path.join(root, "usr", "lib", "systemd", "user", "openshell-gateway.service"); + const gatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const active = path.join(root, "upstream-active"); + const trustedInstaller = writeTrustedInstaller(root, unit, gatewayBin); + const metadata = [ + `FragmentPath=${unit}`, + `ExecStart={ path=${gatewayBin} ; argv[]=${gatewayBin} ; }`, + ].join("\n"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.writeFileSync(gatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(active, "active\n"); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(metadata)}`, + " exit 0", + "fi", + `if [ "$*" = "--user is-active --quiet openshell-gateway.service" ]; then test -f ${JSON.stringify(active)} && exit 0; exit 3; fi`, + `if [ "$*" = "--user stop openshell-gateway.service" ]; then rm -f ${JSON.stringify(active)}; exit 0; fi`, + `if [ "$*" = "--user restart openshell-gateway.service" ]; then touch ${JSON.stringify(active)}; exit 0; fi`, + 'if [ "$*" = "--user is-enabled openshell-gateway.service" ]; then exit 1; fi', + 'if [ "$*" = "--user daemon-reload" ]; then exit 0; fi', + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, }); + const stopped = runStopScript(trustedInstaller, env); - expect(env.XDG_CONFIG_HOME).toBe(configHome); + expect(stopped.status, stopped.stdout + stopped.stderr).toBe(0); + expect(stopped.stdout).toContain(`${stoppedServicePrefix}systemd:openshell-gateway.service`); + expect(fs.existsSync(active)).toBe(false); + + const restarted = runRestartScript( + trustedInstaller, + "systemd:openshell-gateway.service", + env, + ); + expect(restarted.status, restarted.stdout + restarted.stderr).toBe(0); + expect(fs.existsSync(active)).toBe(true); expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ - "--user cat openshell-gateway", - "--user is-enabled nemoclaw-openshell-gateway", + "--user show-environment", + upstreamServiceShow, + "--user is-active --quiet openshell-gateway.service", + "--user stop openshell-gateway.service", + "--user is-active --quiet openshell-gateway.service", + upstreamServiceShow, "--user daemon-reload", - "--user restart nemoclaw-openshell-gateway", + "--user restart openshell-gateway.service", + "--user is-active --quiet openshell-gateway.service", + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it.each(["sh.brew.openshell", "homebrew.mxcl.openshell"])( + "stops and restarts the exact NVIDIA OpenShell Homebrew service %s on macOS (#10947)", + (serviceLabel) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-homebrew-")); + const { active, bin, brewLog, home, launchctlLog, serviceDomain, servicePath } = + writeMacServiceStubs(root, true, serviceLabel); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const result = runStopScript(installer, env); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain(`${stoppedServicePrefix}homebrew:${serviceLabel}`); + expect(fs.readFileSync(launchctlLog, "utf8").trim().split("\n")).toEqual([ + "print gui/501/sh.brew.openshell", + "print gui/501/homebrew.mxcl.openshell", + `print ${serviceDomain}`, + `bootout ${serviceDomain}`, + `print ${serviceDomain}`, + ]); + expect(fs.existsSync(active)).toBe(false); + + const restarted = runRestartScript(installer, `homebrew:${serviceLabel}`, env); + expect(restarted.status, restarted.stdout + restarted.stderr).toBe(0); + expect(fs.readFileSync(brewLog, "utf8").trim().split("\n")).toEqual([ + "--prefix", + "--prefix", + "list --formula openshell", + "info --json=v2 openshell", + "--prefix", + ]); + expect(fs.readFileSync(launchctlLog, "utf8").trim().split("\n")).toEqual([ + "print gui/501/sh.brew.openshell", + "print gui/501/homebrew.mxcl.openshell", + `print ${serviceDomain}`, + `bootout ${serviceDomain}`, + `print ${serviceDomain}`, + `bootstrap gui/501 ${servicePath}`, + `print ${serviceDomain}`, + ]); + expect(fs.readFileSync(brewLog, "utf8")).not.toContain("services restart openshell"); + expect(fs.existsSync(active)).toBe(true); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }, + ); + + it("refuses to restart a selected Homebrew service after its LaunchAgent becomes a symlink (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-restart-homebrew-symlink-"), + ); + const { active, bin, brewLog, home, launchctlLog, serviceLabel, servicePath } = + writeMacServiceStubs(root, true); + const replacement = path.join(root, "replacement.plist"); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const stopped = runStopScript(installer, env); + expect(stopped.status, stopped.stdout + stopped.stderr).toBe(0); + expect(fs.existsSync(active)).toBe(false); + + fs.renameSync(servicePath, replacement); + fs.symlinkSync(replacement, servicePath); + const restarted = runRestartScript(installer, `homebrew:${serviceLabel}`, env); + + expect(restarted.status).not.toBe(0); + expect(restarted.stderr).toContain("untrusted macOS user service"); + expect(fs.existsSync(active)).toBe(false); + expect(fs.readFileSync(brewLog, "utf8")).not.toContain("services restart openshell"); + expect(fs.readFileSync(launchctlLog, "utf8")).not.toContain("bootstrap"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("removes the selected Homebrew service when its restarted identity is untrusted (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-restart-homebrew-identity-"), + ); + const { active, bin, home, launchctlLog, serviceDomain } = writeMacServiceStubs( + root, + true, + "sh.brew.openshell", + false, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const stopped = runStopScript(installer, env); + expect(stopped.status, stopped.stdout + stopped.stderr).toBe(0); + + const restarted = runRestartScript(installer, "homebrew:sh.brew.openshell", env); + + expect(restarted.status).not.toBe(0); + expect(restarted.stderr).toContain("did not restart with its expected identity"); + expect(fs.existsSync(active)).toBe(false); + expect(fs.readFileSync(launchctlLog, "utf8").trim().split("\n").at(-1)).toBe( + `bootout ${serviceDomain}`, + ); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("rejects an untrusted OpenShell Homebrew service without stopping it (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-homebrew-foreign-"), + ); + const { active, bin, home, launchctlLog } = writeMacServiceStubs(root, false); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const result = runStopScript(installer, env); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("untrusted executable"); + expect(fs.readFileSync(launchctlLog, "utf8")).not.toContain("bootout"); + expect(fs.existsSync(active)).toBe(true); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("reports no service without inspecting absent service definitions (#10947)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-absent-")); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const candidateInstaller = writeCandidateInstaller(root, false); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + }); + const result = runStopScript(candidateInstaller, env); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`${stoppedServicePrefix}unavailable`); + expect(fs.readFileSync(log, "utf8").trim()).toBe("--user show-environment"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("rejects an untrusted upstream OpenShell user service without stopping it (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-foreign-"), + ); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const untrustedMetadata = [ + `FragmentPath=${home}/.config/systemd/user/openshell-gateway.service`, + "ExecStart={ path=/usr/bin/openshell-gateway ; argv[]=/usr/bin/openshell-gateway ; }", + ].join("\n"); + const candidateInstaller = writeCandidateInstaller(root, true); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(untrustedMetadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + }); + const result = runStopScript(candidateInstaller, env); + + expect(result.status).toBe(75); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("rejects an untrusted upstream executable independently of its trusted unit (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-executable-foreign-"), + ); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const metadata = [ + "FragmentPath=/usr/lib/systemd/user/openshell-gateway.service", + "ExecStart={ path=/tmp/foreign/openshell-gateway ; argv[]=/tmp/foreign/openshell-gateway ; }", + ].join("\n"); + const candidateInstaller = writeCandidateInstaller(root, true); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(metadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + }); + const result = runStopScript(candidateInstaller, env); + + expect(result.status).toBe(75); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("rejects trusted upstream metadata when the selected executable is unavailable (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-executable-missing-"), + ); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unit = path.join(root, "usr", "lib", "systemd", "user", "openshell-gateway.service"); + const missingGatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const trustedInstaller = writeTrustedInstaller(root, unit, missingGatewayBin); + const metadata = [ + `FragmentPath=${unit}`, + `ExecStart={ path=${missingGatewayBin} ; argv[]=${missingGatewayBin} ; }`, + ].join("\n"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(metadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + }); + const result = runStopScript(trustedInstaller, env); + + expect(result.status).toBe(75); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("falls back to an active marked service when the trusted upstream service is inactive (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-inactive-"), + ); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unit = path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"); + const gatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const upstreamUnit = path.join( + root, + "usr", + "lib", + "systemd", + "user", + "openshell-gateway.service", + ); + const upstreamGatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const active = path.join(root, "managed-active"); + const trustedInstaller = writeTrustedInstaller(root, upstreamUnit, upstreamGatewayBin); + const metadata = [ + `FragmentPath=${upstreamUnit}`, + `ExecStart={ path=${upstreamGatewayBin} ; argv[]=${upstreamGatewayBin} ; }`, + ].join("\n"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.dirname(unit), { recursive: true }); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.mkdirSync(path.dirname(upstreamGatewayBin), { recursive: true }); + fs.writeFileSync(unit, "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"); + fs.writeFileSync(gatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(upstreamGatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(active, "active\n"); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(metadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user is-active --quiet openshell-gateway.service" ]; then exit 3; fi', + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value" ]; then printf '%s\\n' ${JSON.stringify(unit)}; exit 0; fi`, + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value" ]; then printf '{ path=%s ; argv[]=%s ; }\\n' ${JSON.stringify(gatewayBin)} ${JSON.stringify(gatewayBin)}; exit 0; fi`, + `if [ "$*" = "--user is-active --quiet nemoclaw-openshell-gateway.service" ]; then test -f ${JSON.stringify(active)} && exit 0; exit 3; fi`, + `if [ "$*" = "--user stop nemoclaw-openshell-gateway.service" ]; then rm -f ${JSON.stringify(active)}; exit 0; fi`, + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const result = runStopScript(trustedInstaller, env); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain( + `${stoppedServicePrefix}systemd:nemoclaw-openshell-gateway.service`, + ); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + "--user is-active --quiet openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + "--user stop nemoclaw-openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("leaves a foreign NemoClaw-named unit untouched (#10947)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-foreign-")); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unitDir = path.join(configHome, "systemd", "user"); + const unit = path.join(unitDir, "nemoclaw-openshell-gateway.service"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(unitDir, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync(unit, "[Service]\nExecStart=/tmp/foreign\n"); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then exit 1; fi`, + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + NEMOCLAW_GATEWAY_PORT: "8080", + }); + const result = runStopScript(writeNoUpstreamInstaller(root), env); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("non-NemoClaw user service"); + expect(fs.readFileSync(unit, "utf8")).toBe("[Service]\nExecStart=/tmp/foreign\n"); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual(["--user show-environment"]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("preserves a user-manager failure while checking the upstream service state (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-state-manager-"), + ); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unit = path.join(root, "usr", "lib", "systemd", "user", "openshell-gateway.service"); + const gatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const markedUnit = path.join( + configHome, + "systemd", + "user", + "nemoclaw-openshell-gateway.service", + ); + const trustedInstaller = writeTrustedInstaller(root, unit, gatewayBin); + const metadata = [ + `FragmentPath=${unit}`, + `ExecStart={ path=${gatewayBin} ; argv[]=${gatewayBin} ; }`, + ].join("\n"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.dirname(gatewayBin), { recursive: true }); + fs.mkdirSync(path.dirname(markedUnit), { recursive: true }); + fs.writeFileSync(gatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(markedUnit, "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(metadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user is-active --quiet openshell-gateway.service" ]; then', + ' printf "Failed to connect to bus: Host is down\\n" >&2', + " exit 1", + "fi", + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + const result = runStopScript(trustedInstaller, env); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("Failed to connect to bus: Host is down"); + expect(result.stdout).not.toContain(stoppedServicePrefix); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + "--user is-active --quiet openshell-gateway.service", + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("preserves a user-manager failure while checking the marked service state (#10947)", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-marked-state-manager-"), + ); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const unit = path.join(root, "usr", "lib", "systemd", "user", "openshell-gateway.service"); + const upstreamGatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const markedUnit = path.join( + configHome, + "systemd", + "user", + "nemoclaw-openshell-gateway.service", + ); + const markedGatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const trustedInstaller = writeTrustedInstaller(root, unit, upstreamGatewayBin); + const metadata = [ + `FragmentPath=${unit}`, + `ExecStart={ path=${upstreamGatewayBin} ; argv[]=${upstreamGatewayBin} ; }`, + ].join("\n"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.dirname(upstreamGatewayBin), { recursive: true }); + fs.mkdirSync(path.dirname(markedUnit), { recursive: true }); + fs.mkdirSync(path.dirname(markedGatewayBin), { recursive: true }); + fs.writeFileSync(upstreamGatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(markedUnit, "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"); + fs.writeFileSync(markedGatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(metadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user is-active --quiet openshell-gateway.service" ]; then exit 3; fi', + 'if [ "$*" = "--user is-active --quiet nemoclaw-openshell-gateway.service" ]; then', + ' printf "Failed to connect to bus: No medium found\\n" >&2', + " exit 1", + "fi", + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + const result = runStopScript(trustedInstaller, env); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("Failed to connect to bus: No medium found"); + expect(result.stdout).not.toContain(stoppedServicePrefix); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + "--user is-active --quiet openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", ]); } finally { fs.rmSync(root, { force: true, recursive: true }); } }); + + it.each([ + { + expectedDiagnostic: "Failed to connect to bus: Host is down", + expectedStatus: 2, + failedCommand: + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + failure: "manager" as const, + precedingPropertyCommands: [] as string[], + property: "unit path", + }, + { + expectedDiagnostic: "Failed to connect to bus: Host is down", + expectedStatus: 2, + failedCommand: "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + failure: "manager" as const, + precedingPropertyCommands: [ + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + ], + property: "executable", + }, + { + expectedDiagnostic: "user service executable metadata is invalid", + expectedStatus: 1, + failedCommand: "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value", + failure: "metadata" as const, + precedingPropertyCommands: [ + "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value", + ], + property: "executable metadata", + }, + ])( + "refuses to fall back when the marked service $property cannot be verified (#10947)", + ({ expectedDiagnostic, expectedStatus, failedCommand, failure, precedingPropertyCommands }) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-marked-metadata-manager-"), + ); + const home = path.join(root, "home"); + const configHome = path.join(root, "config"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const upstreamUnit = path.join( + root, + "usr", + "lib", + "systemd", + "user", + "openshell-gateway.service", + ); + const upstreamGatewayBin = path.join(root, "usr", "bin", "openshell-gateway"); + const markedUnit = path.join( + configHome, + "systemd", + "user", + "nemoclaw-openshell-gateway.service", + ); + const markedGatewayBin = path.join(home, ".local", "bin", "openshell-gateway"); + const trustedInstaller = writeTrustedInstaller(root, upstreamUnit, upstreamGatewayBin); + const upstreamMetadata = [ + `FragmentPath=${upstreamUnit}`, + `ExecStart={ path=${upstreamGatewayBin} ; argv[]=${upstreamGatewayBin} ; }`, + ].join("\n"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(path.dirname(upstreamGatewayBin), { recursive: true }); + fs.mkdirSync(path.dirname(markedUnit), { recursive: true }); + fs.mkdirSync(path.dirname(markedGatewayBin), { recursive: true }); + fs.writeFileSync(upstreamGatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(markedUnit, "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"); + fs.writeFileSync(markedGatewayBin, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { + mode: 0o755, + }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf "%b\\n" ${JSON.stringify(upstreamMetadata)}`, + " exit 0", + "fi", + 'if [ "$*" = "--user is-active --quiet openshell-gateway.service" ]; then exit 3; fi', + 'if [ "$*" = "--user is-active --quiet nemoclaw-openshell-gateway.service" ]; then exit 0; fi', + `if [ "$*" = ${JSON.stringify(failedCommand)} ]; then`, + ...(failure === "manager" + ? [' printf "Failed to connect to bus: Host is down\\n" >&2', " exit 1"] + : [' printf "ExecStart={}\\n"', " exit 0"]), + "fi", + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value" ]; then printf '%s\\n' ${JSON.stringify(markedUnit)}; exit 0; fi`, + `if [ "$*" = "--user show nemoclaw-openshell-gateway.service --property=ExecStart --value" ]; then printf '{ path=%s ; argv[]=%s ; }\\n' ${JSON.stringify(markedGatewayBin)} ${JSON.stringify(markedGatewayBin)}; exit 0; fi`, + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: configHome, + }); + const result = runStopScript(trustedInstaller, env); + + expect(result.status).toBe(expectedStatus); + expect(result.stderr).toContain(expectedDiagnostic); + expect(result.stdout).not.toContain(stoppedServicePrefix); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + "--user is-active --quiet openshell-gateway.service", + "--user is-active --quiet nemoclaw-openshell-gateway.service", + ...precedingPropertyCommands, + failedCommand, + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }, + ); + + it.each([ + "Failed to connect to bus: No medium found", + "Failed to inspect service: Access denied", + ])("preserves an upstream service inspection failure: %s (#10947)", (diagnostic) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-upstream-manager-"), + ); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + const candidateInstaller = writeCandidateInstaller(root, true); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + 'if [ "$*" = "--user show-environment" ]; then exit 0; fi', + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then`, + ` printf '%s\\n' ${JSON.stringify(diagnostic)} >&2`, + " exit 1", + "fi", + "exit 97", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + }); + const result = runStopScript(candidateInstaller, env); + + expect(result.status).toBe(2); + expect(result.stderr).toContain(diagnostic); + expect(result.stdout).not.toContain(stoppedServicePrefix); + expect(fs.readFileSync(log, "utf8").trim().split("\n")).toEqual([ + "--user show-environment", + upstreamServiceShow, + ]); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("preserves a user-manager failure before inspecting user services (#10947)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-lifecycle-stop-manager-")); + const home = path.join(root, "home"); + const bin = path.join(root, "bin"); + const log = path.join(root, "systemctl.log"); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "uname"), "#!/bin/sh\nprintf 'Linux\\n'\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(bin, "systemctl"), + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >> ${JSON.stringify(log)}`, + `if [ "$*" = ${JSON.stringify(upstreamServiceShow)} ]; then exit 1; fi`, + 'if [ "$*" = "--user show-environment" ]; then', + ' printf "Failed to connect to bus\\n" >&2', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = buildAvailabilityProbeEnv({ + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + }); + const result = runStopScript(installer, env); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("Failed to connect to bus"); + expect(fs.readFileSync(log, "utf8").trim()).toBe("--user show-environment"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + }); }); diff --git a/test/install/install-openshell-gateway-service.test.ts b/test/install/install-openshell-gateway-service.test.ts index 9af8f16d971..2c6f4fa3b02 100644 --- a/test/install/install-openshell-gateway-service.test.ts +++ b/test/install/install-openshell-gateway-service.test.ts @@ -95,7 +95,7 @@ function writeSystemctlStub( `printf '%s\\n' "$*" >> ${JSON.stringify(log)}`, 'case "$*" in', ' "--user is-active --quiet nemoclaw-openshell-gateway.service")', - ` test -f ${JSON.stringify(active)}`, + ` test -f ${JSON.stringify(active)} || exit 3`, " ;;", ' "--user show nemoclaw-openshell-gateway.service --property=FragmentPath --value")', options.failedMetadataProperty === "FragmentPath" @@ -389,6 +389,7 @@ describe("install.sh OpenShell gateway service", () => { const result = runInstallHelper( home, [ + "upstream_openshell_gateway_user_service_installed() { return 0; }", `trusted_upstream_openshell_gateway_unit_for_service() { [[ "$1" == ${JSON.stringify(upstreamUnit)} ]]; }`, `trusted_upstream_openshell_gateway_bin_for_service() { [[ "$1" == ${JSON.stringify(overriddenGatewayBin)} ]]; }`, "resolve_upstream_openshell_gateway_bin_for_service", @@ -422,6 +423,7 @@ describe("install.sh OpenShell gateway service", () => { const result = runInstallHelper( home, [ + "upstream_openshell_gateway_user_service_installed() { return 0; }", `trusted_upstream_openshell_gateway_unit_for_service() { [[ "$1" == ${JSON.stringify(upstreamUnit)} ]]; }`, `trusted_upstream_openshell_gateway_bin_for_service() { [[ "$1" == ${JSON.stringify(gatewayBin)} ]]; }`, 'inspect_upstream_openshell_gateway_user_service || { printf "%s\\n" "$UPSTREAM_OPENSHELL_GATEWAY_SERVICE_ERROR" >&2; exit 1; }', @@ -1109,8 +1111,48 @@ describe("install.sh OpenShell gateway service", () => { ]); }); + it("stops upgrade retirement when the gateway user service cannot be inspected (#10947)", () => { + const home = makeTempRoot(); + const stateDir = path.join(home, "state"); + const registry = path.join(stateDir, "sandboxes.json"); + const log = path.join(home, "retirement.log"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(registry, "{}\n"); + + const result = runInstallHelper( + home, + [ + `nemoclaw_state_dir() { printf '%s\\n' ${JSON.stringify(stateDir)}; }`, + "nemoclaw_gateway_name() { printf 'nemoclaw\\n'; }", + "registered_sandbox_count() { printf '1\\n'; }", + "require_openshell_compatible_sandbox_names() { :; }", + "confirm_legacy_managed_image_recovery() { :; }", + `run_preupgrade_backup() { printf 'backup\\n' >> ${JSON.stringify(log)}; }`, + "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; }", + `openshell() { printf 'openshell %s\\n' "$*" >> ${JSON.stringify(log)}; return 1; }`, + `stop_nemoclaw_openshell_gateway_user_service() { printf 'service\\n' >> ${JSON.stringify(log)}; return 2; }`, + `stop_macos_openshell_gateway_user_service() { printf 'macos\\n' >> ${JSON.stringify(log)}; return 0; }`, + `stop_legacy_openshell_gateway_process() { printf 'pid\\n' >> ${JSON.stringify(log)}; return 0; }`, + "preinstall_backup_and_retire_legacy_gateway", + ].join("\n"), + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Could not retire the legacy OpenShell gateway"); + expect(fs.readFileSync(log, "utf-8").trim().split(/\r?\n/)).toEqual([ + "backup", + "openshell gateway destroy -g nemoclaw", + "openshell gateway destroy", + "service", + ]); + expect(fs.existsSync(registry)).toBe(true); + }); + it.each(["FragmentPath", "ExecStart"] as const)( - "returns control for the PID-file fallback when %s service metadata is unavailable (#8800)", + "returns an unknown-state status when %s service metadata cannot be inspected (#10947)", (failedMetadataProperty) => { const home = makeTempRoot(); const gatewayBin = userGatewayBin(home); @@ -1123,7 +1165,14 @@ describe("install.sh OpenShell gateway service", () => { const result = runInstallHelper( home, - "stop_nemoclaw_openshell_gateway_user_service || printf 'pid-file-fallback\\n'", + [ + "set +e", + "stop_nemoclaw_openshell_gateway_user_service", + "stop_status=$?", + "set -e", + "printf 'stop-status=%s\\n' \"$stop_status\"", + '[ "$stop_status" -eq 2 ]', + ].join("\n"), { PATH: `${systemctl.bin}:${path.dirname(process.execPath)}:${TEST_SYSTEM_PATH}` }, ); const calls = fs.readFileSync(systemctl.log, "utf-8"); @@ -1132,7 +1181,7 @@ describe("install.sh OpenShell gateway service", () => { expect(calls).toContain( `--user show nemoclaw-openshell-gateway.service --property=${failedMetadataProperty} --value`, ); - expect(result.stdout).toContain("pid-file-fallback"); + expect(result.stdout).toContain("stop-status=2"); expect(calls).not.toContain("--user stop nemoclaw-openshell-gateway.service"); }, ); @@ -1153,7 +1202,7 @@ describe("install.sh OpenShell gateway service", () => { const calls = fs.readFileSync(systemctl.log, "utf-8"); expect(result.status).not.toBe(0); - expect(result.stderr).toContain("active user service does not match"); + expect(result.stderr).toContain("user service does not match"); expect(calls).not.toContain("--user stop nemoclaw-openshell-gateway.service"); }); diff --git a/test/install/install-openshell-macos-upgrade.test.ts b/test/install/install-openshell-macos-upgrade.test.ts index b10d97c0b51..2ca90c681d4 100644 --- a/test/install/install-openshell-macos-upgrade.test.ts +++ b/test/install/install-openshell-macos-upgrade.test.ts @@ -624,6 +624,7 @@ describe("install.sh macOS OpenShell upgrade recovery", () => { ...(serviceLabel === "sh.brew.openshell" ? [`print gui/${process.getuid?.()}/${otherServiceLabel}`] : []), + `print ${serviceDomain}`, `bootout ${serviceDomain}`, `print ${serviceDomain}`, ]); @@ -661,6 +662,7 @@ describe("install.sh macOS OpenShell upgrade recovery", () => { expect(launchctlLog.trim().split(/\r?\n/)).toEqual([ `print ${currentDomain}`, `print gui/${process.getuid?.()}/${legacyLabel}`, + `print ${currentDomain}`, `bootout ${currentDomain}`, `print ${currentDomain}`, ]);