From 602b87fbca1d2aff3f2b7d72df98a9f0cc68d238 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:54:42 +0000 Subject: [PATCH 1/7] fix(jetson): use python3 to patch daemon.json instead of sed The previous sed command removed the trailing comma from '"default-runtime": "nvidia",' which produced malformed JSON when '"runtimes"' was the next key: { "default-runtime": "nvidia" <- missing comma "runtimes": { ... } } This caused Docker to fail to start on Jetson devices after running the setup script. Replace the sed one-liner with a Python snippet that parses the JSON, removes the 'iptables' and 'bridge' keys cleanly, and writes back valid JSON. Python3 is available on all supported Jetson/Ubuntu setups. Fixes #1875 Signed-off-by: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> --- scripts/setup-jetson.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index 523d5ba8d77..1807e5a3db1 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -50,7 +50,24 @@ configure_jetson_host() { case "$jetpack_version" in jp6) "${SUDO[@]}" update-alternatives --set iptables /usr/sbin/iptables-legacy - "${SUDO[@]}" sed -i '/"iptables": false,/d; /"bridge": "none"/d; s/"default-runtime": "nvidia",/"default-runtime": "nvidia"/' /etc/docker/daemon.json + # Patch /etc/docker/daemon.json using Python to avoid generating invalid JSON. + # The previous sed approach stripped the trailing comma from + # "default-runtime": "nvidia", which produced malformed JSON when + # "runtimes" was the next key. See: https://github.com/NVIDIA/NemoClaw/issues/1875 + "${SUDO[@]}" python3 - /etc/docker/daemon.json <<'PYEOF' +import json, sys +path = sys.argv[1] +try: + with open(path) as f: + cfg = json.load(f) +except (FileNotFoundError, json.JSONDecodeError): + cfg = {} +cfg.pop('iptables', None) +cfg.pop('bridge', None) +with open(path, 'w') as f: + json.dump(cfg, f, indent=4) +f.write('\n') +PYEOF ;; jp7) # JP7 (Thor) does not need iptables or Docker daemon.json changes. From 999471178002f351f86abba3188abe6ea50b0ea0 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:26:50 +0000 Subject: [PATCH 2/7] fix(jetson): fix f.write outside with block and add atomic write + JSON repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues found in code review: 1. f.write('\n') was outside the with block — ValueError on closed file. Moved inside the with block. 2. Direct write to daemon.json is not atomic: if interrupted, Docker gets a truncated/empty config. Now writes to a tempfile in the same directory and os.replace()s it into place after a successful json.dump. 3. JSONDecodeError path silently wiped the file (cfg = {}). Now attempts to repair the known missing-comma pattern from the previous sed approach before re-parsing. Aborts with a clear error if the file cannot be repaired automatically. Per CodeRabbit + Copilot review on #1913. Signed-off-by: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> --- scripts/setup-jetson.sh | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index 1807e5a3db1..cecabe15079 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -55,18 +55,43 @@ configure_jetson_host() { # "default-runtime": "nvidia", which produced malformed JSON when # "runtimes" was the next key. See: https://github.com/NVIDIA/NemoClaw/issues/1875 "${SUDO[@]}" python3 - /etc/docker/daemon.json <<'PYEOF' -import json, sys +import json, os, re, sys, tempfile path = sys.argv[1] try: with open(path) as f: cfg = json.load(f) -except (FileNotFoundError, json.JSONDecodeError): +except FileNotFoundError: cfg = {} +except json.JSONDecodeError: + # Attempt to repair the known missing-comma pattern introduced by the + # previous sed-based approach before re-parsing. If repair fails, abort + # rather than silently overwriting the file with an empty object. + with open(path) as f: + raw = f.read() + # Insert missing comma after "default-runtime": "nvidia" when followed + # by whitespace + a quoted key (next JSON member without comma separator). + repaired = re.sub( + r'("default-runtime"\s*:\s*"nvidia")([\s\n]+")', + r'\1,\2', + raw, + ) + try: + cfg = json.loads(repaired) + except json.JSONDecodeError as e: + sys.exit(f'daemon.json is malformed and could not be repaired automatically: {e}') cfg.pop('iptables', None) cfg.pop('bridge', None) -with open(path, 'w') as f: - json.dump(cfg, f, indent=4) -f.write('\n') +# Write atomically: dump to a temp file in the same directory, then replace. +dirname = os.path.dirname(os.path.abspath(path)) +fd, tmp = tempfile.mkstemp(dir=dirname) +try: + with os.fdopen(fd, 'w') as f: + json.dump(cfg, f, indent=4) + f.write('\n') + os.replace(tmp, path) +except Exception: + os.unlink(tmp) + raise PYEOF ;; jp7) From f2c102b5d06342999b04f6bad8529c1ea96c6afa Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:47:44 +0000 Subject: [PATCH 3/7] fix(jetson): preserve daemon.json file permissions after atomic replace tempfile.mkstemp creates files with mode 0600. After os.replace the new daemon.json had restrictive permissions instead of the typical 0644, breaking diagnostic tools expecting world-readable config. Now copies permissions from the original file (or falls back to 0644). Per CodeRabbit review on #1913. Signed-off-by: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> --- scripts/setup-jetson.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index cecabe15079..86043ba6fd7 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -82,9 +82,16 @@ except json.JSONDecodeError: cfg.pop('iptables', None) cfg.pop('bridge', None) # Write atomically: dump to a temp file in the same directory, then replace. +# Copy permissions from the original file (or use 0644 if missing) so the +# replaced file is world-readable, matching the typical daemon.json mode. dirname = os.path.dirname(os.path.abspath(path)) +try: + orig_mode = os.stat(path).st_mode & 0o777 +except FileNotFoundError: + orig_mode = 0o644 fd, tmp = tempfile.mkstemp(dir=dirname) try: + os.chmod(tmp, orig_mode) with os.fdopen(fd, 'w') as f: json.dump(cfg, f, indent=4) f.write('\n') From ddafbf28ce5e4b5244a0026a1018785d7e24355f Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:50:13 +0000 Subject: [PATCH 4/7] fix(jetson): restore daemon.json mode after atomic replace After os.replace(), the new daemon.json should keep the original file mode (typically 0644). Restore the mode explicitly so the file remains readable for diagnostics and matches the previous behavior. Per CodeRabbit review on #1913. Signed-off-by: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> --- scripts/setup-jetson.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index 86043ba6fd7..a54c4c5973b 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -96,6 +96,7 @@ try: json.dump(cfg, f, indent=4) f.write('\n') os.replace(tmp, path) + os.chmod(path, orig_mode) except Exception: os.unlink(tmp) raise From 75022f34fcd896a677d627352028408a8a73a6fa Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:05:38 +0000 Subject: [PATCH 5/7] fix(jetson): add python3 preflight and JSON root type guard Two defensive improvements: - Add explicit python3 preflight before invoking the inline patcher, so missing python3 produces a clear error message instead of an opaque 'command not found' abort - Guard against non-dict JSON root in daemon.json with a clear sys.exit message before calling .pop() Per CodeRabbit review on #1913. Signed-off-by: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> --- scripts/setup-jetson.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index a54c4c5973b..b4a0c7ffa55 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -54,6 +54,8 @@ configure_jetson_host() { # The previous sed approach stripped the trailing comma from # "default-runtime": "nvidia", which produced malformed JSON when # "runtimes" was the next key. See: https://github.com/NVIDIA/NemoClaw/issues/1875 + "${SUDO[@]}" python3 --version >/dev/null 2>&1 || + error "python3 is required to patch /etc/docker/daemon.json but was not found on PATH" "${SUDO[@]}" python3 - /etc/docker/daemon.json <<'PYEOF' import json, os, re, sys, tempfile path = sys.argv[1] @@ -79,6 +81,8 @@ except json.JSONDecodeError: cfg = json.loads(repaired) except json.JSONDecodeError as e: sys.exit(f'daemon.json is malformed and could not be repaired automatically: {e}') +if not isinstance(cfg, dict): + sys.exit('daemon.json must contain a top-level JSON object') cfg.pop('iptables', None) cfg.pop('bridge', None) # Write atomically: dump to a temp file in the same directory, then replace. From 1d5f8dd4e528f388849fe94a6ba4c680a574ccc3 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:46:03 +0000 Subject: [PATCH 6/7] ci: retrigger checks after force-push cancellation Signed-off-by: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> From bb70b57a3ddc0d3ee1cbf7ab9e8bd47d2c014155 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 22 Apr 2026 01:59:20 -0700 Subject: [PATCH 7/7] test(jetson): add daemon.json patcher coverage --- scripts/setup-jetson.sh | 4 +- test/setup-jetson.test.ts | 141 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 test/setup-jetson.test.ts diff --git a/scripts/setup-jetson.sh b/scripts/setup-jetson.sh index 27070c6bb21..607cfcbc128 100755 --- a/scripts/setup-jetson.sh +++ b/scripts/setup-jetson.sh @@ -64,8 +64,8 @@ configure_jetson_host() { # The previous sed approach stripped the trailing comma from # "default-runtime": "nvidia", which produced malformed JSON when # "runtimes" was the next key. See: https://github.com/NVIDIA/NemoClaw/issues/1875 - "${SUDO[@]}" python3 --version >/dev/null 2>&1 || - error "python3 is required to patch /etc/docker/daemon.json but was not found on PATH" + "${SUDO[@]}" python3 --version >/dev/null 2>&1 \ + || error "python3 is required to patch /etc/docker/daemon.json but was not found on PATH" "${SUDO[@]}" python3 - /etc/docker/daemon.json <<'PYEOF' import json, os, re, sys, tempfile path = sys.argv[1] diff --git a/test/setup-jetson.test.ts b/test/setup-jetson.test.ts new file mode 100644 index 00000000000..834bcc0e656 --- /dev/null +++ b/test/setup-jetson.test.ts @@ -0,0 +1,141 @@ +// 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 { 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"); + +function extractDaemonJsonPatcher(): string { + const script = readFileSync(SCRIPT_PATH, "utf-8"); + const match = script.match(/<<'PYEOF'\n([\s\S]*?)\nPYEOF/); + if (!match) { + throw new Error("Failed to extract inline daemon.json patcher from scripts/setup-jetson.sh"); + } + return match[1]; +} + +function runDaemonJsonPatcher(daemonPath: string): void { + execFileSync("python3", ["-", daemonPath], { + input: extractDaemonJsonPatcher(), + encoding: "utf-8", + }); +} + +function getExecErrorOutput(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + + const stderr = "stderr" in error ? error.stderr : ""; + if (typeof stderr === "string") { + return stderr; + } + if (Buffer.isBuffer(stderr)) { + return stderr.toString("utf-8"); + } + return error.message; +} + +describe("setup-jetson daemon.json patcher", () => { + it("repairs the missing-comma regression and removes iptables and bridge keys", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "nemoclaw-jetson-patcher-")); + const daemonPath = path.join(tempDir, "daemon.json"); + + try { + writeFileSync( + daemonPath, + [ + "{", + ' "default-runtime": "nvidia"', + ' "runtimes": {', + ' "nvidia": {', + ' "path": "nvidia-container-runtime",', + ' "runtimeArgs": []', + " }", + " },", + ' "iptables": false,', + ' "bridge": "none"', + "}", + "", + ].join("\n"), + ); + chmodSync(daemonPath, 0o640); + + runDaemonJsonPatcher(daemonPath); + + const patched = readFileSync(daemonPath, "utf-8"); + const parsed = JSON.parse(patched) as Record; + + expect(parsed).toEqual({ + "default-runtime": "nvidia", + runtimes: { + nvidia: { + path: "nvidia-container-runtime", + runtimeArgs: [], + }, + }, + }); + expect(patched.endsWith("\n")).toBe(true); + expect(statSync(daemonPath).mode & 0o777).toBe(0o640); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("fails cleanly for unrecoverable malformed JSON without clobbering the file", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "nemoclaw-jetson-patcher-")); + const daemonPath = path.join(tempDir, "daemon.json"); + const original = '{"default-runtime": "nvidia",\n'; + + try { + writeFileSync(daemonPath, original); + + expect(() => runDaemonJsonPatcher(daemonPath)).toThrowError( + /daemon\.json is malformed and could not be repaired automatically/, + ); + expect(readFileSync(daemonPath, "utf-8")).toBe(original); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects non-object JSON roots before mutating keys", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "nemoclaw-jetson-patcher-")); + const daemonPath = path.join(tempDir, "daemon.json"); + + try { + writeFileSync(daemonPath, '["not", "an", "object"]\n'); + + let output = ""; + try { + runDaemonJsonPatcher(daemonPath); + } catch (error) { + output = getExecErrorOutput(error); + } + + expect(output).toContain("daemon.json must contain a top-level JSON object"); + expect(readFileSync(daemonPath, "utf-8")).toBe('["not", "an", "object"]\n'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("creates a new daemon.json with 0644 permissions when the file is missing", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "nemoclaw-jetson-patcher-")); + const daemonPath = path.join(tempDir, "daemon.json"); + + try { + runDaemonJsonPatcher(daemonPath); + + expect(readFileSync(daemonPath, "utf-8")).toBe("{}\n"); + expect(statSync(daemonPath).mode & 0o777).toBe(0o644); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +});