Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
20 changes: 11 additions & 9 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function envInt(name, fallback) {
const n = Number(raw);
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback;
}
const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner");
const { ROOT, SCRIPTS, run, runCapture, runFile, shellQuote, validateName } = require("./runner");
const {
getDefaultOllamaModel,
getBootstrapOllamaModelOptions,
Expand Down Expand Up @@ -2092,7 +2092,10 @@ async function createSandbox(
) {
step(5, 7, "Creating sandbox");

const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName());
const sandboxName = validateName(
sandboxNameOverride || (await promptValidatedSandboxName()),
"sandbox name",
);
const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`;

// Reconcile local registry state with the live OpenShell gateway state.
Expand Down Expand Up @@ -2243,11 +2246,11 @@ async function createSandbox(
// or seeing 502/503 errors during initial load.
console.log(" Waiting for NemoClaw dashboard to become ready...");
for (let i = 0; i < 15; i++) {
const readyMatch = runCapture(
`openshell sandbox exec ${sandboxName} curl -sf http://localhost:18789/ 2>/dev/null || echo "no"`,
const readyMatch = runCaptureOpenshell(
["sandbox", "exec", sandboxName, "curl", "-sf", `http://localhost:${CONTROL_UI_PORT}/`],
{ ignoreError: true },
);
if (readyMatch && !readyMatch.includes("no")) {
if (readyMatch) {
console.log(" ✓ Dashboard is live");
break;
}
Expand All @@ -2272,10 +2275,9 @@ async function createSandbox(
// DNS proxy — run a forwarder in the sandbox pod so the isolated
// sandbox namespace can resolve hostnames (fixes #626).
console.log(" Setting up sandbox DNS proxy...");
run(
`bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`,
{ ignoreError: true },
);
runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], {
ignoreError: true,
});

console.log(` ✓ Sandbox '${sandboxName}' created`);
return sandboxName;
Expand Down
23 changes: 23 additions & 0 deletions bin/lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ function runInteractive(cmd, opts = {}) {
return result;
}

/**
* Run a program directly with argv-style arguments, bypassing shell parsing.
* Exits the process on failure unless opts.ignoreError is true.
*/
function runFile(file, args = [], opts = {}) {
const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"];
const normalizedArgs = args.map((arg) => String(arg));
const result = spawnSync(file, normalizedArgs, {
...opts,
stdio,
cwd: ROOT,
env: { ...process.env, ...opts.env },
});
writeRedactedResult(result, stdio);
if (result.status !== 0 && !opts.ignoreError) {
const rendered = [shellQuote(file), ...normalizedArgs.map((arg) => shellQuote(arg))].join(" ");
console.error(` Command failed (exit ${result.status}): ${redact(rendered).slice(0, 80)}`);
process.exit(result.status || 1);
}
return result;
}

/**
* Run a shell command and return its stdout as a trimmed string.
* Throws a redacted error on failure, or returns '' when opts.ignoreError is true.
Expand Down Expand Up @@ -196,6 +218,7 @@ module.exports = {
redact,
run,
runCapture,
runFile,
runInteractive,
shellQuote,
validateName,
Expand Down
142 changes: 133 additions & 9 deletions test/onboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1260,13 +1260,24 @@ const { EventEmitter } = require("node:events");

const commands = [];
runner.run = (command, opts = {}) => {
commands.push({ command, env: opts.env || null });
commands.push({ type: "run", command, env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ type: "runFile", file, args, env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
commands.push({ type: "runCapture", command });
if (command.includes("'sandbox' 'get' 'my-assistant'")) return "";
if (command.includes("'sandbox' 'list'")) return "my-assistant Ready";
if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok";
if (
command.includes(
"'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'",
)
) {
return "ok";
}
return "";
};
registry.registerSandbox = () => true;
Expand Down Expand Up @@ -1321,7 +1332,7 @@ const { createSandbox } = require(${onboardPath});
const payload = JSON.parse(payloadLine);
assert.equal(payload.sandboxName, "my-assistant");
const createCommand = payload.commands.find((entry) =>
entry.command.includes("'sandbox' 'create'"),
entry.command?.includes("'sandbox' 'create'"),
);
assert.ok(createCommand, "expected sandbox create command");
assert.match(createCommand.command, /'nemoclaw-start'/);
Expand All @@ -1332,10 +1343,96 @@ const { createSandbox } = require(${onboardPath});
assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/);
assert.ok(
payload.commands.some((entry) =>
entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"),
entry.command?.includes("'forward' 'start' '--background' '18789' 'my-assistant'"),
),
"expected default loopback dashboard forward",
);
assert.ok(
payload.commands.some((entry) =>
entry.command?.includes(
"'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'",
),
),
"expected dashboard readiness check via quoted OpenShell argv helper",
);
assert.ok(
payload.commands.some(
(entry) =>
entry.type === "runFile" &&
entry.file === "bash" &&
entry.args[0].endsWith(`${path.sep}scripts${path.sep}setup-dns-proxy.sh`) &&
entry.args[1] === "nemoclaw" &&
entry.args[2] === "my-assistant",
),
"expected DNS proxy setup to run via argv-style helper",
);
});

it("rejects an invalid sandboxNameOverride before any shell helpers run", async () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-invalid-override-"));
const scriptPath = path.join(tmpDir, "invalid-sandbox-override.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js"));
const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js"));
const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js"));
const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js"));
const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js"));

const script = String.raw`
const runner = require(${runnerPath});
const registry = require(${registryPath});
const preflight = require(${preflightPath});
const credentials = require(${credentialsPath});

const commands = [];
runner.run = (command, opts = {}) => {
commands.push({ type: "run", command, env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ type: "runFile", file, args, env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
commands.push({ type: "runCapture", command });
return "";
};
registry.registerSandbox = () => true;
registry.removeSandbox = () => true;
preflight.checkPortAvailable = async () => ({ ok: true });
credentials.prompt = async () => "";

const { createSandbox } = require(${onboardPath});

(async () => {
try {
await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "bad;name");
console.log(JSON.stringify({ ok: true, commands }));
} catch (error) {
console.log(JSON.stringify({ ok: false, message: error.message, 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,
NEMOCLAW_NON_INTERACTIVE: "1",
},
});

assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout.trim().split("\n").pop());
assert.equal(payload.ok, false);
assert.match(payload.message, /Invalid sandbox name/);
assert.deepEqual(payload.commands, []);
});

it("binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", async () => {
Expand Down Expand Up @@ -1364,13 +1461,24 @@ const { EventEmitter } = require("node:events");

const commands = [];
runner.run = (command, opts = {}) => {
commands.push({ command, env: opts.env || null });
commands.push({ type: "run", command, env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ type: "runFile", file, args, env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
commands.push({ type: "runCapture", command });
if (command.includes("'sandbox' 'get' 'my-assistant'")) return "";
if (command.includes("'sandbox' 'list'")) return "my-assistant Ready";
if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok";
if (
command.includes(
"'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'",
)
) {
return "ok";
}
return "";
};
registry.registerSandbox = () => true;
Expand Down Expand Up @@ -1455,16 +1563,27 @@ const commands = [];
let sandboxListCalls = 0;
const keepAlive = setInterval(() => {}, 1000);
runner.run = (command, opts = {}) => {
commands.push({ command, env: opts.env || null });
commands.push({ type: "run", command, env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ type: "runFile", file, args, env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
commands.push({ type: "runCapture", command });
if (command.includes("'sandbox' 'get' 'my-assistant'")) return "";
if (command.includes("'sandbox' 'list'")) {
sandboxListCalls += 1;
return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending";
}
if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok";
if (
command.includes(
"'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'",
)
) {
return "ok";
}
return "";
};
registry.registerSandbox = () => true;
Expand Down Expand Up @@ -1566,10 +1685,15 @@ const registry = require(${registryPath});

const commands = [];
runner.run = (command, opts = {}) => {
commands.push({ command, env: opts.env || null });
commands.push({ type: "run", command, env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ type: "runFile", file, args, env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
commands.push({ type: "runCapture", command });
if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant";
if (command.includes("'sandbox' 'list'")) return "my-assistant Ready";
return "";
Expand Down
56 changes: 56 additions & 0 deletions test/runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ describe("runner helpers", () => {
expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(calls[1][2].stdio).toEqual(["inherit", "pipe", "pipe"]);
});

it("runs argv-style commands without going through bash -c", () => {
const calls = [];
const originalSpawnSync = childProcess.spawnSync;
// @ts-expect-error — intentional partial mock for testing
childProcess.spawnSync = (...args) => {
calls.push(args);
return { status: 0, stdout: "", stderr: "" };
};

try {
delete require.cache[require.resolve(runnerPath)];
const { runFile } = require(runnerPath);
runFile("bash", ["/tmp/setup.sh", "safe;name", "$(id)"]);
} finally {
childProcess.spawnSync = originalSpawnSync;
delete require.cache[require.resolve(runnerPath)];
}

expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe("bash");
expect(calls[0][1]).toEqual(["/tmp/setup.sh", "safe;name", "$(id)"]);
expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]);
});
});

describe("runner env merging", () => {
Expand Down Expand Up @@ -107,6 +131,38 @@ describe("runner env merging", () => {
expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12");
expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin");
});

it("preserves process env when opts.env is provided to runFile", () => {
const calls = [];
const originalSpawnSync = childProcess.spawnSync;
const originalPath = process.env.PATH;
// @ts-expect-error — intentional partial mock for testing
childProcess.spawnSync = (...args) => {
calls.push(args);
return { status: 0, stdout: "", stderr: "" };
};

try {
delete require.cache[require.resolve(runnerPath)];
const { runFile } = require(runnerPath);
process.env.PATH = "/usr/local/bin:/usr/bin";
runFile("bash", ["/tmp/setup.sh"], {
env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" },
});
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
childProcess.spawnSync = originalSpawnSync;
delete require.cache[require.resolve(runnerPath)];
}

expect(calls).toHaveLength(1);
expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12");
expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin");
});
});

describe("shellQuote", () => {
Expand Down
Loading