Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
104 changes: 104 additions & 0 deletions .github/workflows/e2e-vitest-scenarios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4147,6 +4147,109 @@ jobs:
docker logout docker.io || true
rm -rf "${DOCKER_CONFIG}"

sandbox-operations-vitest:
needs: generate-matrix
if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',sandbox-operations-vitest,') || contains(format(',{0},', inputs.scenarios), ',sandbox-operations,') }}
runs-on: ubuntu-latest
# The test onboards two sandboxes, exercises process and gateway recovery,
# and tears both down; its live-test budget is 45 minutes.
timeout-minutes: 60
env:
FREE_STANDING_VITEST_JOB: "1"
FREE_STANDING_SCENARIO_ID: "sandbox-operations"
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/sandbox-operations
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
NEMOCLAW_RUN_E2E_SCENARIOS: "1"
NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1"
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_POLICY_TIER: "open"
OPENSHELL_GATEWAY: "nemoclaw"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- 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 CLI
run: |
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: Configure isolated Docker auth directory
run: echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config-sandbox-operations" >> "$GITHUB_ENV"

- 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
mkdir -p "${DOCKER_CONFIG}"
chmod 700 "${DOCKER_CONFIG}"
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: Run sandbox operations live test
env:
NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
run: |
set -euo pipefail
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
npx vitest run --project e2e-scenarios-live \
test/e2e-scenario/live/sandbox-operations.test.ts \
--silent=false --reporter=default

- name: Upload sandbox operations artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-vitest-scenarios-sandbox-operations
path: e2e-artifacts/vitest/sandbox-operations/
include-hidden-files: false
if-no-files-found: ignore
retention-days: 14

- name: Clean up Docker auth
if: always()
run: |
set -euo pipefail
docker logout docker.io || true
rm -rf "${DOCKER_CONFIG}"

sandbox-survival-vitest:
needs: generate-matrix
if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',sandbox-survival-vitest,') || contains(format(',{0},', inputs.scenarios), ',sandbox-survival,') }}
Expand Down Expand Up @@ -5760,6 +5863,7 @@ jobs:
issue-4462-scope-upgrade-approval-vitest,
onboard-resume-vitest,
model-router-provider-routed-inference-vitest,
sandbox-operations-vitest,
sandbox-survival-vitest,
diagnostics-vitest,
snapshot-commands-vitest,
Expand Down
41 changes: 11 additions & 30 deletions test/e2e-scenario/live/sandbox-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";
import type { HostCliClient } from "../fixtures/clients/host.ts";
import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts";
import { expect, test } from "../fixtures/e2e-test.ts";
import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts";
import type { ShellProbeResult } from "../fixtures/shell-probe.ts";
import { ubuntuRepoDocker } from "../scenarios/matrix.ts";

Expand All @@ -35,10 +36,6 @@ function resultText(result: ProcessResult): string {
return [result.stdout, result.stderr].filter(Boolean).join("\n");
}

function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}

function outputContainsSandbox(result: ProcessResult, sandboxName: string): boolean {
const escaped = sandboxName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|\\s)${escaped}(\\s|$)`, "m").test(resultText(result));
Expand Down Expand Up @@ -100,6 +97,7 @@ async function onboardSandbox(
cleanup: CleanupRegistry,
sandboxName: string,
artifactName: string,
providerEnv: NodeJS.ProcessEnv,
extraEnv: NodeJS.ProcessEnv = {},
): Promise<ShellProbeResult> {
cleanup.add(`destroy sandbox ${sandboxName}`, () => cleanupSandbox(host, sandboxName));
Expand All @@ -109,14 +107,16 @@ async function onboardSandbox(
artifactName,
env: {
...buildAvailabilityProbeEnv(),
...providerEnv,
...extraEnv,
NEMOCLAW_AGENT: "openclaw",
NEMOCLAW_PROVIDER: "cloud",
NEMOCLAW_SANDBOX_NAME: sandboxName,
NEMOCLAW_RECREATE_SANDBOX: "1",
NVIDIA_INFERENCE_API_KEY: process.env.NVIDIA_INFERENCE_API_KEY ?? "",
},
redactionValues: [process.env.NVIDIA_INFERENCE_API_KEY ?? ""],
redactionValues: [
providerEnv.NVIDIA_INFERENCE_API_KEY ?? "",
providerEnv.COMPATIBLE_API_KEY ?? "",
],
timeoutMs: 20 * 60_000,
},
);
Expand Down Expand Up @@ -295,25 +295,6 @@ async function assertAgentJsonTransportBoundaries(
expect(invalidFlag.exitCode, resultText(invalidFlag)).not.toBeNull();
expect(invalidFlag.exitCode, resultText(invalidFlag)).not.toBe(0);

const stdinPrompt = "What is 6 multiplied by 7? Reply with only the integer, no extra words.";
const stdinSessionId = `e2e-sbx-02b-stdin-${Date.now()}-${process.pid}`;
const stdinScript = [
"set -euo pipefail",
`printf '%s\\n' ${shellQuote(stdinPrompt)} | ${shellQuote(host.commandPath)} ${shellQuote(
sandboxName,
)} agent --agent main --json --session-id ${shellQuote(stdinSessionId)}`,
].join("\n");
const stdinResult = await host.command("bash", ["-lc", stdinScript], {
artifactName: "tc-sbx-02b-agent-json-stdin",
env: buildAvailabilityProbeEnv(),
timeoutMs: 120_000,
});
expectExitZero(stdinResult, `printf prompt | nemoclaw ${sandboxName} agent --json`);
expectJsonStdout(stdinResult, "stdin agent --json");
expect(parseOpenClawAgentText(stdinResult.stdout), resultText(stdinResult)).toMatch(
/(^|[^0-9])42([^0-9]|$)/,
);

const provenanceMarker = `NEMOCLAW_PROVENANCE_E2E_${Date.now()}_${process.pid}`;
const failureSessionId = `e2e-sbx-02b-failure-${Date.now()}-${process.pid}`;
const failure = await host.nemoclaw(
Expand Down Expand Up @@ -601,7 +582,7 @@ async function assertGatewayRecovery(host: HostCliClient, sandboxName: string):
liveTest(
"sandbox operations preserve list/status/logs/recovery/multi-sandbox contracts",
async ({ artifacts, cleanup, environment, host, sandbox, secrets, skip }) => {
secrets.required("NVIDIA_INFERENCE_API_KEY");
const hosted = requireHostedInferenceConfig(secrets);

await artifacts.writeJson("scenario.json", {
id: "sandbox-operations",
Expand All @@ -611,7 +592,7 @@ liveTest(
contracts: [
"TC-SBX-01 list shows onboarded sandbox",
"TC-SBX-02 nemoclaw <sandbox> agent --json answers through sandbox inference.local",
"TC-SBX-02b agent --json preserves stdin, nonzero status, and failed-tool provenance boundaries",
"TC-SBX-02b agent --json preserves nonzero status and failed-tool provenance boundaries",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
"TC-SBX-03 status renders Sandbox/Model/Provider/GPU fields",
"TC-SBX-04 logs and logs --follow behave as streaming commands",
"TC-SBX-05 destroy removes NemoClaw and OpenShell entries",
Expand Down Expand Up @@ -641,7 +622,7 @@ liveTest(
await bestEffortCleanupSandbox(host, SANDBOX_B);
await bestEffortCleanupSandbox(host, SANDBOX_A);

await onboardSandbox(host, cleanup, SANDBOX_A, "onboard-sandbox-a");
await onboardSandbox(host, cleanup, SANDBOX_A, "onboard-sandbox-a", hosted.env);

await expectListed(host, SANDBOX_A, "tc-sbx-01-list-sandbox-a");
await assertAgentCanAnswer(host, SANDBOX_A);
Expand All @@ -652,7 +633,7 @@ liveTest(
await assertRegistryRebuild(host, SANDBOX_A);
await assertProcessRecovery(host, sandbox, SANDBOX_A);

await onboardSandbox(host, cleanup, SANDBOX_B, "tc-sbx-10-onboard-sandbox-b", {
await onboardSandbox(host, cleanup, SANDBOX_B, "tc-sbx-10-onboard-sandbox-b", hosted.env, {
CHAT_UI_URL: "http://127.0.0.1:18790",
});
await assertMetadataForBothSandboxes(host, SANDBOX_A, SANDBOX_B);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import {
readSandboxOperationsWorkflow,
validateSandboxOperationsWorkflow,
validateSandboxOperationsWorkflowBoundary,
} from "../../../tools/e2e-scenarios/sandbox-operations-workflow-boundary.mts";
import {
evaluateE2eVitestWorkflowDispatchSelectors,
readFreeStandingJobsInventory,
} from "../../../tools/e2e-scenarios/workflow-boundary.mts";

describe("sandbox operations workflow boundary", () => {
it("runs by default and through either selective dispatch input", () => {
const inventory = readFreeStandingJobsInventory();
expect(validateSandboxOperationsWorkflowBoundary()).toEqual([]);
expect(inventory.scenarioToJob.get("sandbox-operations")).toBe("sandbox-operations-vitest");

for (const selector of [
{ scenarios: "sandbox-operations" },
{ jobs: "sandbox-operations-vitest" },
]) {
expect(evaluateE2eVitestWorkflowDispatchSelectors(selector)).toMatchObject({
valid: true,
liveScenariosRuns: false,
selectedFreeStandingJobs: ["sandbox-operations-vitest"],
});
}
expect(evaluateE2eVitestWorkflowDispatchSelectors({}).selectedFreeStandingJobs).toContain(
"sandbox-operations-vitest",
);
});

it("rejects workspace-scoped auth, unsanitized installs, and broad inference secrets", () => {
const workspaceAuth = readSandboxOperationsWorkflow();
workspaceAuth.jobs["sandbox-operations-vitest"].env!.DOCKER_CONFIG =
"${{ github.workspace }}/docker";
expect(validateSandboxOperationsWorkflow(workspaceAuth)).toContain(
"sandbox-operations-vitest must not configure Docker auth at job scope",
);

const unsanitizedInstall = readSandboxOperationsWorkflow();
unsanitizedInstall.jobs["sandbox-operations-vitest"].steps!.find(
(step) => step.name === "Install OpenShell CLI",
)!.run = "bash scripts/install-openshell.sh";
expect(validateSandboxOperationsWorkflow(unsanitizedInstall)).toContain(
"sandbox-operations-vitest step 'Install OpenShell CLI' must run: -u DOCKER_CONFIG",
);

const broadInferenceSecret = readSandboxOperationsWorkflow();
broadInferenceSecret.jobs["sandbox-operations-vitest"].steps!.find(
(step) => step.name === "Build CLI",
)!.env = { NVIDIA_INFERENCE_API_KEY: "${{ secrets.NVIDIA_INFERENCE_API_KEY }}" };
expect(validateSandboxOperationsWorkflow(broadInferenceSecret)).toContain(
"sandbox-operations-vitest exposes the inference key outside the live test step",
);
});
});
Loading
Loading