Skip to content
Closed
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
45 changes: 42 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Comment on lines +3820 to +3835

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 | 🟠 Major

Handle CORS origin drift on sandbox reuse.

This only runs on the create path. The ready-sandbox reuse branches above return before envArgs are rebuilt, so changing CHAT_UI_URL from loopback to a public origin and re-running onboard leaves the old allowedOrigins in place unless the user forces --recreate-sandbox.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard.ts` around lines 3820 - 3836, The CORS origin logic that sets
NEMOCLAW_CORS_ORIGIN using chatUiUrl, isLoopbackHostname, formatEnvAssignment,
and envArgs only runs on the sandbox create path and is skipped when reusing a
ready sandbox, causing allowedOrigins to drift; move or duplicate this
origin-detection and envArgs update so it runs for both the create and reuse
branches (where the ready-sandbox early returns occur) — ensure the code that
computes parsed = new URL(chatUiUrl), checks
isLoopbackHostname(parsed.hostname), builds origin =
`${parsed.protocol}//${parsed.host}`, and pushes
formatEnvAssignment("NEMOCLAW_CORS_ORIGIN", origin) into envArgs is executed
before any early returns for sandbox reuse so envArgs is always rebuilt when
CHAT_UI_URL changes.

} 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];
Expand Down Expand Up @@ -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;
}
Expand Down
198 changes: 191 additions & 7 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
};
Expand Down Expand Up @@ -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 "";
};
Expand Down Expand Up @@ -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: "",
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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-"));
Expand Down Expand Up @@ -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 "";
};
Expand Down Expand Up @@ -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 "";
};
Expand Down Expand Up @@ -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 "";
};
Expand Down Expand Up @@ -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 "";
};
Expand Down Expand Up @@ -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 "";
};
Expand Down
Loading