Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
69 changes: 2 additions & 67 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ on:
default: ""
type: string
jobs:
description: "Optional comma-separated E2E test IDs. Empty runs default-enabled tests only when targets is also empty; explicit-only tests openshell-gateway-auth-contract, mcp-bridge-dev, hermes-gpu-startup, sandbox-rlimits-connect, jetson-nvmap-gpu, and staging-brev-launchable are skipped unless selected."
description: "Optional comma-separated E2E test IDs. Empty runs default-enabled tests only when targets is also empty; explicit-only tests openshell-gateway-auth-contract, mcp-bridge-dev, hermes-gpu-startup, jetson-nvmap-gpu, and staging-brev-launchable are skipped unless selected."
required: false
default: ""
type: string
Expand Down Expand Up @@ -3350,70 +3350,6 @@ jobs:
shell: bash
run: bash .github/scripts/docker-auth-cleanup.sh

sandbox-rlimits-connect:
needs: generate-matrix
if: ${{ contains(format(',{0},', inputs.jobs), ',sandbox-rlimits-connect,') || contains(format(',{0},', inputs.targets), ',sandbox-rlimits-connect,') }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
E2E_JOB: "1"
E2E_DEFAULT_ENABLED: "0"
E2E_TARGET_ID: "sandbox-rlimits-connect"
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/sandbox-rlimits-connect
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
NEMOCLAW_RUN_LIVE_E2E: "1"
NEMOCLAW_E2E_CONNECT_RLIMITS: "1"
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_SANDBOX_NAME: e2e-rlimits-connect
OPENSHELL_GATEWAY: nemoclaw
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ inputs.checkout_repository || github.repository }}
ref: ${{ inputs.checkout_sha || github.sha }}
persist-credentials: false

- *dockerhub-auth

- name: Prepare E2E workspace
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75

- name: Install OpenShell
env:
NEMOCLAW_NON_INTERACTIVE: "1"
run: |
set -euo pipefail
env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u NVIDIA_INFERENCE_API_KEY -u GITHUB_TOKEN bash scripts/install-openshell.sh

- name: Run sandbox rlimit connect live test
env:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
run: |
set -euo pipefail
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
if command -v openshell >/dev/null 2>&1; then
OPENSHELL_BIN="$(command -v openshell)"
elif [ -x "$HOME/.local/bin/openshell" ]; then
OPENSHELL_BIN="$HOME/.local/bin/openshell"
else
echo "::error::OpenShell CLI not found after install"
ls -la /usr/local/bin/openshell "$HOME/.local/bin/openshell" 2>&1 || true
exit 1
fi
export OPENSHELL_BIN
"$OPENSHELL_BIN" --version
npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/sandbox-rlimits-connect.test.ts

- name: Upload sandbox rlimit connect artifacts
if: always()
uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57

- name: Clean up Docker auth
if: always()
shell: bash
run: bash .github/scripts/docker-auth-cleanup.sh

overlayfs-autofix:
needs: generate-matrix
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',overlayfs-autofix,') || contains(format(',{0},', inputs.targets), ',overlayfs-autofix,') }}
Expand Down Expand Up @@ -4613,7 +4549,7 @@ jobs:

sandbox-operations:
needs: generate-matrix
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',sandbox-operations,') || contains(format(',{0},', inputs.targets), ',sandbox-operations,') }}
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',sandbox-operations,') || contains(format(',{0},', inputs.jobs), ',sandbox-rlimits-connect,') || contains(format(',{0},', inputs.targets), ',sandbox-operations,') || contains(format(',{0},', inputs.targets), ',sandbox-rlimits-connect,') }}
runs-on: ubuntu-latest
# The live test receives 45 minutes for two onboards plus process/gateway
# recovery. The remaining 15 minutes cover checkout, build, OpenShell setup,
Expand Down Expand Up @@ -5802,7 +5738,6 @@ jobs:
rebuild-openclaw,
rebuild-hermes,
rebuild-hermes-stale-base,
sandbox-rlimits-connect,
overlayfs-autofix,
state-backup-restore,
openshell-gateway-upgrade,
Expand Down
69 changes: 69 additions & 0 deletions test/e2e/fixtures/resource-limit-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const SECURITY_RESOURCE_LIMIT_DIAGNOSTIC =
/\[SECURITY\][^\r\n]*(?:resource limits?|nproc|nofile)/iu;
const RESOURCE_LIMIT_PROBE_FIELD =
/^(?:login|interactive)_(?:(?:nproc|nofile)_(?:soft|hard)|raise_(?:nproc|nofile))=\d+$/u;

export const RESOURCE_LIMIT_CONNECT_BEGIN_MARKER = "__NEMOCLAW_RLIMIT_CONNECT_BEGIN__";
export const RESOURCE_LIMIT_CONNECT_END_MARKER = "__NEMOCLAW_RLIMIT_CONNECT_END__";

export function containsSecurityResourceLimitDiagnostic(output: string): boolean {
return SECURITY_RESOURCE_LIMIT_DIAGNOSTIC.test(output);
}

export function resourceLimitOutputFilterScript(): string {
return [
'"use strict";',
'const readline = require("node:readline");',
`const diagnostic = new RegExp(${JSON.stringify(SECURITY_RESOURCE_LIMIT_DIAGNOSTIC.source)}, ${JSON.stringify(SECURITY_RESOURCE_LIMIT_DIAGNOSTIC.flags)});`,
`const probeField = new RegExp(${JSON.stringify(RESOURCE_LIMIT_PROBE_FIELD.source)}, ${JSON.stringify(RESOURCE_LIMIT_PROBE_FIELD.flags)});`,
`const beginMarker = ${JSON.stringify(RESOURCE_LIMIT_CONNECT_BEGIN_MARKER)};`,
`const endMarker = ${JSON.stringify(RESOURCE_LIMIT_CONNECT_END_MARKER)};`,
"let diagnosticFound = false;",
"let frameComplete = false;",
"let frameOpen = false;",
"let protocolError = false;",
"const seenFields = new Set();",
"const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });",
'lines.on("line", (line) => {',
" if (diagnostic.test(line)) diagnosticFound = true;",
" if (line === beginMarker) {",
" if (frameOpen || frameComplete) protocolError = true;",
" else {",
" frameOpen = true;",
' process.stdout.write(beginMarker + "\\n");',
" }",
" return;",
" }",
" if (line === endMarker) {",
" if (!frameOpen || frameComplete) protocolError = true;",
" else {",
" frameOpen = false;",
" frameComplete = true;",
' process.stdout.write(endMarker + "\\n");',
" }",
" return;",
" }",
" if (line.includes(beginMarker) || line.includes(endMarker)) {",
" protocolError = true;",
" return;",
" }",
" if (!probeField.test(line)) return;",
" if (!frameOpen || frameComplete) {",
" protocolError = true;",
" return;",
" }",
' const key = line.slice(0, line.indexOf("="));',
" if (seenFields.has(key)) protocolError = true;",
" seenFields.add(key);",
' process.stdout.write(line + "\\n");',
"});",
'lines.on("close", () => {',
" if (frameOpen || !frameComplete) protocolError = true;",
' process.stdout.write("resource_limit_diagnostic=" + (diagnosticFound ? "1" : "0") + "\\n");',
' process.stdout.write("resource_limit_protocol_error=" + (protocolError ? "1" : "0") + "\\n");',
"});",
].join("\n");
}
88 changes: 88 additions & 0 deletions test/e2e/live/sandbox-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
assertExitZero as expectExitZero,
outputContainsSandbox,
resultText,
shellQuote,
} from "../fixtures/clients/command.ts";
import type { HostCliClient } from "../fixtures/clients/host.ts";
import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts";
Expand All @@ -26,6 +27,11 @@ import {
type HostedInferenceConfig,
requireHostedInferenceConfig,
} from "../fixtures/hosted-inference.ts";
import {
RESOURCE_LIMIT_CONNECT_BEGIN_MARKER,
RESOURCE_LIMIT_CONNECT_END_MARKER,
resourceLimitOutputFilterScript,
} from "../fixtures/resource-limit-diagnostics.ts";
import type { ShellProbeResult } from "../fixtures/shell-probe.ts";
import { ubuntuRepoDocker } from "../registry/matrix.ts";

Expand All @@ -36,6 +42,81 @@ const REGISTRY_FILE = path.join(process.env.HOME ?? os.homedir(), ".nemoclaw", "
const GATEWAY_CONTAINER = "openshell-cluster-nemoclaw";
const GATEWAY_PORT = process.env.NEMOCLAW_GATEWAY_PORT ?? "8080";

function numericProbe(text: string, key: string): number {
const prefix = `${key}=`;
const values = text
.split(/\r?\n/u)
.filter((candidate) => candidate.startsWith(prefix))
.map((candidate) => candidate.slice(prefix.length));
expect(values, `Expected exactly one ${key} in sanitized connect summary`).toHaveLength(1);
expect(values[0], `Expected a numeric ${key} in sanitized connect summary`).toMatch(/^\d+$/u);
return Number(values[0] ?? "NaN");
}

function connectRlimitProbeScript(cliPath: string): string {
const cli = JSON.stringify(cliPath);
const outputFilter = `${shellQuote(process.execPath)} -e ${shellQuote(resourceLimitOutputFilterScript())}`;
const shellProbe = [
"set +e",
'nproc_soft="$(builtin ulimit -Su)"',
'nproc_hard="$(builtin ulimit -Hu)"',
'nofile_soft="$(builtin ulimit -Sn)"',
'nofile_hard="$(builtin ulimit -Hn)"',
"(builtin ulimit -Su 5000) >/dev/null 2>&1",
'raise_nproc="$?"',
"(builtin ulimit -Sn 1048576) >/dev/null 2>&1",
'raise_nofile="$?"',
"set -e",
'printf "nproc_soft=%s\\nnproc_hard=%s\\nnofile_soft=%s\\nnofile_hard=%s\\nraise_nproc=%s\\nraise_nofile=%s\\n" "$nproc_soft" "$nproc_hard" "$nofile_soft" "$nofile_hard" "$raise_nproc" "$raise_nofile"',
].join("; ");
return [
"set -euo pipefail",
`cat <<'NEMOCLAW_CONNECT_RLIMITS' | ${cli} connect 2>&1 | ${outputFilter}`,
"set -euo pipefail",
'printf "__NEMOCLAW_RLIMIT_CONNECT_%s__\\n" BEGIN',
`bash -lc '${shellProbe}' | sed 's/^/login_/'`,
`bash -ic '${shellProbe}' 2>&1 | sed 's/^/interactive_/'`,
'printf "__NEMOCLAW_RLIMIT_CONNECT_%s__\\n" END',
"exit",
"NEMOCLAW_CONNECT_RLIMITS",
].join("\n");
}

async function assertConnectResourceLimits(host: HostCliClient): Promise<string> {
const connect = await host.command("bash", ["-lc", connectRlimitProbeScript(host.commandPath)], {
artifactName: "tc-sbx-13-connect-rlimits",
env: buildAvailabilityProbeEnv(),
timeoutMs: 3 * 60_000,
});
const summary = resultText(connect);
const exit = connect.signal
? `signal=${connect.signal}`
: `exit=${connect.exitCode ?? "unknown"}`;
expect(
connect.exitCode,
`nemoclaw connect resource-limit probe failed: ${exit}, timedOut=${String(connect.timedOut)}`,
).toBe(0);
expect(summary).toContain(RESOURCE_LIMIT_CONNECT_BEGIN_MARKER);
expect(summary).toContain(RESOURCE_LIMIT_CONNECT_END_MARKER);
expect(
numericProbe(summary, "resource_limit_diagnostic"),
"connect shell startup must not print resource-limit security diagnostics",
).toBe(0);
expect(
numericProbe(summary, "resource_limit_protocol_error"),
"connect resource-limit summary must contain exactly one complete probe frame",
).toBe(0);
for (const shell of ["login", "interactive"]) {
expect(numericProbe(summary, `${shell}_nproc_soft`)).toBeLessThanOrEqual(4096);
expect(numericProbe(summary, `${shell}_nproc_hard`)).toBeLessThanOrEqual(4096);
expect(numericProbe(summary, `${shell}_nofile_soft`)).toBeLessThanOrEqual(65536);
expect(numericProbe(summary, `${shell}_nofile_hard`)).toBeLessThanOrEqual(65536);
expect(numericProbe(summary, `${shell}_raise_nproc`)).not.toBe(0);
expect(numericProbe(summary, `${shell}_raise_nofile`)).not.toBe(0);
}
return summary;
}

async function onboardSandbox(
host: HostCliClient,
cleanup: CleanupRegistry,
Expand Down Expand Up @@ -627,6 +708,7 @@ test(
e2ePhases: [
"confirm Docker and clear the sandbox operation fixtures",
"onboard the primary sandbox",
"validate connected shell resource limits",
"exercise primary CLI inference and logs",
"exercise terminal registry and process recovery",
"onboard the secondary sandbox",
Expand Down Expand Up @@ -657,6 +739,7 @@ test(
"TC-SBX-10 two sandboxes list with model/provider metadata",
"TC-SBX-11 sandboxes cannot reach each other by hostname",
"TC-SBX-12 destroying the non-final sandbox preserves the survivor and final destroy releases the gateway port through the macOS default or explicit non-macOS cleanup",
"TC-SBX-13 bare connect routes to the default sandbox and enforces login and interactive shell resource limits without startup diagnostics (#2173)",
],
});

Expand All @@ -673,6 +756,10 @@ test(
progress.phase("onboard the primary sandbox");
await onboardSandbox(host, cleanup, SANDBOX_A, "onboard-sandbox-a", hosted);

progress.phase("validate connected shell resource limits");
const connectRlimitSummary = await assertConnectResourceLimits(host);
await artifacts.writeText("connect-rlimits-summary.txt", connectRlimitSummary);

progress.phase("exercise primary CLI inference and logs");
await expectListed(host, SANDBOX_A, "tc-sbx-01-list-sandbox-a");
await assertAgentCanAnswer(host, SANDBOX_A);
Expand Down Expand Up @@ -717,6 +804,7 @@ test(
finalDestroyCleanupMode,
finalGatewayPortReleased: true,
gatewayRecovery,
connectRlimitsValidated: true,
legacySource: "test/e2e/test-sandbox-operations.sh",
});
},
Expand Down
Loading
Loading