diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index c479d912f8f..e699d66defa 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -12,7 +12,7 @@ on: default: "" type: string jobs: - description: "Optional comma-separated free-standing live Vitest job ids. Empty runs default-enabled jobs only when scenarios is also empty; explicit-only jobs such as jetson-nvmap-gpu-vitest are skipped unless selected." + description: "Optional comma-separated free-standing live Vitest job ids. Empty runs default-enabled jobs only when scenarios is also empty; explicit-only jobs such as jetson-nvmap-gpu-vitest and sandbox-rlimits-connect-vitest are skipped unless selected." required: false default: "" type: string @@ -2399,6 +2399,103 @@ jobs: if-no-files-found: ignore retention-days: 14 + sandbox-rlimits-connect-vitest: + needs: generate-matrix + if: ${{ contains(format(',{0},', inputs.jobs), ',sandbox-rlimits-connect-vitest,') || contains(format(',{0},', inputs.scenarios), ',sandbox-rlimits-connect,') }} + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + FREE_STANDING_VITEST_JOB: "1" + FREE_STANDING_SCENARIO_ID: "sandbox-rlimits-connect" + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/sandbox-rlimits-connect + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_RUN_E2E_SCENARIOS: "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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Authenticate to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::notice::Docker Hub credentials not configured; continuing with anonymous pulls." + exit 0 + fi + login_succeeded=0 + for attempt in 1 2 3; do + if echo "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then + login_succeeded=1 + break + fi + if [[ "$attempt" -lt 3 ]]; then + echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." + sleep 5 + fi + done + if [[ "$login_succeeded" -ne 1 ]]; then + echo "::warning::Docker Hub login failed after 3 attempts; continuing with anonymous pulls." + fi + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22 + cache: npm + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Build CLI + run: npm run build:cli + + - 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 vitest run --project e2e-scenarios-live \ + test/e2e-scenario/live/sandbox-rlimits-connect.test.ts \ + --silent=false --reporter=default + + - name: Upload sandbox rlimit connect artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-vitest-scenarios-sandbox-rlimits-connect + path: e2e-artifacts/vitest/sandbox-rlimits-connect/ + include-hidden-files: false + if-no-files-found: ignore + retention-days: 14 + overlayfs-autofix-vitest: needs: generate-matrix if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',overlayfs-autofix-vitest,') || contains(format(',{0},', inputs.scenarios), ',overlayfs-autofix,') }} @@ -5518,6 +5615,7 @@ jobs: rebuild-hermes-vitest, rebuild-hermes-stale-base-vitest, sandbox-rebuild-vitest, + sandbox-rlimits-connect-vitest, overlayfs-autofix-vitest, state-backup-restore-vitest, upgrade-stale-sandbox-vitest, @@ -5584,6 +5682,11 @@ jobs: scenario: 'jetson-nvmap-gpu', reason: 'default dispatch excludes Jetson until a stable Jetson runner is available', }, + { + job: 'sandbox-rlimits-connect-vitest', + scenario: 'sandbox-rlimits-connect', + reason: 'default dispatch excludes the destructive rlimit fork/connect probe unless selected', + }, ]; const scenariosRejected = rawRequestedScenarios && !selectorValidationPassed; const jobsRejected = rawRequestedJobs && !selectorValidationPassed; @@ -5669,7 +5772,7 @@ jobs: ? '**Requested jobs:** _(selector rejected by workflow validation)_' : requestedJobs ? `**Requested jobs:** \`${requestedJobs}\`` - : '**Requested jobs:** _(default — all default-enabled free-standing jobs; explicit-only jobs such as `jetson-nvmap-gpu-vitest` are skipped unless selected)_', + : '**Requested jobs:** _(default — all default-enabled free-standing jobs; explicit-only jobs such as `jetson-nvmap-gpu-vitest` and `sandbox-rlimits-connect-vitest` are skipped unless selected)_', `**Summary:** ${passed.length} passed, ${failed.length} failed, ${cancelled.length} cancelled, ${skipped.length} skipped`, '', '| Job | Result |', diff --git a/Dockerfile b/Dockerfile index cbf0c94de54..7b25ff2e2f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -952,7 +952,7 @@ RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ && if ! grep -q "sandbox-rlimits.sh" /etc/profile.d/nemoclaw-rlimits.sh 2>/dev/null; then \ printf '%s\n' \ '# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \ - '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \ + '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true' \ > /etc/profile.d/nemoclaw-rlimits.sh \ && chmod 444 /etc/profile.d/nemoclaw-rlimits.sh; \ fi \ @@ -969,7 +969,7 @@ RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ '' \ '# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \ - '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \ + '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true' \ ''; \ grep -Ev 'NemoClaw runtime proxy config|nemoclaw-proxy-env[.]sh|NemoClaw sandbox resource limits|sandbox-rlimits[.]sh' /etc/bash.bashrc || true; \ } > /etc/bash.bashrc.new \ diff --git a/Dockerfile.base b/Dockerfile.base index c17659cbad5..fca3d394825 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -166,7 +166,7 @@ RUN printf '%s\n' \ RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ && printf '%s\n' \ '# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \ - '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \ + '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true' \ > /etc/profile.d/nemoclaw-rlimits.sh \ && chmod 444 /etc/profile.d/nemoclaw-rlimits.sh \ && printf '%s\n' \ @@ -179,7 +179,7 @@ RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ '' \ '# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \ - '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \ + '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true' \ ''; \ cat /etc/bash.bashrc; \ } > /etc/bash.bashrc.new \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 74fb020780b..fc72b9592b3 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -126,13 +126,13 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init && mkdir -p /etc/profile.d \ && printf '%s\n' \ '# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \ - '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \ + '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true' \ > /etc/profile.d/nemoclaw-rlimits.sh \ && chmod 444 /etc/profile.d/nemoclaw-rlimits.sh \ && (chmod 644 /etc/bash.bashrc 2>/dev/null || true) \ && { printf '%s\n' \ '# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)' \ - '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits' \ + '[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true' \ ''; \ if [ -f /etc/bash.bashrc ]; then \ grep -Ev 'NemoClaw sandbox resource limits|sandbox-rlimits[.]sh' /etc/bash.bashrc || true; \ diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 7dfe4165312..4d9cce4dd6e 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -69,7 +69,10 @@ const getMessagingInputValue = (input: ChannelInputSpec): string | null => { */ export function detectMessagingChannelsFromEnv(agent: AgentDefinition | null = null): string[] { const manifestRegistry = createBuiltInChannelManifestRegistry(); - const availabilityContext = getMessagingManifestAvailabilityContext(agent); + const availabilityContext = getMessagingManifestAvailabilityContext( + agent, + manifestRegistry.list(), + ); const availableChannels = manifestRegistry.listAvailable(availabilityContext); return availableChannels .filter((manifest) => hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue)) diff --git a/test/e2e-scenario/live/sandbox-rlimits-connect.test.ts b/test/e2e-scenario/live/sandbox-rlimits-connect.test.ts index df785a2bb05..293fc4dea1a 100644 --- a/test/e2e-scenario/live/sandbox-rlimits-connect.test.ts +++ b/test/e2e-scenario/live/sandbox-rlimits-connect.test.ts @@ -26,6 +26,12 @@ function numericProbe(text: string, key: string): number { return Number(match?.[1] ?? "NaN"); } +function expectNoRlimitStartupDiagnostics(text: string): void { + expect(text, "connect shell startup must not print rlimit security diagnostics").not.toMatch( + /\[SECURITY\].*(?:Effective|Could not set).*(?:nproc|nofile).*limit/iu, + ); +} + function connectAcceptanceScript(cliPath: string, sandboxName: string): string { const cli = JSON.stringify(cliPath); const sandbox = JSON.stringify(sandboxName); @@ -66,6 +72,7 @@ runConnectRlimitTest( "bash -lc 'ulimit -u; ulimit -n'", "bash -ic 'ulimit -u; ulimit -n'", "ulimit -a", + "shell startup does not emit [SECURITY] rlimit diagnostics before user commands", "for i in $(seq 1 5000); do sleep 60 & done 2>&1 | tail -5", ], hermesCoverage: @@ -127,6 +134,7 @@ runConnectRlimitTest( expect(connect.exitCode, output).toBe(0); expect(output).toContain("__NEMOCLAW_RLIMIT_CONNECT_BEGIN__"); expect(output).toContain("__NEMOCLAW_RLIMIT_CONNECT_END__"); + expectNoRlimitStartupDiagnostics(output); expect(numericProbe(output, "login_nproc")).toBeLessThanOrEqual(4096); expect(numericProbe(output, "login_nofile")).toBeLessThanOrEqual(65536); diff --git a/test/e2e-scenario/support-tests/rlimit-connect-workflow-boundary.test.ts b/test/e2e-scenario/support-tests/rlimit-connect-workflow-boundary.test.ts new file mode 100644 index 00000000000..ae8be58dd91 --- /dev/null +++ b/test/e2e-scenario/support-tests/rlimit-connect-workflow-boundary.test.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + evaluateE2eVitestWorkflowDispatchSelectors, + readFreeStandingJobsInventory, +} from "../../../tools/e2e-scenarios/workflow-boundary.mts"; + +describe("rlimit connect workflow boundary", () => { + it("maps the rlimit connect acceptance selector to its explicit Vitest job", () => { + const inventory = readFreeStandingJobsInventory(); + expect(inventory.allowedJobs).toContain("sandbox-rlimits-connect-vitest"); + expect(inventory.scenarioToJob.get("sandbox-rlimits-connect")).toBe( + "sandbox-rlimits-connect-vitest", + ); + + expect( + evaluateE2eVitestWorkflowDispatchSelectors({ scenarios: "sandbox-rlimits-connect" }), + ).toMatchObject({ + valid: true, + liveScenariosRuns: false, + selectedFreeStandingJobs: ["sandbox-rlimits-connect-vitest"], + registryScenarios: [], + }); + expect( + evaluateE2eVitestWorkflowDispatchSelectors({ jobs: "sandbox-rlimits-connect-vitest" }), + ).toMatchObject({ + valid: true, + liveScenariosRuns: false, + selectedFreeStandingJobs: ["sandbox-rlimits-connect-vitest"], + registryScenarios: [], + }); + expect( + evaluateE2eVitestWorkflowDispatchSelectors({ jobs: "", scenarios: "" }) + .selectedFreeStandingJobs, + ).not.toContain("sandbox-rlimits-connect-vitest"); + }); +}); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 9044ecdedde..be21d9d7dda 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -902,7 +902,7 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const rlimitLib = path.join(tmp, "sandbox-rlimits.sh"); const bashrc = path.join(tmp, "bash.bashrc"); const runtimeEnvShim = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh"; - const rlimitShim = `[ -f ${rlimitLib} ] && . ${rlimitLib} && harden_resource_limits --quiet && verify_resource_limits`; + const rlimitShim = `[ -f ${rlimitLib} ] && . ${rlimitLib} && harden_resource_limits --quiet && verify_resource_limits --quiet || true`; try { fs.mkdirSync(path.dirname(profileHook), { recursive: true }); @@ -946,7 +946,7 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const rlimitLib = path.join(tmp, "sandbox-rlimits.sh"); const bashrc = path.join(tmp, "bash.bashrc"); const runtimeEnvShim = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh"; - const rlimitShim = `[ -f ${rlimitLib} ] && . ${rlimitLib} && harden_resource_limits --quiet && verify_resource_limits`; + const rlimitShim = `[ -f ${rlimitLib} ] && . ${rlimitLib} && harden_resource_limits --quiet && verify_resource_limits --quiet || true`; try { fs.mkdirSync(path.dirname(profileHook), { recursive: true }); diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index 96e2d2e6228..a238eb169ed 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -73,7 +73,7 @@ function copyRlimitFixtureWithNprocLimit(rlimitLib: string, limit: number): void } function rlimitShim(rlimitLib: string): string { - return `[ -f ${rlimitLib} ] && . ${rlimitLib} && harden_resource_limits --quiet && verify_resource_limits`; + return `[ -f ${rlimitLib} ] && . ${rlimitLib} && harden_resource_limits --quiet && verify_resource_limits --quiet || true`; } type ProbeValues = Record; @@ -167,6 +167,36 @@ function expectSystemRlimitHookBypassesShadowedUlimit(hookPath: string): void { expect(Number(values.nofile)).toBeLessThanOrEqual(65536); } +function expectSystemRlimitHookIsSilentWhenVerificationFails( + hookPath: string, + rlimitLib: string, +): void { + fs.chmodSync(rlimitLib, 0o644); + fs.writeFileSync( + rlimitLib, + [ + "harden_resource_limits() { :; }", + "verify_resource_limits() {", + ' if [ "${1:-}" != "--quiet" ]; then', + ' echo "[SECURITY] noisy verification failure" >&2', + " fi", + " return 1", + "}", + ].join("\n"), + ); + const probe = ["set -euo pipefail", `source ${JSON.stringify(hookPath)}`, 'printf "OK\\n"'].join( + "\n", + ); + const result = spawnSync("bash", ["--noprofile", "--norc", "-c", probe], { + encoding: "utf-8", + timeout: 5000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("OK\n"); + expect(result.stderr).toBe(""); +} + function expectRlimitLibIsPosixShSafe(rlimitLib: string): void { const probe = [ "set -e", @@ -388,6 +418,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { expectSystemRlimitHookEnforcesLimits(rlimitHook); expectSystemRlimitHookEnforcesLimits(bashrc); expectSystemRlimitHookBypassesShadowedUlimit(rlimitHook); + expectSystemRlimitHookIsSilentWhenVerificationFails(rlimitHook, rlimitLib); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -447,7 +478,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { "# NemoClaw runtime proxy config — see /tmp/nemoclaw-proxy-env.sh (#2704)", "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh", "# NemoClaw sandbox resource limits — see sandbox-rlimits.sh (#2173)", - "[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits", + "[ -f /usr/local/lib/nemoclaw/sandbox-rlimits.sh ] && . /usr/local/lib/nemoclaw/sandbox-rlimits.sh && harden_resource_limits --quiet && verify_resource_limits --quiet || true", ].join("\n"), ); const command = dockerRunCommandBetween( @@ -467,6 +498,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(occurrenceCount(bashrcBody, expectedRlimitShim)).toBe(1); expectSystemRlimitHookEnforcesLimits(rlimitHook); expectSystemRlimitHookEnforcesLimits(bashrc); + expectSystemRlimitHookIsSilentWhenVerificationFails(bashrc, rlimitLib); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -517,6 +549,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(fs.readFileSync(bashrc, "utf-8")).toContain(expectedRlimitShim); expectSystemRlimitHookEnforcesLimits(profileHook); expectSystemRlimitHookEnforcesLimits(bashrc); + expectSystemRlimitHookIsSilentWhenVerificationFails(bashrc, rlimitLib); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/tools/e2e-scenarios/workflow-boundary.mts b/tools/e2e-scenarios/workflow-boundary.mts index f55bece8618..08dfe55aeb2 100644 --- a/tools/e2e-scenarios/workflow-boundary.mts +++ b/tools/e2e-scenarios/workflow-boundary.mts @@ -51,8 +51,12 @@ const FREE_STANDING_SELECTOR_SPECIAL_CASES = new Set([ "hermes-e2e-vitest", "hermes-root-entrypoint-smoke-vitest", "jetson-nvmap-gpu-vitest", + "sandbox-rlimits-connect-vitest", +]); +const FULL_SUITE_EXCLUDED_FREE_STANDING_JOBS = new Set([ + "jetson-nvmap-gpu-vitest", + "sandbox-rlimits-connect-vitest", ]); -const FULL_SUITE_EXCLUDED_FREE_STANDING_JOBS = new Set(["jetson-nvmap-gpu-vitest"]); function asRecord(value: unknown): WorkflowRecord { return value && typeof value === "object" && !Array.isArray(value) @@ -7797,6 +7801,50 @@ export function validateE2eVitestScenariosWorkflowBoundary( if (jetsonJob.if !== explicitOnlyFreeStandingJobIf("jetson-nvmap-gpu-vitest", "jetson-nvmap-gpu")) { errors.push("jetson-nvmap-gpu-vitest job must run only when explicitly selected"); } + + const sandboxRlimitConnectJob = asRecord(jobs["sandbox-rlimits-connect-vitest"]); + if (sandboxRlimitConnectJob.needs !== "generate-matrix") { + errors.push("sandbox-rlimits-connect-vitest job must depend on generate-matrix"); + } + if ( + sandboxRlimitConnectJob.if !== + explicitOnlyFreeStandingJobIf( + "sandbox-rlimits-connect-vitest", + "sandbox-rlimits-connect", + ) + ) { + errors.push("sandbox-rlimits-connect-vitest job must run only when explicitly selected"); + } + const sandboxRlimitConnectEnv = asRecord(sandboxRlimitConnectJob.env); + if (sandboxRlimitConnectEnv.NEMOCLAW_RUN_E2E_SCENARIOS !== "1") { + errors.push("sandbox-rlimits-connect-vitest job must set NEMOCLAW_RUN_E2E_SCENARIOS=1"); + } + if (sandboxRlimitConnectEnv.NEMOCLAW_E2E_CONNECT_RLIMITS !== "1") { + errors.push("sandbox-rlimits-connect-vitest job must opt in with NEMOCLAW_E2E_CONNECT_RLIMITS=1"); + } + if ( + sandboxRlimitConnectEnv.E2E_ARTIFACT_DIR !== + "${{ github.workspace }}/e2e-artifacts/vitest/sandbox-rlimits-connect" + ) { + errors.push("sandbox-rlimits-connect-vitest job must write artifacts under e2e-artifacts/vitest/sandbox-rlimits-connect"); + } + const sandboxRlimitConnectSteps = asSteps(sandboxRlimitConnectJob.steps); + const sandboxRlimitConnectRun = namedStep( + sandboxRlimitConnectSteps, + "Run sandbox rlimit connect live test", + ); + if (!sandboxRlimitConnectRun) { + errors.push("sandbox-rlimits-connect-vitest job missing step: Run sandbox rlimit connect live test"); + } else { + const runScript = stringValue(sandboxRlimitConnectRun.run); + if (!runScript.includes("test/e2e-scenario/live/sandbox-rlimits-connect.test.ts")) { + errors.push("sandbox-rlimits-connect-vitest job must run sandbox-rlimits-connect.test.ts"); + } + if (asRecord(sandboxRlimitConnectRun.env).NVIDIA_API_KEY !== "${{ secrets.NVIDIA_API_KEY }}") { + errors.push("sandbox-rlimits-connect-vitest step must receive NVIDIA_API_KEY from secrets"); + } + } + validateFreeStandingJobSelector( errors, jobs, @@ -7924,6 +7972,16 @@ export function validateE2eVitestScenariosWorkflowBoundary( "step 'Post Vitest scenario results to PR' run script must document the explicit Jetson scenario selector", ); } + if (!reportScript.includes("sandbox-rlimits-connect-vitest")) { + errors.push( + "step 'Post Vitest scenario results to PR' run script must document the explicit rlimit jobs selector", + ); + } + if (!reportScript.includes("sandbox-rlimits-connect")) { + errors.push( + "step 'Post Vitest scenario results to PR' run script must document the explicit rlimit scenario selector", + ); + } for (const forbidden of [ "toJSON(inputs.pr_number)", "toJSON(inputs.scenarios)",