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
117 changes: 117 additions & 0 deletions test/e2e-scenario/live/dashboard-remote-bind.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +25 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Escape dashboardPort before injecting into RegExp patterns.

dashboardPort is env-derived and interpolated directly into regex sources, so metacharacters can change match behavior and make this scenario pass/fail incorrectly.

Suggested fix
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
 function bindsAllInterfaces(line: string, dashboardPort: string): boolean {
+  const port = escapeRegExp(dashboardPort);
   return (
     line.includes(`0.0.0.0:${dashboardPort}`) ||
     line.includes(`*:${dashboardPort}`) ||
-    new RegExp(`\\b0\\.0\\.0\\.0\\s+${dashboardPort}\\b`).test(line)
+    new RegExp(`\\b0\\.0\\.0\\.0\\s+${port}\\b`).test(line)
   );
 }
 
 function bindsLoopback(line: string, dashboardPort: string): boolean {
+  const port = escapeRegExp(dashboardPort);
   return (
     line.includes(`127.0.0.1:${dashboardPort}`) ||
     line.includes(`localhost:${dashboardPort}`) ||
-    new RegExp(`\\b127\\.0\\.0\\.1\\s+${dashboardPort}\\b`).test(line)
+    new RegExp(`\\b127\\.0\\.0\\.1\\s+${port}\\b`).test(line)
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function bindsAllInterfaces(line: string, dashboardPort: string): boolean {
const port = escapeRegExp(dashboardPort);
return (
line.includes(`0.0.0.0:${dashboardPort}`) ||
line.includes(`*:${dashboardPort}`) ||
new RegExp(`\\b0\\.0\\.0\\.0\\s+${port}\\b`).test(line)
);
}
function bindsLoopback(line: string, dashboardPort: string): boolean {
const port = escapeRegExp(dashboardPort);
return (
line.includes(`127.0.0.1:${dashboardPort}`) ||
line.includes(`localhost:${dashboardPort}`) ||
new RegExp(`\\b127\\.0\\.0\\.1\\s+${port}\\b`).test(line)
);
}
🧰 Tools
🪛 ast-grep (0.43.0)

[warning] 28-28: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\b0\\.0\\.0\\.0\\s+${dashboardPort}\\b)
Note: [CWE-1333] Inefficient Regular Expression Complexity

(regexp-from-variable)


[warning] 36-36: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\b127\\.0\\.0\\.1\\s+${dashboardPort}\\b)
Note: [CWE-1333] Inefficient Regular Expression Complexity

(regexp-from-variable)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e-scenario/live/dashboard-remote-bind.test.ts` around lines 25 - 37,
The RegExp constructors in bindsAllInterfaces and bindsLoopback interpolate the
env-derived dashboardPort directly, which can allow regex metacharacters to
change matching; fix by escaping dashboardPort before constructing the RegExp
(add a small helper like escapeRegExp that replaces /[.*+?^${}()|[\]\\]/g with
'\\$&' and use the escaped value in new RegExp calls), and update both functions
(bindsAllInterfaces and bindsLoopback) to use the escaped port value when
building the regex.

Source: Linters/SAST tools

);
}

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);
},
);
34 changes: 25 additions & 9 deletions test/e2e/brev-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -396,21 +396,23 @@ 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`,
`cd ${remoteDir}`,
`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;
Expand All @@ -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(
Expand Down Expand Up @@ -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,
);
Expand Down
Loading