Skip to content
Merged
19 changes: 16 additions & 3 deletions scripts/setup-jetson.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ info() {
printf "[INFO] %s\n" "$*"
}

warn() {
printf "[WARN] %s\n" "$*" >&2
}

error() {
printf "[ERROR] %s\n" "$*" >&2
exit 1
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions test/helpers/vitest-watch-triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
180 changes: 178 additions & 2 deletions test/setup-jetson.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,112 @@
// 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";

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<T>(
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/);
Expand Down Expand Up @@ -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");
});
});
2 changes: 2 additions & 0 deletions test/vitest-watch-triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading