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
58 changes: 40 additions & 18 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ const nim = require("./nim");
const onboardSession = require("./onboard-session");
const policies = require("./policies");
const { checkPortAvailable, ensureSwap, getMemoryInfo } = require("./preflight");
function secureTempFile(prefix, ext = "") {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
return path.join(dir, `${prefix}${ext}`);
}

/**
* Safely remove a mkdtemp-created directory. Guards against accidentally
* deleting the system temp root if a caller passes os.tmpdir() itself.
*/
function cleanupTempDir(filePath, expectedPrefix) {
const parentDir = path.dirname(filePath);
if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) {
fs.rmSync(parentDir, { recursive: true, force: true });
}
}

const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1";
const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY;
const DIM = USE_COLOR ? "\x1b[2m" : "";
Expand Down Expand Up @@ -683,10 +699,7 @@ function getProbeRecovery(probe, options = {}) {

// eslint-disable-next-line complexity
function runCurlProbe(argv) {
const bodyFile = path.join(
os.tmpdir(),
`nemoclaw-curl-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const bodyFile = secureTempFile("nemoclaw-curl-probe", ".json");
try {
const args = [...argv];
const url = args.pop();
Expand Down Expand Up @@ -739,7 +752,7 @@ function runCurlProbe(argv) {
message: summarizeCurlFailure(error?.status || 1, error?.message || String(error)),
};
} finally {
fs.rmSync(bodyFile, { force: true });
cleanupTempDir(bodyFile, "nemoclaw-curl-probe");
}
}

Expand Down Expand Up @@ -2244,23 +2257,32 @@ async function recoverGatewayRuntime() {
// ── Step 3: Sandbox ──────────────────────────────────────────────

async function promptValidatedSandboxName() {
const nameAnswer = await promptOrDefault(
" Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ",
"NEMOCLAW_SANDBOX_NAME",
"my-assistant",
);
const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase();
while (true) {
const nameAnswer = await promptOrDefault(
" Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ",
"NEMOCLAW_SANDBOX_NAME",
"my-assistant",
);
const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase();

// Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens,
// must start and end with alphanumeric (required by Kubernetes/OpenShell)
if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) {
return sandboxName;
}

// Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens,
// must start and end with alphanumeric (required by Kubernetes/OpenShell)
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) {
console.error(` Invalid sandbox name: '${sandboxName}'`);
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" and must start and end with a letter or number.");
process.exit(1);
}

return sandboxName;
// Non-interactive runs cannot re-prompt — abort so the caller can fix the
// NEMOCLAW_SANDBOX_NAME env var and retry.
if (isNonInteractive()) {
process.exit(1);
}

console.error(" Please try again.\n");
}
}

// ── Step 5: Sandbox ──────────────────────────────────────────────
Expand Down Expand Up @@ -3189,7 +3211,7 @@ async function setupOpenclaw(sandboxName, model, provider) {
{ stdio: ["ignore", "ignore", "inherit"] },
);
} finally {
fs.rmSync(path.dirname(scriptFile), { recursive: true, force: true });
cleanupTempDir(scriptFile, "nemoclaw-sync");
}
}

Expand Down
33 changes: 29 additions & 4 deletions test/onboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,17 +486,23 @@ describe("onboard helpers", () => {
});

it("writes sandbox sync scripts to a temp file for stdin redirection", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-test-"));
const scriptFile = writeSandboxConfigSyncFile("echo test");
try {
const scriptFile = writeSandboxConfigSyncFile("echo test", tmpDir);
expect(scriptFile).toMatch(/nemoclaw-sync-.*[/\\]sync\.sh$/);
expect(scriptFile).toMatch(/nemoclaw-sync.*\.sh$/);
expect(fs.readFileSync(scriptFile, "utf8")).toBe("echo test\n");
// Verify the file lives inside a mkdtemp-created directory (not directly in /tmp)
const parentDir = path.dirname(scriptFile);
expect(parentDir).not.toBe(os.tmpdir());
expect(parentDir).toContain("nemoclaw-sync");
if (process.platform !== "win32") {
const stat = fs.statSync(scriptFile);
expect(stat.mode & 0o777).toBe(0o600);
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
const parentDir = path.dirname(scriptFile);
if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith("nemoclaw-sync-")) {
fs.rmSync(parentDir, { recursive: true, force: true });
}
}
});

Expand Down Expand Up @@ -1798,4 +1804,23 @@ const { setupInference } = require(${onboardPath});
const commands = JSON.parse(result.stdout.trim().split("\n").pop());
assert.equal(commands.length, 3);
});

it("re-prompts on invalid sandbox names instead of exiting in interactive mode", () => {
const source = fs.readFileSync(
path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"),
"utf-8",
);
// Extract the promptValidatedSandboxName function body
const fnMatch = source.match(
/async function promptValidatedSandboxName\(\)\s*\{([\s\S]*?)\n\}/,
);
assert.ok(fnMatch, "promptValidatedSandboxName function not found");
const fnBody = fnMatch[1];
// Verify the retry loop exists within this function
assert.match(fnBody, /while\s*\(true\)/);
assert.match(fnBody, /Please try again/);
// Non-interactive still exits within this function
assert.match(fnBody, /isNonInteractive\(\)/);
assert.match(fnBody, /process\.exit\(1\)/);
});
});
Loading