Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
62 changes: 55 additions & 7 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,60 @@ async function createSandbox(gpu) {
if (process.env.NVIDIA_API_KEY) {
envArgs.push(`NVIDIA_API_KEY=${process.env.NVIDIA_API_KEY}`);
}
// set -o pipefail ensures the openshell exit code propagates through the awk pipe.
// Without it, awk's exit code (always 0) would mask a failed sandbox create.
run(`set -o pipefail; openshell sandbox create ${createArgs.join(" ")} -- env ${envArgs.join(" ")} nemoclaw-start 2>&1 | awk '/Sandbox allocated/{if(!seen){print;seen=1}next}1'`);

// Run without piping through awk — the pipe masked non-zero exit codes
// from openshell because bash returns the status of the last pipeline
// command (awk, always 0) unless pipefail is set. Removing the pipe
// lets the real exit code flow through to run().
const createResult = run(
`openshell sandbox create ${createArgs.join(" ")} -- env ${envArgs.join(" ")} nemoclaw-start 2>&1`,
{ ignoreError: true }
);

// Clean up build context regardless of outcome
run(`rm -rf "${buildCtx}"`, { ignoreError: true });

if (createResult.status !== 0) {
console.error("");
console.error(` Sandbox creation failed (exit ${createResult.status}).`);
console.error(" Try: openshell sandbox list # check gateway state");
console.error(" Try: nemoclaw onboard # retry from scratch");
process.exit(createResult.status || 1);
}

// Wait for sandbox to reach Ready state in k3s before registering.
// On WSL2 + Docker Desktop the pod can take longer to initialize;
// without this gate, NemoClaw registers a phantom sandbox that
// causes "sandbox not found" on every subsequent connect/status call.
console.log(" Waiting for sandbox to become ready...");
let ready = false;
for (let i = 0; i < 30; i++) {
const list = runCapture("openshell sandbox list 2>&1", { ignoreError: true });
const clean = list.replace(/\x1b\[[0-9;]*m/g, "");
// Exact-match sandbox name in the first column and "Ready" as a whole
// word. includes() would false-positive on substring names ("my" in
// "my-assistant") and on "NotReady" containing "Ready".
if (clean.split("\n").some((l) => {
const cols = l.trim().split(/\s+/);
return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady");
})) {
ready = true;
break;
}
require("child_process").spawnSync("sleep", ["2"]);
}

if (!ready) {
// Clean up the orphaned sandbox so the next onboard retry with the same
// name doesn't fail on "sandbox already exists".
run(`openshell sandbox delete "${sandboxName}" 2>/dev/null || true`, { ignoreError: true });
console.error("");
console.error(` Sandbox '${sandboxName}' was created but did not become ready within 60s.`);
console.error(" The orphaned sandbox has been removed — you can safely retry.");
console.error(` Logs: openshell sandbox logs "${sandboxName}"`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
console.error(" Retry: nemoclaw onboard");
process.exit(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// Release any stale forward on port 18789 before claiming it for the new sandbox.
// A previous onboard run may have left the port forwarded to a different sandbox,
Expand All @@ -445,10 +496,7 @@ async function createSandbox(gpu) {
// Forward dashboard port to the new sandbox
run(`openshell forward start --background 18789 "${sandboxName}"`, { ignoreError: true });

// Clean up build context
run(`rm -rf "${buildCtx}"`, { ignoreError: true });

// Register in registry
// Register only after confirmed ready — prevents phantom entries
registry.registerSandbox({
name: sandboxName,
gpuEnabled: !!gpu,
Expand Down
10 changes: 10 additions & 0 deletions bin/lib/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ function buildPolicyGetCommand(sandboxName) {
}

function applyPreset(sandboxName, presetName) {
// Guard against truncated sandbox names — WSL can truncate hyphenated
// names during argument parsing, e.g. "my-assistant" → "m"
const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName);
if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) {
throw new Error(
`Invalid or truncated sandbox name: '${sandboxName}'. ` +
`Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const presetContent = loadPreset(presetName);
if (!presetContent) {
console.error(` Cannot load preset: ${presetName}`);
Expand Down
136 changes: 136 additions & 0 deletions test/onboard-readiness.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const { describe, it } = require("node:test");
const assert = require("node:assert/strict");

const { buildPolicySetCommand, buildPolicyGetCommand } = require("../bin/lib/policies");

// Test the readiness parsing logic extracted for testability
// These validate the exact matching algorithm from onboard.js createSandbox()

function isSandboxReady(output, sandboxName) {
const clean = output.replace(/\x1b\[[0-9;]*m/g, "");
return clean.split("\n").some((l) => {
const cols = l.trim().split(/\s+/);
return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady");
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

describe("sandbox readiness parsing", () => {
it("detects Ready sandbox", () => {
assert.ok(isSandboxReady("my-assistant Ready 2m ago", "my-assistant"));
});

it("rejects NotReady sandbox", () => {
assert.ok(!isSandboxReady("my-assistant NotReady init failed", "my-assistant"));
});

it("rejects empty output", () => {
assert.ok(!isSandboxReady("No sandboxes found.", "my-assistant"));
assert.ok(!isSandboxReady("", "my-assistant"));
});

it("strips ANSI escape codes before matching", () => {
assert.ok(isSandboxReady(
"\x1b[1mmy-assistant\x1b[0m \x1b[32mReady\x1b[0m 2m ago",
"my-assistant"
));
});

it("rejects ANSI-wrapped NotReady", () => {
assert.ok(!isSandboxReady(
"\x1b[1mmy-assistant\x1b[0m \x1b[31mNotReady\x1b[0m crash",
"my-assistant"
));
});

it("exact-matches sandbox name in first column", () => {
// "my" should NOT match "my-assistant"
assert.ok(!isSandboxReady("my-assistant Ready 2m ago", "my"));
});

it("does not match sandbox name in non-first column", () => {
assert.ok(!isSandboxReady("other-box Ready owned-by-my-assistant", "my-assistant"));
});

it("handles multiple sandboxes in output", () => {
const output = [
"NAME STATUS AGE",
"dev-box NotReady 5m ago",
"my-assistant Ready 2m ago",
"staging Ready 10m ago",
].join("\n");
assert.ok(isSandboxReady(output, "my-assistant"));
assert.ok(!isSandboxReady(output, "dev-box")); // NotReady
assert.ok(isSandboxReady(output, "staging"));
assert.ok(!isSandboxReady(output, "prod")); // not present
});

it("handles Ready sandbox with extra status columns", () => {
assert.ok(isSandboxReady("my-assistant Ready Running 2m ago 1/1", "my-assistant"));
});

it("rejects when output only contains name in a URL or path", () => {
assert.ok(!isSandboxReady("Connecting to my-assistant.openshell.internal Ready", "my-assistant"));
// "my-assistant.openshell.internal" is cols[0], not "my-assistant"
});

it("handles tab-separated output", () => {
assert.ok(isSandboxReady("my-assistant\tReady\t2m ago", "my-assistant"));
});
});

// Regression tests: WSL truncates hyphenated sandbox names during shell
// argument parsing (e.g. "my-assistant" → "m").
describe("WSL sandbox name handling", () => {
it("buildPolicySetCommand preserves hyphenated sandbox name", () => {
const cmd = buildPolicySetCommand("/tmp/policy.yaml", "my-assistant");
assert.ok(cmd.includes('"my-assistant"'), `Expected quoted name in: ${cmd}`);
assert.ok(!cmd.includes(' my-assistant '), "Name must be quoted, not bare");
});

it("buildPolicyGetCommand preserves hyphenated sandbox name", () => {
const cmd = buildPolicyGetCommand("my-assistant");
assert.ok(cmd.includes('"my-assistant"'), `Expected quoted name in: ${cmd}`);
});

it("buildPolicySetCommand preserves multi-hyphen names", () => {
const cmd = buildPolicySetCommand("/tmp/p.yaml", "my-dev-assistant-v2");
assert.ok(cmd.includes('"my-dev-assistant-v2"'));
});

it("buildPolicySetCommand preserves single-char name", () => {
// If WSL truncates "my-assistant" to "m", the single-char name should
// still be quoted and passed through unchanged
const cmd = buildPolicySetCommand("/tmp/p.yaml", "m");
assert.ok(cmd.includes('"m"'));
});

it("applyPreset rejects truncated/invalid sandbox name", () => {
const policies = require("../bin/lib/policies");
// Empty name
assert.throws(
() => policies.applyPreset("", "npm"),
/Invalid or truncated sandbox name/
);
// Name with uppercase (not valid per RFC 1123)
assert.throws(
() => policies.applyPreset("My-Assistant", "npm"),
/Invalid or truncated sandbox name/
);
// Name starting with hyphen
assert.throws(
() => policies.applyPreset("-broken", "npm"),
/Invalid or truncated sandbox name/
);
});

it("readiness check uses exact match preventing truncated name false-positive", () => {
// If "my-assistant" was truncated to "m", the readiness check should
// NOT match a sandbox named "my-assistant" when searching for "m"
assert.ok(!isSandboxReady("my-assistant Ready 2m ago", "m"));
assert.ok(!isSandboxReady("my-assistant Ready 2m ago", "my"));
assert.ok(!isSandboxReady("my-assistant Ready 2m ago", "my-"));
});
});