diff --git a/test/e2e-scenario/live/dashboard-remote-bind.test.ts b/test/e2e-scenario/live/dashboard-remote-bind.test.ts new file mode 100644 index 0000000000..1436b682ee --- /dev/null +++ b/test/e2e-scenario/live/dashboard-remote-bind.test.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import os from "node:os"; + +import { expect, test } from "../fixtures/e2e-test.ts"; + +// Migrated from test/e2e/test-dashboard-remote-bind.sh. +// Branch validation provisions and onboards a real remote sandbox first; this +// test restarts only that sandbox's dashboard forward and proves the explicit +// remote-bind opt-in is honored without adding another harness. + +const runDashboardRemoteBindTest = + process.env.NEMOCLAW_E2E_DASHBOARD_REMOTE_BIND === "1" ? test : test.skip; + +function matchingForwardLine(output: string, sandboxName: string, dashboardPort: string): string { + return ( + output + .split("\n") + .map((line) => line.trim()) + .find((line) => line.includes(sandboxName) && line.includes(dashboardPort)) ?? "" + ); +} + +function bindsAllInterfaces(line: string, dashboardPort: string): boolean { + return ( + line.includes(`0.0.0.0:${dashboardPort}`) || + line.includes(`*:${dashboardPort}`) || + new RegExp(`\\b0\\.0\\.0\\.0\\s+${dashboardPort}\\b`).test(line) + ); +} + +function bindsLoopback(line: string, dashboardPort: string): boolean { + return ( + line.includes(`127.0.0.1:${dashboardPort}`) || + line.includes(`localhost:${dashboardPort}`) || + new RegExp(`\\b127\\.0\\.0\\.1\\s+${dashboardPort}\\b`).test(line) + ); +} + +function remoteHostCandidate(): string { + const externalIpv4 = Object.values(os.networkInterfaces()) + .flat() + .find((iface) => iface && iface.family === "IPv4" && !iface.internal)?.address; + return process.env.NEMOCLAW_E2E_REMOTE_HOST || externalIpv4 || os.hostname(); +} + +runDashboardRemoteBindTest( + "dashboard forward binds all interfaces when remote bind is explicitly requested", + async ({ artifacts, host, sandbox }) => { + const sandboxName = process.env.NEMOCLAW_SANDBOX_NAME || "e2e-test"; + const dashboardPort = process.env.NEMOCLAW_DASHBOARD_PORT || "18789"; + const remoteHost = remoteHostCandidate(); + + await artifacts.writeJson("scenario.json", { + id: "dashboard-remote-bind", + runner: "vitest", + migratedFrom: "test/e2e/test-dashboard-remote-bind.sh", + boundary: "remote-dashboard-forward", + optIn: "NEMOCLAW_E2E_DASHBOARD_REMOTE_BIND=1", + sandboxName, + dashboardPort, + remoteHost, + }); + + const cliProbe = await host.command( + "bash", + ["-lc", "command -v nemoclaw && command -v openshell"], + { + artifactName: "dashboard-remote-bind-cli-probe", + inheritEnv: true, + timeoutMs: 30_000, + }, + ); + expect(cliProbe.exitCode, `required CLI probe failed\n${cliProbe.stderr}`).toBe(0); + expect(cliProbe.stdout).toContain("nemoclaw"); + expect(cliProbe.stdout).toContain("openshell"); + + await sandbox.openshell(["forward", "stop", dashboardPort], { + artifactName: "dashboard-remote-bind-forward-stop", + inheritEnv: true, + timeoutMs: 30_000, + }); + + const connect = await host.nemoclaw([sandboxName, "connect"], { + artifactName: "dashboard-remote-bind-connect", + inheritEnv: true, + env: { + NEMOCLAW_DASHBOARD_BIND: "0.0.0.0", + }, + timeoutMs: 120_000, + }); + expect(connect.exitCode, `nemoclaw connect failed\n${connect.stderr}`).toBe(0); + + const forwardList = await sandbox.openshell(["forward", "list"], { + artifactName: "dashboard-remote-bind-forward-list", + inheritEnv: true, + timeoutMs: 30_000, + }); + expect(forwardList.exitCode, `openshell forward list failed\n${forwardList.stderr}`).toBe(0); + await artifacts.writeText("forward-list.txt", forwardList.stdout); + + const forwardLine = matchingForwardLine(forwardList.stdout, sandboxName, dashboardPort); + expect( + forwardLine, + `No OpenShell forward found for ${sandboxName} on ${dashboardPort}`, + ).not.toBe(""); + expect( + bindsLoopback(forwardLine, dashboardPort), + `Dashboard forward is still localhost-only; expected an all-interface bind: ${forwardLine}`, + ).toBe(false); + expect( + bindsAllInterfaces(forwardLine, dashboardPort), + `Could not prove dashboard forward uses 0.0.0.0:${dashboardPort}: ${forwardLine}`, + ).toBe(true); + }, +); diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index df66f6eceb..938dcd5ec4 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -50,9 +50,9 @@ * TELEGRAM_CHAT_ID_E2E — Telegram chat ID for optional sendMessage test */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { execSync, execFileSync, spawnSync, type StdioOptions } from "node:child_process"; +import { execFileSync, execSync, type StdioOptions, spawnSync } from "node:child_process"; import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; // Instance configuration const BREV_MIN_VCPU = parseInt(process.env.BREV_MIN_VCPU || "4", 10); @@ -396,7 +396,10 @@ function waitForLaunchableReady(maxWaitMs = 1_200_000, pollIntervalMs = 15_000): ); } -function runRemoteTest(scriptPath: string): string { +function runRemoteCommand( + command: string, + timeoutMs = GPU_TEST_SUITE ? 1_800_000 : 900_000, +): string { const cmd = [ `set -o pipefail`, `source ~/.nvm/nvm.sh 2>/dev/null || true`, @@ -404,13 +407,12 @@ function runRemoteTest(scriptPath: string): string { `export npm_config_prefix=$HOME/.local`, `export PATH=$HOME/.local/bin:$PATH`, // Docker socket is chmod 666 by setup script, no sg docker needed. - - `bash ${scriptPath} 2>&1 | tee /tmp/test-output.log`, + `${command} 2>&1 | tee /tmp/test-output.log`, ].join(" && "); // Stream test output to CI log AND capture it for assertions try { - sshEnv(cmd, { timeout: GPU_TEST_SUITE ? 1_800_000 : 900_000, stream: true }); + sshEnv(cmd, { timeout: timeoutMs, stream: true }); } catch (error) { printRemoteFailureDiagnostics(); throw error; @@ -419,6 +421,10 @@ function runRemoteTest(scriptPath: string): string { return ssh("cat /tmp/test-output.log", { timeout: 30_000 }); } +function runRemoteTest(scriptPath: string): string { + return runRemoteCommand(`bash ${scriptPath}`); +} + function printRemoteFailureDiagnostics(): void { try { const diagnostics = ssh( @@ -1235,9 +1241,19 @@ describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => { it.runIf(TEST_SUITE === "dashboard-remote-bind")( "dashboard forward binds to all interfaces for remote browser origins", () => { - const output = runRemoteTest("test/e2e/test-dashboard-remote-bind.sh"); - expect(output).toContain("PASS"); - expect(output).not.toMatch(/FAIL:/); + const output = runRemoteCommand( + [ + `NEMOCLAW_RUN_E2E_SCENARIOS=1`, + `NEMOCLAW_E2E_DASHBOARD_REMOTE_BIND=1`, + `NEMOCLAW_SANDBOX_NAME=e2e-test`, + `npx vitest run --project e2e-scenarios-live`, + `test/e2e-scenario/live/dashboard-remote-bind.test.ts`, + `--silent=false --reporter=default`, + ].join(" "), + 300_000, + ); + expect(output).toContain("dashboard forward binds all interfaces"); + expect(output).not.toMatch(/FAIL|Failed/i); }, 300_000, );