diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f0e9dd86444..c6b82d90c7c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3817,6 +3817,26 @@ async function createSandbox( if (process.env.NEMOCLAW_DASHBOARD_PORT) { envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", String(DASHBOARD_PORT))); } + // When CHAT_UI_URL points to a non-loopback address (Brev Launchable, + // remote host, custom domain), pass NEMOCLAW_CORS_ORIGIN into the sandbox + // so nemoclaw-start.sh's apply_cors_override() adds the browser's origin + // to gateway.controlUi.allowedOrigins at startup. Without this, the + // Dockerfile-baked allowedOrigins only contains http://127.0.0.1:PORT + // and the gateway rejects WebSocket/API connections from the external URL. + const corsOrigin = process.env.NEMOCLAW_CORS_ORIGIN; + if (corsOrigin) { + envArgs.push(formatEnvAssignment("NEMOCLAW_CORS_ORIGIN", corsOrigin)); + } else { + try { + const parsed = new URL(chatUiUrl); + if (!isLoopbackHostname(parsed.hostname)) { + const origin = `${parsed.protocol}//${parsed.host}`; + envArgs.push(formatEnvAssignment("NEMOCLAW_CORS_ORIGIN", origin)); + } + } catch { + // Invalid chatUiUrl — skip CORS auto-detection + } + } if (webSearchConfig?.fetchEnabled) { const braveKey = getCredential(webSearch.BRAVE_API_KEY_ENV) || process.env[webSearch.BRAVE_API_KEY_ENV]; @@ -3926,14 +3946,33 @@ async function createSandbox( // Wait for NemoClaw dashboard to become fully ready (web server live) // This prevents port forwards from connecting to a non-existent port // or seeing 502/503 errors during initial load. + // Probe /health instead of / — the root path returns 401 when device auth + // is enabled (standard for Brev Launchable and headless deployments), + // causing this readiness check to false-negative for 30s. /health returns + // 200 unconditionally when the gateway is up. Falls back to accepting any + // HTTP response (including 401) from / as proof the server is listening. console.log(" Waiting for NemoClaw dashboard to become ready..."); const openshellBin = getOpenshellBinary(); for (let i = 0; i < 15; i++) { - const readyMatch = runCaptureOpenshell( - ["sandbox", "exec", sandboxName, "curl", "-sf", `http://localhost:${effectivePort}/`], + // Primary: /health endpoint (no auth required, returns 200 when gateway is up). + // Use -o /dev/null -w '%{http_code}' to check by status code, not output + // content — runCaptureOpenshell merges stdout/stderr so a failed curl can + // return non-empty error text that would incorrectly pass a truthy check. + const healthCode = runCaptureOpenshell( + ["sandbox", "exec", sandboxName, "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", `http://localhost:${effectivePort}/health`], + { ignoreError: true }, + ); + if (healthCode && String(healthCode).trim() === "200") { + console.log(" ✓ Dashboard is live"); + break; + } + // Fallback: accept any HTTP response from / (including 401) as proof + // the server is listening — any 1xx-5xx means the gateway process is up. + const httpCode = runCaptureOpenshell( + ["sandbox", "exec", sandboxName, "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", `http://localhost:${effectivePort}/`], { ignoreError: true }, ); - if (readyMatch) { + if (httpCode && /^[1-5]\d\d$/.test(String(httpCode).trim())) { console.log(" ✓ Dashboard is live"); break; } diff --git a/test/onboard.test.ts b/test/onboard.test.ts index bd888763f6d..6e3df2ad89e 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2378,7 +2378,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; @@ -2486,7 +2486,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; @@ -2542,6 +2542,190 @@ const { createSandbox } = require(${onboardPath}); ); }); + it("injects NEMOCLAW_CORS_ORIGIN into sandbox envArgs when CHAT_UI_URL is non-loopback (#2342)", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-cors-origin-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "cors-origin-envargs.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command: _n(command), env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (_n(command).includes("sandbox get my-assistant")) return ""; + if (_n(command).includes("sandbox list")) return "my-assistant Ready"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; + if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: _n(args[1][1]), env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.CHAT_UI_URL = "https://nemoclaw0-abc123.brevlab.com"; + await createSandbox(null, "gpt-5.4"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_CORS_ORIGIN: "", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + // The sandbox create command should include NEMOCLAW_CORS_ORIGIN with the + // Brev public URL origin so apply_cors_override() adds it to allowedOrigins. + const createCmd = commands.find((entry) => entry.command.includes("sandbox create")); + assert.ok(createCmd, "expected a sandbox create command"); + assert.ok( + createCmd.command.includes("NEMOCLAW_CORS_ORIGIN=https://nemoclaw0-abc123.brevlab.com"), + `expected NEMOCLAW_CORS_ORIGIN in sandbox create envArgs, got: ${createCmd.command}`, + ); + }); + + it("does not inject NEMOCLAW_CORS_ORIGIN when CHAT_UI_URL is loopback (#2342)", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-cors-loopback-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "cors-loopback-envargs.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command: _n(command), env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (_n(command).includes("sandbox get my-assistant")) return ""; + if (_n(command).includes("sandbox list")) return "my-assistant Ready"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; + if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: _n(args[1][1]), env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.CHAT_UI_URL = "http://127.0.0.1:18789"; + await createSandbox(null, "gpt-5.4"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_CORS_ORIGIN: "", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + // Loopback CHAT_UI_URL should NOT inject NEMOCLAW_CORS_ORIGIN — + // the Dockerfile-baked allowedOrigins already contains the loopback origin. + const createCmd = commands.find((entry) => entry.command.includes("sandbox create")); + assert.ok(createCmd, "expected a sandbox create command"); + assert.ok( + !createCmd.command.includes("NEMOCLAW_CORS_ORIGIN"), + `NEMOCLAW_CORS_ORIGIN should not be set for loopback URL, got: ${createCmd.command}`, + ); + }); + it("injects NEMOCLAW_DASHBOARD_PORT into sandbox create envArgs when set (#1925)", async () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dashboard-port-")); @@ -2580,7 +2764,7 @@ runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; // Custom port: dashboard readiness curl uses 19000 (DASHBOARD_PORT from env) - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:19000/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:19000/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 19000 12345 running"; return ""; }; @@ -4087,7 +4271,7 @@ runner.runCapture = (command) => { sandboxListCalls += 1; return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; } - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; @@ -4476,7 +4660,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; @@ -4604,7 +4788,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; @@ -4880,7 +5064,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec my-assistant curl -s -o /dev/null -w %{http_code} http://localhost:18789/health")) return "200"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; };