diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index 68cb04379d1..6b054780d06 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -11,6 +11,10 @@ info() { printf "[INFO] %s\n" "$*" } +warn() { + printf "[WARN] %s\n" "$*" >&2 +} + error() { printf "[ERROR] %s\n" "$*" >&2 exit 1 @@ -52,6 +56,13 @@ apply_br_netfilter_setup() { echo "net.bridge.bridge-nf-call-iptables=1" | "${SUDO[@]}" tee /etc/sysctl.d/99-nemoclaw.conf >/dev/null } +warn_host_setup_skipped() { + warn "Skipped Jetson host setup: iptables legacy mode and the Docker daemon.json adjustment (L4T 36.x only), and br_netfilter with net.bridge.bridge-nf-call-iptables=1 (every release)." + warn "Without br_netfilter, k3s inside the OpenShell gateway cannot NAT sandbox pod traffic to ClusterIP services, so sandbox pods cannot reach CoreDNS." + warn "Recognized L4T releases: 36.x (JetPack 6), 38.x (JetPack 7), and 39.x or later (JetPack 7)." + warn "Installation continues in an untested configuration." +} + get_jetpack_version() { local release_line release revision l4t_version @@ -62,8 +73,9 @@ get_jetpack_version() { revision="$(printf '%s\n' "$release_line" | sed -n 's/^.*REVISION: \([0-9][0-9]*\)\..*$/\1/p')" l4t_version="${release}.${revision}" - if [[ -z "$release" ]]; then - info "Jetson detected but could not parse L4T release — skipping host setup" >&2 + if [[ -z "$release" || -z "$revision" ]]; then + warn "Jetson detected but the L4T release could not be parsed from /etc/nv_tegra_release." + warn_host_setup_skipped return 0 fi @@ -104,7 +116,8 @@ get_jetpack_version() { printf "%s" "jp7-r38" ;; *) - info "Jetson detected (L4T $l4t_version) but version is not recognized — skipping host setup" >&2 + warn "Jetson detected (L4T $l4t_version) but this L4T release is not recognized." + warn_host_setup_skipped ;; esac } diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index e6880297107..7499b7c7390 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -71,6 +71,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)test\/e2e\/lib\/ci-compatible-inference\.sh$/, testsToRun: runTests("test/e2e/support/hosted-inference.test.ts"), }, + { + pattern: /(?:^|\/)scripts\/setup-jetson\.sh$/, + testsToRun: runTests("test/setup-jetson.test.ts"), + }, { pattern: /(?:^|\/)scripts\/e2e\/sanitize-trace-timing\.py$/, testsToRun: runTests( diff --git a/test/setup-jetson.test.ts b/test/setup-jetson.test.ts index b1a9f7874c9..67ea13c5a0a 100644 --- a/test/setup-jetson.test.ts +++ b/test/setup-jetson.test.ts @@ -1,8 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -10,6 +18,95 @@ import { describe, expect, it } from "vitest"; const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "setup-jetson.sh"); +const HOST_MUTATION_COMMANDS = [ + "sudo", + "modprobe", + "sysctl", + "tee", + "update-alternatives", + "systemctl", + "python3", +]; + +type SetupJetsonRun = { + status: number | null; + stdout: string; + stderr: string; + headArgs: string; +}; + +function withJetsonReleaseSandbox( + run: (paths: { headArgsPath: string; releasePath: string; stubDir: string }) => T, +): T { + const tempDir = mkdtempSync(path.join(tmpdir(), "nemoclaw-jetson-release-")); + + try { + const stubDir = path.join(tempDir, "bin"); + const headArgsPath = path.join(tempDir, "head-args"); + const releasePath = path.join(tempDir, "nv_tegra_release"); + mkdirSync(stubDir); + for (const command of HOST_MUTATION_COMMANDS) { + const stubPath = path.join(stubDir, command); + writeFileSync(stubPath, "#!/usr/bin/env bash\nexit 0\n"); + chmodSync(stubPath, 0o755); + } + + const headStubPath = path.join(stubDir, "head"); + writeFileSync( + headStubPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' "$*" > ${JSON.stringify(headArgsPath)}`, + `if [[ -f ${JSON.stringify(releasePath)} ]]; then`, + ` cat ${JSON.stringify(releasePath)}`, + "fi", + "", + ].join("\n"), + ); + chmodSync(headStubPath, 0o755); + + return run({ headArgsPath, releasePath, stubDir }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function spawnSetupJetson( + stubDir: string, + headArgsPath: string, + extraEnv: NodeJS.ProcessEnv = {}, +): SetupJetsonRun { + const result = spawnSync("bash", [SCRIPT_PATH], { + encoding: "utf-8", + env: { + ...process.env, + ...extraEnv, + PATH: `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }); + + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + headArgs: readFileSync(headArgsPath, "utf-8").trim(), + }; +} + +function runSetupJetson(releaseLine: string): SetupJetsonRun { + return withJetsonReleaseSandbox(({ headArgsPath, releasePath, stubDir }) => { + writeFileSync(releasePath, `${releaseLine}\n`); + return spawnSetupJetson(stubDir, headArgsPath); + }); +} + +function runSetupJetsonWithoutReleaseFile(): SetupJetsonRun { + return withJetsonReleaseSandbox(({ headArgsPath, stubDir }) => + spawnSetupJetson(stubDir, headArgsPath), + ); +} + function extractDaemonJsonPatcher(): string { const script = readFileSync(SCRIPT_PATH, "utf-8"); const match = script.match(/<<'PYEOF'\n([\s\S]*?)\nPYEOF/); @@ -147,3 +244,82 @@ describe("setup-jetson daemon.json patcher", () => { } }); }); + +describe("setup-jetson host setup on an unrecognized L4T release (#7612)", () => { + it("names the skipped host setup, its consequence, the recognized releases, and that installation continues", () => { + const result = runSetupJetson("# R35 (release), REVISION: 4.1, GCID: 12345678, BOARD: t186ref"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain( + "Jetson detected (L4T 35.4) but this L4T release is not recognized.", + ); + expect(result.stderr).toContain("Skipped Jetson host setup"); + expect(result.stderr).toContain("iptables legacy mode"); + expect(result.stderr).toContain("br_netfilter"); + expect(result.stderr).toContain("sandbox pods cannot reach CoreDNS"); + expect(result.stderr).toContain( + "Recognized L4T releases: 36.x (JetPack 6), 38.x (JetPack 7), and 39.x or later (JetPack 7).", + ); + expect(result.stderr).toContain("Installation continues in an untested configuration."); + }); + + it("keeps the warning off stdout so the resolved version stays empty", () => { + const result = runSetupJetson("# R35 (release), REVISION: 4.1, GCID: 12345678, BOARD: t186ref"); + + expect(result.stdout).toBe(""); + }); + + it("warns with the same detail when the release line cannot be parsed", () => { + const result = runSetupJetson("not a tegra release line"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Jetson detected but the L4T release could not be parsed"); + expect(result.stderr).toContain("Skipped Jetson host setup"); + expect(result.stderr).toContain("Installation continues in an untested configuration."); + expect(result.stdout).toBe(""); + }); + + it("treats a missing revision as a parse failure instead of selecting a release family", () => { + const result = runSetupJetson("# R36 (release), GCID: 12345678, BOARD: t186ref"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Jetson detected but the L4T release could not be parsed"); + expect(result.stderr).toContain("Skipped Jetson host setup"); + expect(result.stderr).toContain("Installation continues in an untested configuration."); + expect(result.stdout).toBe(""); + }); + + it("stays silent on a host that is not a Jetson", () => { + const result = runSetupJetsonWithoutReleaseFile(); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + + it("ignores an inherited test release-path override during normal installation", () => { + const result = withJetsonReleaseSandbox(({ headArgsPath, releasePath, stubDir }) => { + const inheritedOverridePath = path.join(path.dirname(releasePath), "inherited-release"); + writeFileSync( + inheritedOverridePath, + "# R36 (release), REVISION: 5.1, GCID: 12345678, BOARD: t186ref\n", + ); + return spawnSetupJetson(stubDir, headArgsPath, { + NEMOCLAW_TEST_NV_TEGRA_RELEASE_PATH: inheritedOverridePath, + }); + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + expect(result.headArgs).toBe("-n1 /etc/nv_tegra_release"); + }); + + it("resolves a recognized release to its host configuration without warning", () => { + const result = runSetupJetson("# R36 (release), REVISION: 5.1, GCID: 12345678, BOARD: t186ref"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Jetson detected (jp6)"); + expect(result.stderr).not.toContain("Skipped Jetson host setup"); + }); +}); diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index 8517b474caa..aea6c1324ad 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -54,6 +54,7 @@ const OPAQUE_INPUTS = [ "agents/hermes/runtime-config-guard.py", "agents/hermes/mcp-config-transaction.py", "test/e2e/lib/ci-compatible-inference.sh", + "scripts/setup-jetson.sh", "scripts/e2e/sanitize-trace-timing.py", "test/e2e/manifests/openclaw-nvidia.yaml", "test/e2e/docs/parity-inventory.generated.json", @@ -99,6 +100,7 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy("test/e2e/lib/ci-compatible-inference.sh")).toEqual([ "test/e2e/support/hosted-inference.test.ts", ]); + expect(triggeredBy("scripts/setup-jetson.sh")).toEqual(["test/setup-jetson.test.ts"]); expect(triggeredBy("scripts/e2e/sanitize-trace-timing.py")).toEqual([ "test/e2e/support/e2e-scorecard.test.ts", "test/e2e/support/sanitize-trace-timing.test.ts",