Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,29 @@ reviews:
- path: "src/commands/sandbox/agents/**"
instructions: *e2e-sessions-agents-cli

- path: "test/e2e/test-jetson-nvmap-gpu.sh"
instructions: &e2e-jetson-nvmap |
This script is the reporter-workflow E2E for #4231: on a Jetson Orin
host it onboards with GPU, inspects the sandbox user's groups and
/dev/nvmap, runs the cuInit(0) CUDA proof inside the sandbox, and
asserts `nemoclaw status` reports `(CUDA verified)`. It validates that
the Jetson Docker GPU recreate grants the sandbox user the host group
owning /dev/nvmap so CUDA can initialize. Invisible to unit tests
because it needs real Tegra device nodes and a live sandbox.

**E2E test recommendation:**
- `gpu-jetson-nvmap-e2e` — Jetson /dev/nvmap CUDA usability + status
proof (Jetson-gated; needs a Jetson/Tegra GPU runner with
`vars.JETSON_E2E_ENABLED=true`)

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=gpu-jetson-nvmap-e2e
```

- path: "src/lib/onboard/docker-gpu-patch.ts"
instructions: *e2e-jetson-nvmap

- path: ".github/workflows/nightly-e2e.yaml"
instructions: |
This is the nightly E2E workflow definition. Changes here affect
Expand Down
68 changes: 67 additions & 1 deletion .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@
# launchable-smoke-e2e Community install path (brev-launchable-ci-cpu.sh) on ubuntu-latest.
# gpu-e2e Local Ollama inference on an NVKS ephemeral GPU runner.
# gpu-double-onboard-e2e Ollama proxy token consistency after re-onboard (#2553).
# gpu-jetson-nvmap-e2e Jetson Orin /dev/nvmap CUDA usability + status proof (#4231).
# Gated behind vars.JETSON_E2E_ENABLED; needs a Jetson runner.
# concurrent-gateway-ports-e2e
# Two sandboxes coexisting on the same host with distinct
# NEMOCLAW_GATEWAY_PORT values; verifies per-instance
Expand Down Expand Up @@ -143,7 +145,7 @@ on:
onboard-resume-e2e, onboard-negative-paths-e2e, runtime-overrides-e2e,
credential-sanitization-e2e, telegram-injection-e2e, overlayfs-autofix-e2e,
device-auth-health-e2e, launchable-smoke-e2e, gpu-e2e, gpu-double-onboard-e2e,
concurrent-gateway-ports-e2e
gpu-jetson-nvmap-e2e, concurrent-gateway-ports-e2e
required: false
type: string
default: ""
Expand Down Expand Up @@ -2107,6 +2109,67 @@ jobs:
path: /tmp/nemoclaw-gpu-double-onboard-test.log
if-no-files-found: ignore

# ── Jetson nvmap GPU status E2E (#4231) ──────────────────────
# Reproduces the reporter's exact Jetson Orin workflow: onboard with GPU,
# then prove the sandbox user can open /dev/nvmap (CUDA cuInit(0)=0) and that
# `nemoclaw status` reports proven CUDA usability instead of a misleading
# bare "enabled". Requires a Jetson/Tegra (arm64 L4T) GPU runner, which the
# project does not yet host — so this job is gated behind
# `vars.JETSON_E2E_ENABLED` (unset by default → skipped) and an explicit
# runner label. When a Jetson runner is provisioned, set the variable and
# point `runs-on` at its label. The same fix has deterministic, hardware-free
# regression coverage in src/lib/onboard/docker-gpu-patch.test.ts.
gpu-jetson-nvmap-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
vars.JETSON_E2E_ENABLED == 'true' &&
(github.event_name != 'workflow_dispatch' ||
inputs.jobs == '' ||
contains(format(',{0},', inputs.jobs), ',gpu-jetson-nvmap-e2e,'))
runs-on: ${{ vars.JETSON_E2E_RUNNER_LABEL || 'linux-arm64-gpu-jetson-orin-latest-1' }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
timeout-minutes: 40
env:
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-jetson-nvmap"
NEMOCLAW_RECREATE_SANDBOX: "1"
NEMOCLAW_PROVIDER: "ollama"
steps:
- *target-ref-checkout

- *dockerhub-auth-step

- name: Verify Jetson GPU availability
run: |
echo "=== Tegra release ==="
cat /etc/nv_tegra_release 2>/dev/null || echo "(no /etc/nv_tegra_release)"
echo ""
echo "=== /dev/nvmap ==="
ls -l /dev/nvmap 2>/dev/null || echo "(no /dev/nvmap)"
echo ""
echo "=== Docker ==="
docker info --format '{{.ServerVersion}}'
docker info --format '{{json .Runtimes}}'

- name: Run Jetson nvmap GPU E2E test (#4231)
run: bash test/e2e/test-jetson-nvmap-gpu.sh

- name: Upload install log on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gpu-jetson-nvmap-install-log
path: /tmp/nemoclaw-jetson-nvmap-e2e-install.log
if-no-files-found: ignore

- name: Upload test log on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gpu-jetson-nvmap-test-log
path: /tmp/nemoclaw-jetson-nvmap-e2e-test.log
if-no-files-found: ignore

concurrent-gateway-ports-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
Expand Down Expand Up @@ -2228,6 +2291,7 @@ jobs:
launchable-smoke-e2e,
gpu-e2e,
gpu-double-onboard-e2e,
gpu-jetson-nvmap-e2e,
concurrent-gateway-ports-e2e,
]
if: ${{ always() && github.event_name == 'schedule' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
Expand Down Expand Up @@ -2344,6 +2408,7 @@ jobs:
launchable-smoke-e2e,
gpu-e2e,
gpu-double-onboard-e2e,
gpu-jetson-nvmap-e2e,
concurrent-gateway-ports-e2e,
]
if: ${{ always() && github.event_name == 'workflow_dispatch' }}
Expand Down Expand Up @@ -2519,6 +2584,7 @@ jobs:
launchable-smoke-e2e,
gpu-e2e,
gpu-double-onboard-e2e,
gpu-jetson-nvmap-e2e,
concurrent-gateway-ports-e2e,
]
if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
Expand Down
118 changes: 118 additions & 0 deletions src/lib/onboard/docker-gpu-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
collectDockerGpuPatchDiagnostics,
type DockerContainerInspect,
detectSandboxFallbackDns,
detectTegraDeviceGroupGids,
dockerReportsNvidiaCdiDevices,
formatDockerInspectNetworkSummary,
getDockerGpuPatchNetworkMode,
Expand Down Expand Up @@ -800,6 +801,123 @@ describe("docker-gpu-patch sandbox DNS fallback (#3579)", () => {
});
});

// Jetson `/dev/nvmap` group-permission propagation (#4231). The reporter's
// Jetson Orin sandbox saw the GPU devices mounted but CUDA failed with
// `NvRmMemInitNvmap ... Permission denied` / `cuInit(0)=999` because the
// sandbox user (uid/gid 998) was not in the `video` group that owns
// `/dev/nvmap` (`crw-rw---- root video`). The Jetson recreate must grant that
// group via `--group-add` so CUDA can initialize.
describe("Jetson /dev/nvmap group propagation (#4231)", () => {
it("returns the owning GID(s) of present Tegra device nodes, skipping missing and root-owned", () => {
const deviceGids: Record<string, number> = {
"/dev/nvmap": 44, // root video
"/dev/nvhost-ctrl": 44,
"/dev/nvhost-gpu": 0, // root root — skipped (root already has access)
"/dev/nvgpu/igpu0/ctrl": 110, // render
// every other Tegra node is absent on this host
};
const gids = detectTegraDeviceGroupGids({
statDeviceGid: (p: string) => (p in deviceGids ? deviceGids[p] : null),
});
// Deduped, sorted numerically, root (0) and missing nodes excluded.
expect(gids).toEqual(["44", "110"]);
});

it("returns no GIDs when no Tegra device nodes are present (non-Jetson host)", () => {
expect(detectTegraDeviceGroupGids({ statDeviceGid: () => null })).toEqual([]);
});

it("emits --group-add for extraGroupGids and dedupes against existing GroupAdd", () => {
const inspect = inspectFixture();
inspect.HostConfig!.GroupAdd = ["44"]; // baseline already carries video
const args = buildDockerGpuCloneRunArgs(
inspect,
buildDockerGpuMode("nvidia-runtime", null, { backend: "jetson" }),
{ extraGroupGids: ["44", "110"] },
);
// `44` is added exactly once (baseline + extra deduped); `110` added.
expect(args.filter((arg, i) => args[i - 1] === "--group-add" && arg === "44").length).toBe(1);
expect(args).toEqual(expect.arrayContaining(["--group-add", "110"]));
});

it("does not add --group-add when extraGroupGids is absent", () => {
const inspect = inspectFixture();
inspect.HostConfig!.GroupAdd = [];
const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("gpus"));
expect(args).not.toEqual(expect.arrayContaining(["--group-add"]));
});

it("plumbs detected Tegra device GIDs into the Jetson recreate as --group-add", () => {
const dockerCapture = vi.fn((args: readonly string[]) => {
if (args[0] === "ps") return "old-container-id\n";
if (args[0] === "inspect") return JSON.stringify([inspectFixture()]);
if (args[0] === "info") return "";
return "";
});
const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" }));
const detectTegraDeviceGroupGidsStub = vi.fn(() => ["44"]);

recreateOpenShellDockerSandboxWithGpu(
{ sandboxName: "alpha", timeoutSecs: 1, backend: "jetson" },
{
dockerCapture,
dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })),
dockerRunDetached,
dockerRename: vi.fn(() => ({ status: 0 })),
dockerStop: vi.fn(() => ({ status: 0 })),
dockerRm: vi.fn(() => ({ status: 0 })),
runOpenshell: vi.fn(() => ({ status: 0 })),
sleep: vi.fn(),
now: () => new Date("2026-05-15T00:00:00Z"),
detectSandboxFallbackDns: () => null,
detectTegraDeviceGroupGids: detectTegraDeviceGroupGidsStub,
},
);

expect(detectTegraDeviceGroupGidsStub).toHaveBeenCalled();
expect(dockerRunDetached).toHaveBeenCalledWith(
expect.arrayContaining(["--group-add", "44"]),
expect.objectContaining({ ignoreError: true }),
);
});

it("does not add Tegra device GIDs for the generic (non-Jetson) backend", () => {
const dockerCapture = vi.fn((args: readonly string[]) => {
if (args[0] === "ps") return "old-container-id\n";
if (args[0] === "inspect") return JSON.stringify([inspectFixture()]);
if (args[0] === "info") return "";
return "";
});
const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" }));
const detectTegraDeviceGroupGidsStub = vi.fn(() => ["44"]);

recreateOpenShellDockerSandboxWithGpu(
{ sandboxName: "alpha", timeoutSecs: 1, backend: "generic" },
{
dockerCapture,
dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })),
dockerRunDetached,
dockerRename: vi.fn(() => ({ status: 0 })),
dockerStop: vi.fn(() => ({ status: 0 })),
dockerRm: vi.fn(() => ({ status: 0 })),
runOpenshell: vi.fn(() => ({ status: 0 })),
sleep: vi.fn(),
now: () => new Date("2026-05-15T00:00:00Z"),
detectSandboxFallbackDns: () => null,
detectTegraDeviceGroupGids: detectTegraDeviceGroupGidsStub,
},
);

// Generic backend never queries Tegra device groups and never emits the
// extra --group-add (inspectFixture has no baseline GroupAdd).
expect(detectTegraDeviceGroupGidsStub).not.toHaveBeenCalled();
expect(dockerRunDetached).not.toHaveBeenCalledWith(
expect.arrayContaining(["--group-add", "44"]),
expect.anything(),
);
});
});

// Regression coverage for NemoClaw issue #4316: the Docker GPU patch path
// must distinguish "sandbox never became executable" (Error phase / dead
// container) from "GPU proof failed inside an executable sandbox", and the
Expand Down
Loading
Loading