diff --git a/scripts/install.sh b/scripts/install.sh index f43c79e4d1c..ca6ed5b06ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2280,9 +2280,212 @@ maybe_install_openshell_during_install() { install_nemoclaw_openshell_gateway_user_service } +is_installer_managed_cli_shim() { + local shim_path="${1:-}" cli_bin="${2:-}" canonical_cli_path="${3:-}" + local line path_line node_dir path_dir exec_line shim_cli_path + local path_prefix path_middle path_suffix exec_prefix exec_suffix + local -a lines=() + + [[ -n "$shim_path" && -n "$cli_bin" && -n "$canonical_cli_path" ]] || return 1 + [[ -f "$shim_path" && ! -L "$shim_path" && -e "$canonical_cli_path" ]] || return 1 + while IFS= read -r line || [[ -n "$line" ]]; do + lines[${#lines[@]}]="$line" + [[ "${#lines[@]}" -le 3 ]] || return 1 + done <"$shim_path" || return 1 + [[ "${#lines[@]}" -eq 3 && "${lines[0]}" == "#!/usr/bin/env bash" ]] || return 1 + + path_line="${lines[1]}" + path_prefix='export PATH="' + # shellcheck disable=SC2016 # Match the literal $PATH emitted by the managed shim. + path_suffix=':$PATH"' + if [[ "$path_line" == "$path_prefix"*"$path_suffix" ]]; then + path_dir="${path_line#"$path_prefix"}" + path_dir="${path_dir%"$path_suffix"}" + [[ -n "$path_dir" ]] || return 1 + else + # shellcheck disable=SC2016 # Match the literal command substitution emitted by the shim. + path_prefix='[[ "$(command -v node 2>/dev/null)" == "' + path_middle='/node" ]] || export PATH="' + [[ "$path_line" == "$path_prefix"*"$path_middle"*"$path_suffix" ]] || return 1 + node_dir="${path_line#"$path_prefix"}" + node_dir="${node_dir%%"$path_middle"*}" + path_dir="${path_line#*"$path_middle"}" + path_dir="${path_dir%"$path_suffix"}" + [[ -n "$node_dir" && "$node_dir" == "$path_dir" ]] || return 1 + fi + + exec_prefix='exec "' + exec_suffix='" "$@"' + exec_line="${lines[2]}" + [[ "$exec_line" == "$exec_prefix"*"$exec_suffix" ]] || return 1 + shim_cli_path="${exec_line#"$exec_prefix"}" + shim_cli_path="${shim_cli_path%"$exec_suffix"}" + [[ "$shim_cli_path" == */"$cli_bin" && -e "$shim_cli_path" ]] || return 1 + [[ "$shim_cli_path" -ef "$canonical_cli_path" ]] +} + +is_npm_managed_nemoclaw_acp_link() { + local shim_path="${1:-}" cli_path="${2:-}" link_target + [[ -n "$shim_path" && -n "$cli_path" && "$shim_path" == "$cli_path" && -L "$shim_path" ]] \ + || return 1 + link_target="$(readlink "$shim_path")" || return 1 + [[ "$link_target" == "../lib/node_modules/nemoclaw/dist/lib/acp/main.js" ]] +} + +assert_nemoclaw_acp_shim_replaceable() { + local cli_bin="${1:-}" cli_path="${2:-}" shim_path before_identity after_identity + _NEMOCLAW_ACP_SHIM_REPLACE_IDENTITY="" + [[ "$cli_bin" == "nemoclaw-acp" ]] || return 0 + shim_path="${NEMOCLAW_SHIM_DIR}/${cli_bin}" + + if [[ ! -e "$shim_path" && ! -L "$shim_path" ]]; then + _NEMOCLAW_ACP_SHIM_REPLACE_IDENTITY="absent" + return 0 + fi + before_identity="$(cli_shim_entry_identity "$shim_path")" \ + || error "Installation stopped because NemoClaw could not inspect $shim_path without following symbolic links. NemoClaw left it unchanged." + if ! is_installer_managed_cli_shim "$shim_path" "$cli_bin" "$cli_path" \ + && ! is_npm_managed_nemoclaw_acp_link "$shim_path" "$cli_path"; then + error "Installation stopped because $shim_path already exists and is not a NemoClaw-managed shim. NemoClaw left it unchanged. Move or remove that path, then rerun the installer." + fi + after_identity="$(cli_shim_entry_identity "$shim_path")" \ + || error "Installation stopped because NemoClaw could not recheck $shim_path. NemoClaw left it unchanged." + [[ "$before_identity" == "$after_identity" ]] \ + || error "Installation stopped because $shim_path changed while NemoClaw checked it. NemoClaw left the current path unchanged. Rerun the installer." + _NEMOCLAW_ACP_SHIM_REPLACE_IDENTITY="$before_identity" +} + +cli_shim_entry_identity() { + local shim_path="${1:-}" identity + [[ -n "$shim_path" ]] || return 1 + if [[ ! -e "$shim_path" && ! -L "$shim_path" ]]; then + printf 'absent' + return 0 + fi + if identity="$(stat -c '%d:%i' -- "$shim_path" 2>/dev/null)"; then + : + elif identity="$(stat -f '%d:%i' "$shim_path" 2>/dev/null)"; then + : + else + return 1 + fi + printf '%s' "$identity" +} + +assert_nemoclaw_acp_shim_unchanged() { + local cli_bin="${1:-}" cli_path="${2:-}" expected_identity="${3:-}" shim_path current_identity + [[ "$cli_bin" == "nemoclaw-acp" ]] || return 0 + shim_path="${NEMOCLAW_SHIM_DIR}/${cli_bin}" + current_identity="$(cli_shim_entry_identity "$shim_path")" \ + || error "Installation stopped because NemoClaw could not recheck $shim_path. NemoClaw left it unchanged." + [[ -n "$expected_identity" && "$current_identity" == "$expected_identity" ]] \ + || error "Installation stopped because $shim_path changed while NemoClaw prepared its shim. NemoClaw left the current path unchanged. Rerun the installer." + if [[ "$expected_identity" != "absent" ]]; then + is_installer_managed_cli_shim "$shim_path" "$cli_bin" "$cli_path" \ + || is_npm_managed_nemoclaw_acp_link "$shim_path" "$cli_path" \ + || error "Installation stopped because $shim_path is no longer a NemoClaw-managed shim. NemoClaw left it unchanged. Rerun the installer." + fi +} + +preflight_nemoclaw_acp_shim() { + local shim_path="${NEMOCLAW_SHIM_DIR}/nemoclaw-acp" npm_bin="" cli_path="" + [[ -e "$shim_path" || -L "$shim_path" ]] || return 0 + npm_bin="$(resolve_npm_bin)" \ + || error "Installation stopped because NemoClaw could not resolve the active npm prefix to verify $shim_path. NemoClaw left it unchanged. Fix the npm configuration, then rerun the installer." + cli_path="${npm_bin:+${npm_bin}/nemoclaw-acp}" + if [[ -n "$cli_path" && "$cli_path" != "$shim_path" && -e "$cli_path" && + "$cli_path" -ef "$shim_path" ]]; then + return 0 + fi + is_installer_managed_cli_shim "$shim_path" "nemoclaw-acp" "$cli_path" && return 0 + is_npm_managed_nemoclaw_acp_link "$shim_path" "$cli_path" && return 0 + error "Installation stopped because $shim_path already exists and is not a NemoClaw-managed shim. NemoClaw left it unchanged. Move or remove that path, then rerun the installer." +} + +publish_cli_shim_no_clobber() { + local source_path="${1:-}" destination_path="${2:-}" node_path="${3:-}" + [[ -n "$source_path" && -n "$destination_path" && -x "$node_path" ]] || return 1 + "$node_path" -e ' +const fs = require("node:fs"); +try { + fs.linkSync(process.argv[1], process.argv[2]); +} catch { + process.exitCode = 1; +} +' "$source_path" "$destination_path" +} + +refresh_managed_nemoclaw_acp_shim() { + local source_path="${1:-}" destination_path="${2:-}" node_path="${3:-}" + local expected_identity="${4:-}" + [[ -n "$source_path" && -n "$destination_path" && -x "$node_path" && + -n "$expected_identity" && "$expected_identity" != "absent" ]] || return 1 + # shellcheck disable=SC2016 # JavaScript template literals are evaluated by Node.js. + "$node_path" -e ' +const fs = require("node:fs"); +const { O_NOFOLLOW, O_RDONLY, O_RDWR } = fs.constants; +let destination; +let original; +let originalMode; +let source; +let wroteDestination = false; + +function identity(stat) { + return `${stat.dev}:${stat.ino}`; +} + +function writeAll(fd, contents) { + let offset = 0; + while (offset < contents.length) { + offset += fs.writeSync(fd, contents, offset, contents.length - offset, offset); + } +} + +try { + source = fs.openSync(process.argv[1], O_RDONLY | O_NOFOLLOW); + const sourceStat = fs.fstatSync(source, { bigint: true }); + if (!sourceStat.isFile()) throw new Error("invalid source"); + const contents = fs.readFileSync(source); + + destination = fs.openSync(process.argv[2], O_RDWR | O_NOFOLLOW); + const before = fs.fstatSync(destination, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || identity(before) !== process.argv[3]) { + throw new Error("destination changed"); + } + original = fs.readFileSync(destination); + originalMode = Number(before.mode & 0o777n); + + fs.ftruncateSync(destination, 0); + wroteDestination = true; + writeAll(destination, contents); + fs.fchmodSync(destination, 0o755); + fs.fsyncSync(destination); + + const current = fs.lstatSync(process.argv[2], { bigint: true }); + if (!current.isFile() || identity(current) !== process.argv[3]) { + throw new Error("destination changed"); + } +} catch { + if (destination !== undefined && wroteDestination && original !== undefined) { + try { + fs.ftruncateSync(destination, 0); + writeAll(destination, original); + fs.fchmodSync(destination, originalMode); + fs.fsyncSync(destination); + } catch {} + } + process.exitCode = 1; +} finally { + if (destination !== undefined) fs.closeSync(destination); + if (source !== undefined) fs.closeSync(source); +} +' "$source_path" "$destination_path" "$expected_identity" +} + ensure_cli_shim() { local cli_bin="${1:-$_CLI_BIN}" - local npm_bin shim_path node_path node_dir cli_path expected_shim + local npm_bin shim_path node_path node_dir cli_path expected_shim temp_shim="" + local replace_identity="" npm_bin="$(resolve_npm_bin)" || true shim_path="${NEMOCLAW_SHIM_DIR}/${cli_bin}" @@ -2305,7 +2508,15 @@ ensure_cli_shim() { # npm_config_prefix=$HOME/.local), writing a shim would overwrite the real # binary with a script that exec's itself — an infinite loop. In that case # the binary is already where it needs to be; skip shim creation. - if [[ "$cli_path" -ef "$shim_path" ]]; then + if [[ "$cli_path" != "$shim_path" && -e "$shim_path" && "$cli_path" -ef "$shim_path" ]]; then + refresh_path + ensure_local_bin_in_profile + return 0 + fi + + assert_nemoclaw_acp_shim_replaceable "$cli_bin" "$cli_path" + replace_identity="${_NEMOCLAW_ACP_SHIM_REPLACE_IDENTITY:-}" + if [[ "$cli_path" == "$shim_path" ]]; then refresh_path ensure_local_bin_in_profile return 0 @@ -2319,15 +2530,51 @@ exec "$cli_path" "\$@" EOF )" - if [[ -x "$shim_path" ]] && cmp -s "$shim_path" <(printf '%s\n' "$expected_shim"); then + if [[ "$cli_bin" == "nemoclaw-acp" && "$replace_identity" != "absent" ]]; then + assert_nemoclaw_acp_shim_unchanged "$cli_bin" "$cli_path" "$replace_identity" + if cmp -s "$shim_path" <(printf '%s\n' "$expected_shim"); then + refresh_path + ensure_local_bin_in_profile + return 0 + fi + fi + + if [[ "$cli_bin" != "nemoclaw-acp" && -x "$shim_path" ]] \ + && cmp -s "$shim_path" <(printf '%s\n' "$expected_shim"); then refresh_path ensure_local_bin_in_profile return 0 fi mkdir -p "$NEMOCLAW_SHIM_DIR" - printf '%s\n' "$expected_shim" >"$shim_path" - chmod +x "$shim_path" + temp_shim="$(mktemp "${shim_path}.tmp.XXXXXX")" \ + || error "Could not create a temporary shim beside $shim_path." + _cleanup_files+=("$temp_shim") + if ! printf '%s\n' "$expected_shim" >"$temp_shim" || ! chmod 755 "$temp_shim"; then + rm -f "$temp_shim" + error "Could not prepare the user-local shim for $cli_bin." + fi + assert_nemoclaw_acp_shim_unchanged "$cli_bin" "$cli_path" "$replace_identity" + if [[ "$cli_bin" == "nemoclaw-acp" ]]; then + if [[ "$replace_identity" == "absent" ]]; then + if ! publish_cli_shim_no_clobber "$temp_shim" "$shim_path" "$node_path"; then + rm -f "$temp_shim" + if [[ -e "$shim_path" || -L "$shim_path" ]]; then + error "Installation stopped because $shim_path changed while NemoClaw published its shim. NemoClaw left the current path unchanged. Rerun the installer." + fi + error "Could not publish the user-local shim at $shim_path." + fi + elif ! refresh_managed_nemoclaw_acp_shim \ + "$temp_shim" "$shim_path" "$node_path" "$replace_identity"; then + rm -f "$temp_shim" + error "Installation stopped because NemoClaw could not safely refresh $shim_path. NemoClaw did not replace the current path. Rerun the installer." + fi + rm -f "$temp_shim" \ + || error "NemoClaw published $shim_path but could not remove its temporary shim." + elif ! mv -f -- "$temp_shim" "$shim_path"; then + rm -f "$temp_shim" + error "Could not publish the user-local shim at $shim_path." + fi refresh_path ensure_local_bin_in_profile info "Created user-local shim at $shim_path" @@ -2336,6 +2583,7 @@ EOF ensure_nemoclaw_shim() { local cli_bin status=0 + preflight_nemoclaw_acp_shim ensure_cli_shim "$_CLI_BIN" || status=$? for cli_bin in nemoclaw nemoclaw-acp nemohermes nemo-deepagents; do [[ "$cli_bin" == "$_CLI_BIN" ]] && continue @@ -2794,6 +3042,7 @@ install_nemoclaw() { local repo_root package_json repo_root="$(resolve_repo_root)" package_json="${repo_root}/package.json" + preflight_nemoclaw_acp_shim # Tell prepare not to run npm link — the installer handles linking explicitly. export NEMOCLAW_INSTALLING=1 @@ -6548,6 +6797,7 @@ install_nemoclaw_before_onboarding() { # `nemoclaw onboard` (the install-ollama / install-vllm branches). # install.sh stays focused on dependency setup. fix_npm_permissions + preflight_nemoclaw_acp_shim preinstall_backup_and_retire_legacy_gateway install_nemoclaw verify_nemoclaw diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index f52da469a3a..4e6e7dc1259 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -13,6 +13,7 @@ import { import { redirectInheritedChildStdoutToStderr } from "../../cli/stdout-guard"; import { buildSubprocessEnv } from "../../subprocess-env"; import { processTreeBoundedOpenshellInvocation } from "./process-tree-timeout"; +import { captureSandboxSshConfig } from "./sandbox-ssh-config-capture"; import { classifyManagedGatewayEndpointBinding } from "../../../../nemoclaw/dist/shared/openshell-gateway-endpoint-boundary.cjs"; export { classifyManagedGatewayEndpointBinding }; @@ -308,46 +309,17 @@ export function captureOpenshellCommand( }; } -/** - * Insert `-g ` after the subcommand pair, matching the placement - * `gatewayScopedArgs` already uses in `actions/sandbox/gateway-state.ts`. - * Duplicated rather than imported: an adapter must not depend on the actions - * layer. - */ -function gatewayScopedArgs(args: string[], gatewayName?: string): string[] { - if (!gatewayName) return args; - return [...args.slice(0, 2), "-g", gatewayName, ...args.slice(2)]; -} - export function captureSandboxSshConfigCommand( binary: string, sandboxName: string, opts: CaptureSandboxSshConfigOptions = {}, ): CaptureOpenshellResult { const { gatewayName, ...spawnOpts } = opts; - const sandboxGet = captureOpenshellCommand( - binary, - gatewayScopedArgs(["sandbox", "get", sandboxName], gatewayName), - { + return captureSandboxSshConfig(sandboxName, gatewayName, (args, { includeStderr }) => + captureOpenshellCommand(binary, args, { ...spawnOpts, - ignoreError: true, - includeStderr: true, - }, - ); - if (sandboxGet.status !== 0) { - const output = sandboxGet.output || `failed to query sandbox '${sandboxName}'`; - const sandboxMissing = /\bnot[- ]?found\b/i.test(output); - return { - ...sandboxGet, - output: sandboxMissing ? `sandbox '${sandboxName}' not found` : output, - }; - } - // Pin every hop to the same gateway so `get` and `ssh-config` cannot - // disagree about which one owns the sandbox. - return captureOpenshellCommand( - binary, - gatewayScopedArgs(["sandbox", "ssh-config", sandboxName], gatewayName), - spawnOpts, + ...(includeStderr ? { ignoreError: true, includeStderr: true } : {}), + }), ); } diff --git a/src/lib/adapters/openshell/hermes-acp-ssh-cli.test.ts b/src/lib/adapters/openshell/hermes-acp-ssh-cli.test.ts index ed451eb36ea..86c048d7905 100644 --- a/src/lib/adapters/openshell/hermes-acp-ssh-cli.test.ts +++ b/src/lib/adapters/openshell/hermes-acp-ssh-cli.test.ts @@ -176,7 +176,8 @@ describe("CLI Hermes ACP SSH transport", () => { expect(probe.at(-1)).toContain('m.version("hermes-agent")'); expect(probe.at(-1)).toContain('m.version("agent-client-protocol")'); expect(session.at(-1)).toBe("/usr/local/bin/hermes-acp"); - expect(session).not.toContain("sh"); + expect(session.slice(0, -1)).not.toContain("sh"); + expect(session.slice(0, -1)).not.toContain("/bin/sh"); }); it("forwards duplex bytes with backpressure and keeps stderr out of ACP output", async () => { @@ -322,6 +323,29 @@ describe("CLI Hermes ACP SSH transport", () => { expect(fixture.cleanup).toHaveBeenCalledOnce(); }); + it("returns the SIGTERM exit code when AbortSignal cancels the compatibility probe", async () => { + const probe = fakeChild(); + const fixture = harness([probe]); + const controller = new AbortController(); + const io = streams(); + const pending = fixture.transport.run({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + signal: controller.signal, + streams: io.value, + }); + await vi.waitFor(() => expect(fixture.spawnSsh).toHaveBeenCalledOnce()); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ + kind: "failed", + error: { kind: "cancelled" }, + exitCode: 143, + }); + expect(probe.kill).toHaveBeenCalledWith("SIGTERM"); + expect(fixture.cleanup).toHaveBeenCalledOnce(); + }); + it.each([ ["SIGTERM" as const, 143], ["SIGINT" as const, 130], diff --git a/src/lib/adapters/openshell/hermes-acp-ssh-cli.ts b/src/lib/adapters/openshell/hermes-acp-ssh-cli.ts index 6700cfce044..031ea08e719 100644 --- a/src/lib/adapters/openshell/hermes-acp-ssh-cli.ts +++ b/src/lib/adapters/openshell/hermes-acp-ssh-cli.ts @@ -20,6 +20,7 @@ import { isValidName } from "../../sandbox-name-contract"; import { isSshTransportFailure } from "../../state/ssh-transport"; import { resolveOpenshellBinaryOrNull } from "./resolve-shared"; import { buildOpenShellRuntimeSelectionEnv } from "./runtime-selection"; +import { captureSandboxSshConfig } from "./sandbox-ssh-config-capture"; import { OPENSHELL_DEFAULT_WORKSPACE } from "./sandbox-ssh-host"; import { HERMES_ACP_EXECUTABLE, @@ -297,7 +298,7 @@ async function runProbe( return failure( "cancelled", "The Hermes ACP compatibility probe was cancelled.", - requestedSignal === "SIGTERM" ? 143 : 130, + requestedSignal === "SIGINT" ? 130 : 143, ); } if (outputOverflow) { @@ -449,33 +450,6 @@ function buildRuntimeEnv(gatewayName: string): NodeJS.ProcessEnv { return environment; } -function captureConfig( - binary: string, - request: HermesAcpSshRequest, - environment: NodeJS.ProcessEnv, - capture: CaptureOpenShell, -): string | null { - const options = { - env: environment, - ignoreError: true, - includeStreams: true, - maxBuffer: SSH_CONFIG_MAX_BYTES, - openshellBinary: binary, - replaceEnv: true, - timeout: ACP_SETUP_TIMEOUT_MS, - } as const; - const sandbox = capture( - ["sandbox", "get", "-g", request.gatewayName, request.sandboxName], - options, - ); - if (sandbox.status !== 0 || sandbox.error) return null; - const result = capture( - ["sandbox", "ssh-config", "-g", request.gatewayName, request.sandboxName], - options, - ); - return result.status === 0 && !result.error && result.output.trim() ? result.output : null; -} - function parseExactVersion(output: string): string | null { const matches = [...output.matchAll(/(?:^|[^0-9.])(\d+\.\d+\.\d+)(?![0-9.])/gu)] .map((match) => match[1]) @@ -568,12 +542,24 @@ export function createCliHermesAcpSshTransport( 69, ); } - const config = captureConfig( - binaries.openshell, - request, - environment, - deps.captureOpenShell ?? captureOpenShell, + const configResult = captureSandboxSshConfig( + request.sandboxName, + request.gatewayName, + (args) => + (deps.captureOpenShell ?? captureOpenShell)(args, { + env: environment, + ignoreError: true, + includeStreams: true, + maxBuffer: SSH_CONFIG_MAX_BYTES, + openshellBinary: binaries.openshell, + replaceEnv: true, + timeout: ACP_SETUP_TIMEOUT_MS, + }), ); + const config = + configResult.status === 0 && !configResult.error && configResult.output.trim() + ? configResult.output + : null; if (!config) { return failure( "transport", diff --git a/src/lib/adapters/openshell/sandbox-ssh-config-capture.ts b/src/lib/adapters/openshell/sandbox-ssh-config-capture.ts new file mode 100644 index 00000000000..af2cd2e991e --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-ssh-config-capture.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type SandboxSshConfigCaptureResult = Readonly<{ + error?: Error; + output: string; + status: number | null; +}>; + +type CaptureSandboxCommand = ( + args: string[], + options: Readonly<{ includeStderr: boolean }>, +) => SandboxSshConfigCaptureResult; + +function gatewayScopedArgs(args: string[], gatewayName?: string): string[] { + if (!gatewayName) return args; + return [...args.slice(0, 2), "-g", gatewayName, ...args.slice(2)]; +} + +/** Capture one sandbox's SSH configuration after confirming it exists on the same gateway. */ +export function captureSandboxSshConfig( + sandboxName: string, + gatewayName: string | undefined, + capture: CaptureSandboxCommand, +): SandboxSshConfigCaptureResult { + const sandboxGet = capture(gatewayScopedArgs(["sandbox", "get", sandboxName], gatewayName), { + includeStderr: true, + }); + if (sandboxGet.status !== 0 || sandboxGet.error) { + const output = sandboxGet.output || `failed to query sandbox '${sandboxName}'`; + const sandboxMissing = /\bnot[- ]?found\b/iu.test(output); + return { + ...sandboxGet, + output: sandboxMissing ? `sandbox '${sandboxName}' not found` : output, + }; + } + return capture(gatewayScopedArgs(["sandbox", "ssh-config", sandboxName], gatewayName), { + includeStderr: false, + }); +} diff --git a/src/lib/gateway-runtime-action.test.ts b/src/lib/gateway-runtime-action.test.ts index 0cd84c382c5..6424581c233 100644 --- a/src/lib/gateway-runtime-action.test.ts +++ b/src/lib/gateway-runtime-action.test.ts @@ -281,6 +281,34 @@ describe("gateway-runtime-action per-sandbox gateway routing", () => { expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw-8090"); }); + it("reports a redacted startup failure only after recovery remains unhealthy", async () => { + const output = { + error: vi.fn(), + log: vi.fn(), + step: vi.fn(), + warn: vi.fn(), + }; + captureSpy.mockReturnValue({ + status: 0, + output: "Status: Disconnected\nGateway: nemoclaw\n", + }); + runSpy.mockReturnValue({ status: 0 } as never); + startGatewaySpy.mockRejectedValueOnce( + new Error("gateway start failed with Authorization: Bearer recovery-secret"), + ); + + const result = await gatewayRuntime.recoverNamedGatewayRuntime({ + gatewayName: "nemoclaw-8090", + output, + }); + + expect(result).toMatchObject({ recovered: false, attempted: true }); + expect(output.error).toHaveBeenCalledOnce(); + expect(output.error.mock.calls[0]?.[0]).toContain("OpenShell gateway recovery failed"); + expect(output.error.mock.calls[0]?.[0]).toContain(""); + expect(output.error.mock.calls[0]?.[0]).not.toContain("recovery-secret"); + }); + it("keeps recovery probes and startup on the frozen OpenShell target (#10514)", async () => { vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); diff --git a/src/lib/gateway-runtime-action.ts b/src/lib/gateway-runtime-action.ts index ef11ae46bad..a7790b34676 100644 --- a/src/lib/gateway-runtime-action.ts +++ b/src/lib/gateway-runtime-action.ts @@ -19,6 +19,7 @@ import { resolveSandboxGatewayName, } from "./onboard/gateway-binding"; import type { GatewayRecoveryOutput } from "./onboard/gateway-recovery"; +import { sanitizeReadinessText } from "./readiness/sanitize"; export { resolveGatewayName, resolveSandboxGatewayName }; @@ -247,6 +248,7 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun const shouldStartGateway = [before.state, after.state].some((state) => recoverableStates.has(state), ); + let startFailure: unknown = null; if (shouldStartGateway) { try { @@ -256,9 +258,8 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun ...(options.output ? { output: options.output } : {}), ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), }); - } catch { - // Fall through to the lifecycle re-check below so we preserve the - // existing recovery result shape and emit the correct classification. + } catch (error) { + startFailure = error; } gatewayRuntimeDependencies.runOpenshell( ["gateway", "select", gatewayName], @@ -278,5 +279,15 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun } } + if (startFailure !== null && options.output) { + const detail = sanitizeReadinessText( + startFailure instanceof Error ? startFailure.message : String(startFailure), + 240, + ) + .replace(/\s+/gu, " ") + .trim(); + options.output.error(`OpenShell gateway recovery failed${detail ? `: ${detail}` : "."}`); + } + return { recovered: false, before, after, attempted: true }; } diff --git a/src/lib/onboard/docker-driver-gateway-service.test.ts b/src/lib/onboard/docker-driver-gateway-service.test.ts index a1f668621c2..3abf0e98c19 100644 --- a/src/lib/onboard/docker-driver-gateway-service.test.ts +++ b/src/lib/onboard/docker-driver-gateway-service.test.ts @@ -461,9 +461,16 @@ describe("docker-driver-gateway-service", () => { }); it("uses managed service only after metadata and direct gRPC health are ready (#6903)", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => undefined); const events: string[] = []; const clock = createVirtualClock(); + const output = { + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + }; + const verifySandboxBridgeGatewayReachableOrExit = vi.fn(async () => { + events.push("verify"); + }); let registerCount = 0; await expect( @@ -479,6 +486,7 @@ describe("docker-driver-gateway-service", () => { return true; }, now: clock.now, + output, registerDockerDriverGatewayEndpoint: () => { events.push("register"); registerCount += 1; @@ -495,14 +503,16 @@ describe("docker-driver-gateway-service", () => { started: true, statusCommand: "systemctl --user status nemoclaw-openshell-gateway", }), - verifySandboxBridgeGatewayReachableOrExit: async () => { - events.push("verify"); - }, + verifySandboxBridgeGatewayReachableOrExit, }), ).resolves.toBe(true); expect(events).toEqual(["register", "sleep", "register", "ready", "clear", "verify"]); - expect(log).toHaveBeenCalledWith(" Starting OpenShell gateway via managed service..."); + expect(output.log).toHaveBeenCalledWith(" Starting OpenShell gateway via managed service..."); + expect(verifySandboxBridgeGatewayReachableOrExit).toHaveBeenCalledWith(false, { + output, + skip: false, + }); }); it.each([ diff --git a/src/lib/onboard/docker-driver-gateway-service.ts b/src/lib/onboard/docker-driver-gateway-service.ts index 44aa4cbd8b3..56dd08fd394 100644 --- a/src/lib/onboard/docker-driver-gateway-service.ts +++ b/src/lib/onboard/docker-driver-gateway-service.ts @@ -148,7 +148,10 @@ export interface PackageManagedDockerDriverGatewayOptions { validatePortOwnerForOpenShellGatewayUserServiceStart?: () => void; verifySandboxBridgeGatewayReachableOrExit: ( exitOnFailure: boolean, - options?: { skip?: boolean }, + options?: { + output?: Pick; + skip?: boolean; + }, ) => Promise; } @@ -1516,6 +1519,7 @@ export async function startPackageManagedDockerDriverGateway({ if (healthy) { clearDockerDriverGatewayRuntimeFiles(); await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure, { + ...(output ? { output } : {}), skip: skipSandboxBridgeReachability, }); log(" ✓ OpenShell gateway managed service is healthy"); diff --git a/test/e2e/fixtures/hermes-acp-live.ts b/test/e2e/fixtures/hermes-acp-live.ts index c3588e9fda9..97376e56476 100644 --- a/test/e2e/fixtures/hermes-acp-live.ts +++ b/test/e2e/fixtures/hermes-acp-live.ts @@ -23,6 +23,7 @@ export type HermesAcpLiveScenario = | "cancel" | "client-disconnect" | "exchange" + | "gateway-recovery" | "gateway-restart" | "initialize" | "remote-exit"; @@ -55,6 +56,7 @@ export function hermesAcpLiveHostEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessE "NODE_EXTRA_CA_CERTS", "CURL_CA_BUNDLE", "XDG_CONFIG_HOME", + "NEMOCLAW_OPENSHELL_BIN", "OPENSHELL_GATEWAY", "OPENSHELL_WORKSPACE", ]) { @@ -393,7 +395,10 @@ export async function runHermesAcpLiveScenario(options: HermesAcpLiveOptions): P options.sandboxName, options.env, ); - } else if (scenarioValid && options.scenario === "exchange") { + } else if ( + scenarioValid && + (options.scenario === "exchange" || options.scenario === "gateway-recovery") + ) { scenarioValid = await writeRequest(input, { jsonrpc: "2.0", id: 2, @@ -435,6 +440,7 @@ export async function runHermesAcpLiveScenario(options: HermesAcpLiveOptions): P cancel: 143, "client-disconnect": 1, exchange: 0, + "gateway-recovery": 0, "gateway-restart": 255, initialize: 0, "remote-exit": null, @@ -458,7 +464,8 @@ export async function runHermesAcpLiveScenario(options: HermesAcpLiveOptions): P scenarioValid && adapterProcessAbsent && remoteProcessAbsent && - (options.scenario !== "exchange" || + (options.scenario !== "gateway-recovery" || stderrObserved) && + (!["exchange", "gateway-recovery"].includes(options.scenario) || hermesAcpExchangeEvidencePassed({ pongObserved: promptEvidence.pongObserved, promptCompleted, diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index d6369b2bb79..f8fa398c7b5 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -792,8 +792,12 @@ test( timeoutMs: 30_000, }, ); - expect(recoveredHealth.exitCode, resultText(recoveredHealth)).toBe(0); - expect(resultText(recoveredHealth)).toMatch(/"ok"/i); + expect( + recoveredHealth.exitCode === 0 && + recoveredHealth.stderr === "" && + /"ok"/i.test(recoveredHealth.stdout), + resultText(recoveredHealth), + ).toBe(true); await expectDashboardReachable("phase-4-dashboard-host-after-recover"); // OpenClaw launch qualification now reads its structured JSONL session @@ -813,6 +817,19 @@ test( sandboxName: SANDBOX_NAME, scenario, }); + await lifecycle.stopGatewayRuntime(); + const stoppedGatewayStatus = await host.command("openshell", ["status"], { + artifactName: "phase-5-openshell-gateway-stopped-before-acp-recovery", + env: commandEnv(), + timeoutMs: 30_000, + }); + expect( + stoppedGatewayStatus.exitCode === 0 && + /^Status:[ \t]*Disconnected[ \t]*\r?$/imu.test(stoppedGatewayStatus.stdout), + resultText(stoppedGatewayStatus), + ).toBe(true); + const gatewayRecoveryPassed = await runAcpScenario("gateway-recovery"); + await lifecycle.waitForGatewayConnected(); const exchangePassed = await runAcpScenario("exchange"); const remoteExitPassed = await runAcpScenario("remote-exit"); const cancellationPassed = await runAcpScenario("cancel"); @@ -832,6 +849,7 @@ test( }); const postRestartInitializePassed = await runAcpScenario("initialize"); const acpLifecyclePassed = + gatewayRecoveryPassed && exchangePassed && remoteExitPassed && cancellationPassed && @@ -988,6 +1006,7 @@ test( hermesSkillUsedInFreshSession: true, standaloneRoutingSidecarsAbsentAfterRecovery: true, hermesAcpInitializeSessionPromptPong: true, + hermesAcpRecoversStoppedOpenShellGatewayBeforeSession: true, hermesAcpInterruptDisconnectAndRemoteExitClean: true, hermesAcpCleansUpAndReconnectsAfterOpenShellGatewayRestart: true, dashboardChecked: hermesDashboardE2eEnabled(), diff --git a/test/e2e/support/hermes-acp-live.test.ts b/test/e2e/support/hermes-acp-live.test.ts index 555ed61505c..26ac8679786 100644 --- a/test/e2e/support/hermes-acp-live.test.ts +++ b/test/e2e/support/hermes-acp-live.test.ts @@ -24,6 +24,7 @@ describe("Hermes ACP live evidence boundary", () => { expect( hermesAcpLiveHostEnv({ HOME: "/tmp/home", + NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-openshell", PATH: "/usr/bin", OPENSHELL_GATEWAY: "nemoclaw", NVIDIA_INFERENCE_API_KEY: "secret", @@ -33,6 +34,7 @@ describe("Hermes ACP live evidence boundary", () => { }), ).toEqual({ HOME: "/tmp/home", + NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-openshell", PATH: "/usr/bin", OPENSHELL_GATEWAY: "nemoclaw", }); diff --git a/test/install/install-npm-resolution.test.ts b/test/install/install-npm-resolution.test.ts index 282fe2cda6c..e54e01c9367 100644 --- a/test/install/install-npm-resolution.test.ts +++ b/test/install/install-npm-resolution.test.ts @@ -66,6 +66,44 @@ function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); } +function writeNodeForwarder(target: string): void { + writeExecutable(target, `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} "$@"\n`); +} + +function createPackagedCliTree(prefix: string): { + fakeBin: string; + prefixBin: string; +} { + const fakeBin = path.join(prefix, "bin"); + const prefixBin = path.join(prefix, "prefix", "bin"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(prefixBin, { recursive: true }); + + writeNodeForwarder(path.join(fakeBin, "node")); + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then + [ -z "$NPM_CALL_LOG" ] || printf '%s\n' "$*" >> "$NPM_CALL_LOG" + echo "$ACTIVE_NPM_PREFIX" + exit 0 +fi +[ -z "$NPM_CALL_LOG" ] || printf '%s\n' "$*" >> "$NPM_CALL_LOG" +exit 99 +`, + ); + ["nemoclaw", "nemoclaw-acp", "nemohermes", "nemo-deepagents"].forEach((cliBin) => { + writeExecutable( + path.join(prefixBin, cliBin), + `#!/usr/bin/env bash +echo "${cliBin} v0.1.0" +`, + ); + }); + + return { fakeBin, prefixBin }; +} + function normalizeShellPathForAssert(value: string): string { return value.replace(/\\/g, "/"); } @@ -203,12 +241,7 @@ describe("installer npm resolution", () => { fs.mkdirSync(fakeBin); fs.mkdirSync(prefixBin, { recursive: true }); - writeExecutable( - path.join(fakeBin, "node"), - `#!/usr/bin/env bash -exit 0 -`, - ); + writeNodeForwarder(path.join(fakeBin, "node")); writeExecutable( path.join(fakeBin, "npm"), `#!/usr/bin/env bash @@ -247,6 +280,470 @@ echo "${cliBin} v0.1.0" }, ); + it("leaves a foreign nemoclaw-acp executable untouched and stops before creating sibling shims (#10947)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-collision-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const foreignContents = Buffer.from("#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + const foreignMode = 0o755; + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(shimPath, foreignContents, { mode: foreignMode }); + + const result = runInstallerFunction("_CLI_BIN=nemoclaw; ensure_nemoclaw_shim", fakeBin, { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.readFileSync(shimPath)).toEqual(foreignContents); + expect(fs.statSync(shimPath).mode & 0o777).toBe(foreignMode); + expect(fs.existsSync(path.join(tmp, ".local", "bin", "nemoclaw"))).toBe(false); + expect(`${result.stdout}${result.stderr}`).toContain( + `${shimPath} already exists and is not a NemoClaw-managed shim`, + ); + expect(`${result.stdout}${result.stderr}`).toContain("NemoClaw left it unchanged"); + }); + + it("stops installation before npm links over a foreign nemoclaw-acp executable (#10947)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-prefix-collision-")); + const { fakeBin } = createPackagedCliTree(tmp); + const npmPrefix = path.join(tmp, ".local"); + const shimPath = path.join(npmPrefix, "bin", "nemoclaw-acp"); + const npmCallLog = path.join(tmp, "npm-calls.log"); + const foreignContents = Buffer.from("#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(shimPath, foreignContents, { mode: 0o755 }); + + const result = runInstallerFunction("install_nemoclaw", fakeBin, { + ACTIVE_NPM_PREFIX: npmPrefix, + HOME: tmp, + NPM_CALL_LOG: npmCallLog, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.readFileSync(shimPath)).toEqual(foreignContents); + expect(fs.readFileSync(npmCallLog, "utf-8")).not.toContain("link --ignore-scripts"); + expect(fs.existsSync(path.join(tmp, ".local", "bin", "nemoclaw"))).toBe(false); + expect(`${result.stdout}${result.stderr}`).toContain( + `${shimPath} already exists and is not a NemoClaw-managed shim`, + ); + }); + + it("reports npm prefix failure before classifying an existing nemoclaw-acp shim (#10947)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-npm-prefix-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const packagedCli = path.join(prefixBin, "nemoclaw-acp"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + writeExecutable( + shimPath, + [ + "#!/usr/bin/env bash", + `export PATH="${fakeBin}:$PATH"`, + `exec "${packagedCli}" "$@"`, + "", + ].join("\n"), + ); + writeExecutable(path.join(fakeBin, "npm"), "#!/usr/bin/env bash\nexit 99\n"); + const originalContents = fs.readFileSync(shimPath); + + const result = runInstallerFunction("preflight_nemoclaw_acp_shim", fakeBin, { + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.readFileSync(shimPath)).toEqual(originalContents); + expect(`${result.stdout}${result.stderr}`).toContain( + `could not resolve the active npm prefix to verify ${shimPath}`, + ); + expect(`${result.stdout}${result.stderr}`).not.toContain("is not a NemoClaw-managed shim"); + }); + + it.skipIf(process.platform === "win32")( + "accepts the exact npm-managed nemoclaw-acp link at the user-local prefix (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-prefix-link-")); + const { fakeBin } = createPackagedCliTree(tmp); + const npmPrefix = path.join(tmp, ".local"); + const shimPath = path.join(npmPrefix, "bin", "nemoclaw-acp"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.symlinkSync("../lib/node_modules/nemoclaw/dist/lib/acp/main.js", shimPath); + + const result = runInstallerFunction("preflight_nemoclaw_acp_shim", fakeBin, { + ACTIVE_NPM_PREFIX: npmPrefix, + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readlinkSync(shimPath)).toBe("../lib/node_modules/nemoclaw/dist/lib/acp/main.js"); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects an npm-managed nemoclaw-acp link from an inactive prefix (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-stale-prefix-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.symlinkSync("../lib/node_modules/nemoclaw/dist/lib/acp/main.js", shimPath); + + const result = runInstallerFunction("preflight_nemoclaw_acp_shim", fakeBin, { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.readlinkSync(shimPath)).toBe("../lib/node_modules/nemoclaw/dist/lib/acp/main.js"); + expect(`${result.stdout}${result.stderr}`).toContain( + `${shimPath} already exists and is not a NemoClaw-managed shim`, + ); + expect(`${result.stdout}${result.stderr}`).toContain("NemoClaw left it unchanged"); + }, + ); + + it.skipIf(process.platform === "win32")( + "leaves a foreign nemoclaw-acp symbolic link and its target untouched (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-link-collision-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const targetPath = path.join(tmp, "user-command"); + const foreignContents = Buffer.from("#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(targetPath, foreignContents, { mode: 0o755 }); + fs.symlinkSync(targetPath, shimPath); + + const result = runInstallerFunction('ensure_cli_shim "nemoclaw-acp"', fakeBin, { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.lstatSync(shimPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(shimPath)).toBe(targetPath); + expect(fs.readFileSync(targetPath)).toEqual(foreignContents); + expect(`${result.stdout}${result.stderr}`).toContain("NemoClaw left it unchanged"); + }, + ); + + it("rejects an unverified three-line nemoclaw-acp wrapper (#10947)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-ambiguous-shim-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const foreignBin = path.join(tmp, "foreign", "bin"); + const foreignCli = path.join(foreignBin, "nemoclaw-acp"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.mkdirSync(foreignBin, { recursive: true }); + writeExecutable(foreignCli, "#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + writeExecutable( + shimPath, + [ + "#!/usr/bin/env bash", + '[[ "$(command -v node 2>/dev/null)" == "/old/node/bin/node" ]] || export PATH="/old/node/bin:$PATH"', + `exec "${foreignCli}" "$@"`, + "", + ].join("\n"), + ); + const originalContents = fs.readFileSync(shimPath); + + const result = runInstallerFunction("_CLI_BIN=nemoclaw; ensure_nemoclaw_shim", fakeBin, { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.readFileSync(shimPath)).toEqual(originalContents); + expect(`${result.stdout}${result.stderr}`).toContain( + `${shimPath} already exists and is not a NemoClaw-managed shim`, + ); + }); + + it("keeps the current nemoclaw-acp wrapper that targets the packaged executable (#10947)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-managed-shim-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const packagedCli = path.join(prefixBin, "nemoclaw-acp"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + writeExecutable( + shimPath, + [ + "#!/usr/bin/env bash", + `[[ "$(command -v node 2>/dev/null)" == "${path.join(fakeBin, "node")}" ]] || export PATH="${fakeBin}:$PATH"`, + `exec "${packagedCli}" "$@"`, + "", + ].join("\n"), + ); + const originalContents = fs.readFileSync(shimPath); + + const result = runInstallerFunction( + 'ensure_cli_shim "nemoclaw-acp"; "$NEMOCLAW_SHIM_DIR/nemoclaw-acp" --version', + fakeBin, + { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readFileSync(shimPath)).toEqual(originalContents); + expect(result.stdout).toContain("nemoclaw-acp v0.1.0"); + }); + + it("refreshes a managed nemoclaw-acp wrapper for the selected Node.js runtime (#10947)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-stale-node-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const packagedCli = path.join(prefixBin, "nemoclaw-acp"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + writeExecutable(packagedCli, "#!/usr/bin/env node\nconsole.log('nemoclaw-acp v0.1.0');\n"); + writeExecutable( + shimPath, + [ + "#!/usr/bin/env bash", + '[[ "$(command -v node 2>/dev/null)" == "/removed/node/bin/node" ]] || export PATH="/removed/node/bin:$PATH"', + `exec "${packagedCli}" "$@"`, + "", + ].join("\n"), + ); + const originalContents = fs.readFileSync(shimPath); + + const result = runInstallerFunction( + '_CLI_BIN=nemoclaw; ensure_nemoclaw_shim; PATH="$TEST_SYSTEM_PATH" "$NEMOCLAW_SHIM_DIR/nemoclaw-acp"', + fakeBin, + { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + TEST_SYSTEM_PATH, + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readFileSync(shimPath)).not.toEqual(originalContents); + expect(fs.readFileSync(shimPath, "utf-8")).toContain(path.join(fakeBin, "node")); + expect(result.stdout).toContain("nemoclaw-acp v0.1.0"); + }); + + it.skipIf(process.platform === "win32")( + "preserves a foreign path raced into place during managed ACP shim refresh (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-refresh-race-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const packagedCli = path.join(prefixBin, "nemoclaw-acp"); + const foreignPath = path.join(tmp, "user-command"); + const foreignContents = Buffer.from("#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(foreignPath, foreignContents, { mode: 0o755 }); + writeExecutable( + shimPath, + [ + "#!/usr/bin/env bash", + '[[ "$(command -v node 2>/dev/null)" == "/removed/node/bin/node" ]] || export PATH="/removed/node/bin:$PATH"', + `exec "${packagedCli}" "$@"`, + "", + ].join("\n"), + ); + fs.unlinkSync(path.join(fakeBin, "node")); + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +rm -f "$RACE_SHIM_PATH" +ln -s "$RACE_FOREIGN_PATH" "$RACE_SHIM_PATH" +exec ${JSON.stringify(process.execPath)} "$@" +`, + ); + + const result = runInstallerFunction('ensure_cli_shim "nemoclaw-acp"', fakeBin, { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + RACE_FOREIGN_PATH: foreignPath, + RACE_SHIM_PATH: shimPath, + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.lstatSync(shimPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(shimPath)).toBe(foreignPath); + expect(fs.readFileSync(foreignPath)).toEqual(foreignContents); + expect(`${result.stdout}${result.stderr}`).toContain(`could not safely refresh ${shimPath}`); + }, + ); + + it.skipIf(process.platform === "win32")( + "keeps a packaged nemoclaw-acp executable reached through a symlinked directory (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-same-file-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimDir = path.join(tmp, ".local", "bin"); + const packagedCli = path.join(prefixBin, "nemoclaw-acp"); + const originalContents = fs.readFileSync(packagedCli); + fs.mkdirSync(path.dirname(shimDir), { recursive: true }); + fs.symlinkSync(prefixBin, shimDir, "dir"); + + const result = runInstallerFunction('ensure_cli_shim "nemoclaw-acp"', fakeBin, { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readFileSync(packagedCli)).toEqual(originalContents); + expect(fs.realpathSync(path.join(shimDir, "nemoclaw-acp"))).toBe( + fs.realpathSync(packagedCli), + ); + }, + ); + + it.skipIf(process.platform === "win32")( + "stops when nemoclaw-acp changes after validation and preserves the replacement target (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-swap-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const packagedCli = path.join(prefixBin, "nemoclaw-acp"); + const targetPath = path.join(tmp, "user-command"); + const foreignContents = Buffer.from("#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(targetPath, foreignContents, { mode: 0o755 }); + writeExecutable( + shimPath, + [ + "#!/usr/bin/env bash", + `[[ "$(command -v node 2>/dev/null)" == "${path.join(fakeBin, "node")}" ]] || export PATH="${fakeBin}:$PATH"`, + `exec "${packagedCli}" "$@"`, + "", + ].join("\n"), + ); + + const result = runInstallerFunction( + `shim_path=${JSON.stringify(shimPath)} +swap_target=${JSON.stringify(targetPath)} +identity_calls=${JSON.stringify(path.join(tmp, "identity-calls"))} +eval "$(declare -f cli_shim_entry_identity | sed '1s/cli_shim_entry_identity/original_cli_shim_entry_identity/')" +cli_shim_entry_identity() { + local count=0 + [[ ! -f "$identity_calls" ]] || count="$(cat "$identity_calls")" + count=$((count + 1)) + printf '%s\\n' "$count" >"$identity_calls" + if [[ "$count" -eq 3 ]]; then + rm -f "$shim_path" + ln -s "$swap_target" "$shim_path" + fi + original_cli_shim_entry_identity "$@" +} +ensure_cli_shim "nemoclaw-acp"`, + fakeBin, + { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.lstatSync(shimPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(shimPath)).toBe(targetPath); + expect(fs.readFileSync(targetPath)).toEqual(foreignContents); + expect( + fs.readdirSync(path.dirname(shimPath)).filter((name) => name.includes(".tmp.")).length, + ).toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output).toContain(shimPath); + expect(output).toMatch( + /(?:changed while NemoClaw prepared its shim|is no longer a NemoClaw-managed shim)/, + ); + }, + ); + + it.skipIf(process.platform === "win32")( + "stops when nemoclaw-acp appears during publication and preserves that entry (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-publish-race-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const replacementPath = path.join(tmp, "user-command"); + const foreignContents = Buffer.from("#!/usr/bin/env bash\nprintf 'user-owned\\n'\n"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.writeFileSync(replacementPath, foreignContents, { mode: 0o755 }); + + const result = runInstallerFunction( + `shim_path=${JSON.stringify(shimPath)} +replacement_path=${JSON.stringify(replacementPath)} +eval "$(declare -f publish_cli_shim_no_clobber | sed '1s/publish_cli_shim_no_clobber/original_publish_cli_shim_no_clobber/')" +publish_cli_shim_no_clobber() { + command ln "$replacement_path" "$shim_path" + original_publish_cli_shim_no_clobber "$@" +} +ensure_cli_shim "nemoclaw-acp"`, + fakeBin, + { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.readFileSync(shimPath)).toEqual(foreignContents); + expect( + fs.readdirSync(path.dirname(shimPath)).filter((name) => name.includes(".tmp.")).length, + ).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + `${shimPath} changed while NemoClaw published its shim`, + ); + }, + ); + + it.skipIf(process.platform === "win32")( + "stops when a directory symlink appears during nemoclaw-acp publication (#10947)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-acp-dir-race-")); + const { fakeBin, prefixBin } = createPackagedCliTree(tmp); + const shimPath = path.join(tmp, ".local", "bin", "nemoclaw-acp"); + const replacementDir = path.join(tmp, "user-directory"); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.mkdirSync(replacementDir); + + const result = runInstallerFunction( + `shim_path=${JSON.stringify(shimPath)} +replacement_dir=${JSON.stringify(replacementDir)} +eval "$(declare -f publish_cli_shim_no_clobber | sed '1s/publish_cli_shim_no_clobber/original_publish_cli_shim_no_clobber/')" +publish_cli_shim_no_clobber() { + command ln -s "$replacement_dir" "$shim_path" + original_publish_cli_shim_no_clobber "$@" +} +ensure_cli_shim "nemoclaw-acp"`, + fakeBin, + { + ACTIVE_NPM_PREFIX: path.dirname(prefixBin), + HOME: tmp, + NO_COLOR: "1", + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(fs.lstatSync(shimPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(shimPath)).toBe(replacementDir); + expect(fs.readdirSync(replacementDir)).toEqual([]); + expect( + fs.readdirSync(path.dirname(shimPath)).filter((name) => name.includes(".tmp.")).length, + ).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + `${shimPath} changed while NemoClaw published its shim`, + ); + }, + ); + it("keeps PATH stable only when the generated shim resolves its selected Node", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-stable-shim-path-")); const fakeBin = path.join(tmp, "bin");