Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ $ BRAVE_API_KEY=... \
The wizard prompts for a sandbox name.
Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character.
Uppercase letters are automatically lowercased.
If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running OpenClaw UI matches your selection.
In interactive mode, the wizard asks for confirmation before delete and recreate.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default.
Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected.

Before creating the gateway, the wizard runs preflight checks.
It verifies that Docker is reachable, warns on untested runtimes such as Podman, and prints host remediation guidance when prerequisites are missing.
Expand Down
184 changes: 158 additions & 26 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,111 @@ function pruneStaleSandboxEntry(sandboxName) {
return liveExists;
}

function findSelectionConfigPath(dir) {
if (!dir || !fs.existsSync(dir)) return null;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = findSelectionConfigPath(fullPath);
if (found) return found;
continue;
}
if (entry.name === "config.json") {
return fullPath;
}
}
return null;
}

function readSandboxSelectionConfig(sandboxName) {
if (!sandboxName) return null;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-"));
try {
const result = runOpenshell(
["sandbox", "download", sandboxName, "/sandbox/.nemoclaw/config.json", `${tmpDir}${path.sep}`],
{ ignoreError: true, stdio: ["ignore", "ignore", "ignore"] },
);
if (result.status !== 0) return null;
const configPath = findSelectionConfigPath(tmpDir);
if (!configPath) return null;
try {
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
} catch {
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}

function getSelectionDrift(sandboxName, requestedProvider, requestedModel) {
const existing = readSandboxSelectionConfig(sandboxName);
if (!existing) {
return {
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider: null,
existingModel: null,
unknown: true,
};
}

const existingProvider = typeof existing.provider === "string" ? existing.provider : null;
const existingModel = typeof existing.model === "string" ? existing.model : null;
if (!existingProvider || !existingModel) {
return {
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider,
existingModel,
unknown: true,
};
}

const providerChanged = Boolean(
existingProvider && requestedProvider && existingProvider !== requestedProvider,
);
const modelChanged = Boolean(existingModel && requestedModel && existingModel !== requestedModel);

return {
changed: providerChanged || modelChanged,
providerChanged,
modelChanged,
existingProvider,
existingModel,
};
}

async function confirmRecreateForSelectionDrift(sandboxName, drift, requestedProvider, requestedModel) {
const currentProvider = drift.existingProvider || "unknown";
const currentModel = drift.existingModel || "unknown";
const nextProvider = requestedProvider || "unknown";
const nextModel = requestedModel || "unknown";

console.log(` Sandbox '${sandboxName}' exists but requested inference selection changed.`);
console.log(` Current: provider=${currentProvider} model=${currentModel}`);
console.log(` Requested: provider=${nextProvider} model=${nextModel}`);
console.log(" Recreating the sandbox is required to apply this change to the running OpenClaw UI.");

if (isNonInteractive()) {
note(" [non-interactive] Recreating sandbox due to provider/model drift.");
return true;
}

const answer = await prompt(` Recreate sandbox '${sandboxName}' now? [y/N]: `);
return isAffirmativeAnswer(answer);
}

function buildSandboxConfigSyncScript(selectionConfig) {
// openclaw.json is immutable (root:root 444, Landlock read-only) — never
// write to it at runtime. Model routing is handled by the host-side
Expand Down Expand Up @@ -2316,41 +2421,66 @@ async function createSandbox(
const needsProviderMigration =
hasMessagingTokens &&
messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name));
const selectionDrift = getSelectionDrift(sandboxName, provider, model);
const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown;

if (!isRecreateSandbox() && !needsProviderMigration) {
if (isNonInteractive()) {
if (existingSandboxState === "ready") {
// Upsert messaging providers even on reuse so credential changes take
// effect without requiring a full sandbox recreation.
upsertMessagingProviders(messagingTokenDefs);
note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`);
note(" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.");
ensureDashboardForward(sandboxName, chatUiUrl);
return sandboxName;
if (confirmedSelectionDrift) {
note(" [non-interactive] Recreating sandbox due to provider/model drift.");
} else {
// Upsert messaging providers even on reuse so credential changes take
// effect without requiring a full sandbox recreation.
upsertMessagingProviders(messagingTokenDefs);
if (selectionDrift.unknown) {
note(
" [non-interactive] Existing provider/model selection is unreadable; reusing sandbox.",
);
note(
" [non-interactive] Set NEMOCLAW_RECREATE_SANDBOX=1 (or --recreate-sandbox) to force recreation.",
);
} else {
note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`);
note(
" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.",
);
}
ensureDashboardForward(sandboxName, chatUiUrl);
return sandboxName;
}
} else {
console.error(` Sandbox '${sandboxName}' already exists but is not ready.`);
console.error(" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to overwrite.");
process.exit(1);
}
console.error(` Sandbox '${sandboxName}' already exists but is not ready.`);
console.error(" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to overwrite.");
process.exit(1);
}

if (existingSandboxState === "ready") {
console.log(` Sandbox '${sandboxName}' already exists.`);
console.log(" Choosing 'n' will delete the existing sandbox and create a new one.");
const answer = await promptOrDefault(" Reuse existing sandbox? [Y/n]: ", null, "y");
const normalizedAnswer = answer.trim().toLowerCase();
if (normalizedAnswer !== "n" && normalizedAnswer !== "no") {
upsertMessagingProviders(messagingTokenDefs);
ensureDashboardForward(sandboxName, chatUiUrl);
return sandboxName;
} else if (existingSandboxState === "ready") {
if (confirmedSelectionDrift) {
const confirmed = await confirmRecreateForSelectionDrift(
sandboxName,
selectionDrift,
provider,
model,
);
if (!confirmed) {
console.error(" Aborted. Existing sandbox left unchanged.");
process.exit(1);
}
} else {
console.log(` Sandbox '${sandboxName}' already exists.`);
console.log(" Choosing 'n' will delete the existing sandbox and create a new one.");
const answer = await promptOrDefault(" Reuse existing sandbox? [Y/n]: ", null, "y");
const normalizedAnswer = answer.trim().toLowerCase();
if (normalizedAnswer !== "n" && normalizedAnswer !== "no") {
upsertMessagingProviders(messagingTokenDefs);
ensureDashboardForward(sandboxName, chatUiUrl);
return sandboxName;
}
}
} else {
console.log(` Sandbox '${sandboxName}' exists but is not ready.`);
console.log(" Selecting 'n' will abort onboarding.");
const answer = await promptOrDefault(
" Delete it and create a new one? [Y/n]: ",
null,
"y",
);
const answer = await promptOrDefault(" Delete it and create a new one? [Y/n]: ", null, "y");
const normalizedAnswer = answer.trim().toLowerCase();
if (normalizedAnswer === "n" || normalizedAnswer === "no") {
console.log(" Aborting onboarding.");
Expand All @@ -2362,6 +2492,8 @@ async function createSandbox(
if (needsProviderMigration) {
console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`);
console.log(" Recreating to ensure credentials flow through the provider pipeline.");
} else if (confirmedSelectionDrift) {
note(` Sandbox '${sandboxName}' exists — recreating to apply model/provider change.`);
} else if (existingSandboxState === "ready") {
note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`);
} else {
Expand Down
97 changes: 90 additions & 7 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1650,9 +1650,24 @@ const { setupInference } = require(${onboardPath});
const runner = require(${runnerPath});
const registry = require(${registryPath});
const credentials = require(${credentialsPath});
const childProcess = require("node:child_process");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const path = require("node:path");

const commands = [];
runner.run = (command, opts = {}) => {
if (command.includes("'sandbox' 'download'")) {
const parts = command.match(/'([^']*)'/g) || [];
const downloadDir = parts.length ? parts[parts.length - 1].slice(1, -1) : null;
if (downloadDir) {
fs.mkdirSync(downloadDir, { recursive: true });
fs.writeFileSync(
path.join(downloadDir, "config.json"),
JSON.stringify({ provider: "nvidia-prod", model: "gpt-5.4" }),
);
}
}
commands.push({ command, env: opts.env || null });
return { status: 0 };
};
Expand Down Expand Up @@ -2491,9 +2506,24 @@ const { createSandbox } = require(${onboardPath});
const runner = require(${runnerPath});
const registry = require(${registryPath});
const credentials = require(${credentialsPath});
const childProcess = require("node:child_process");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const path = require("node:path");

const commands = [];
runner.run = (command, opts = {}) => {
if (command.includes("'sandbox' 'download'")) {
const parts = command.match(/'([^']*)'/g) || [];
const downloadDir = parts.length ? parts[parts.length - 1].slice(1, -1) : null;
if (downloadDir) {
fs.mkdirSync(downloadDir, { recursive: true });
fs.writeFileSync(
path.join(downloadDir, "config.json"),
JSON.stringify({ provider: "nvidia-prod", model: "gpt-5.4" }),
);
}
}
commands.push({ command, env: opts.env || null });
return { status: 0 };
};
Expand All @@ -2508,6 +2538,18 @@ registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false });
// Mock prompt to return "y" (reuse)
credentials.prompt = async () => "y";

childProcess.spawn = (...args) => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
commands.push({ command: args[1]?.[1] || String(args[0]), 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 () => {
Expand Down Expand Up @@ -2562,7 +2604,7 @@ const { createSandbox } = require(${onboardPath});
);

it(
"interactive mode deletes and recreates sandbox when user declines reuse",
"interactive mode deletes and recreates sandbox when user confirms drift recreate",
{ timeout: 60_000 },
async () => {
const repoRoot = path.join(import.meta.dirname, "..");
Expand All @@ -2587,9 +2629,22 @@ const registry = require(${registryPath});
const credentials = require(${credentialsPath});
const childProcess = require("node:child_process");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const path = require("node:path");

const commands = [];
runner.run = (command, opts = {}) => {
if (command.includes("'sandbox' 'download'")) {
const parts = command.match(/'([^']*)'/g) || [];
const downloadDir = parts.length ? parts[parts.length - 1].slice(1, -1) : null;
if (downloadDir) {
fs.mkdirSync(downloadDir, { recursive: true });
fs.writeFileSync(
path.join(downloadDir, "config.json"),
JSON.stringify({ provider: "openai-prod", model: "gpt-4o" }),
);
}
}
commands.push({ command, env: opts.env || null });
return { status: 0 };
};
Expand All @@ -2607,8 +2662,8 @@ registry.removeSandbox = () => true;
const preflight = require(${JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js"))});
preflight.checkPortAvailable = async () => ({ ok: true });

// Mock prompt to return "n" (decline reuse)
credentials.prompt = async () => "n";
// Mock prompt to return "y" (confirm recreate)
credentials.prompt = async () => "y";

childProcess.spawn = (...args) => {
const child = new EventEmitter();
Expand Down Expand Up @@ -2661,15 +2716,15 @@ const { createSandbox } = require(${onboardPath});

assert.ok(
payload.commands.some((entry) => entry.command.includes("'sandbox' 'delete'")),
"should delete existing sandbox when user declines reuse",
"should delete existing sandbox when user confirms recreate",
);
assert.ok(
payload.commands.some((entry) => entry.command.includes("'sandbox' 'create'")),
"should create a new sandbox when user declines reuse",
"should create a new sandbox when user confirms recreate",
);
assert.ok(
result.stdout.includes("already exists"),
"should show 'already exists' message before prompting",
result.stdout.includes("requested inference selection changed"),
"should show drift warning before prompting",
);
},
);
Expand Down Expand Up @@ -2800,6 +2855,34 @@ const { createSandbox } = require(${onboardPath});
assert.ok(result.stdout.includes("not ready"), "should mention sandbox is not ready");
},
);
it("detects provider/model drift and avoids silent reuse", () => {
const source = fs.readFileSync(
path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"),
"utf-8",
);
assert.match(source, /const selectionDrift = getSelectionDrift\(sandboxName, provider, model\);/);
assert.match(
source,
/const confirmedSelectionDrift = selectionDrift\.changed && !selectionDrift\.unknown;/,
);
assert.match(source, /unknown:\s*true/);
assert.match(source, /if \(confirmedSelectionDrift\)/);
assert.match(source, /Recreating sandbox due to provider\/model drift/);
assert.match(
source,
/Sandbox '\$\{sandboxName\}' exists — recreating to apply model\/provider change\./,
);
});

it("prompts before destructive recreate when drift is detected in interactive mode", () => {
const source = fs.readFileSync(
path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"),
"utf-8",
);
assert.match(source, /async function confirmRecreateForSelectionDrift/);
assert.match(source, /Recreate sandbox '\$\{sandboxName\}' now\? \[y\/N\]:/);
assert.match(source, /Aborted\. Existing sandbox left unchanged\./);
});

it("upsertProvider creates a new provider and returns ok on success", () => {
const repoRoot = path.join(import.meta.dirname, "..");
Expand Down