Skip to content
Open
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
192 changes: 152 additions & 40 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,126 @@ exit
`.trim();
}

function parseSelectionConfigJson(raw) {
const text = String(raw || "").trim();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
const first = text.indexOf("{");
const last = text.lastIndexOf("}");
if (first >= 0 && last > first) {
try {
return JSON.parse(text.slice(first, last + 1));
} catch {
return null;
}
}
return null;
}
}

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

function readSandboxSelectionConfig(sandboxName) {
if (!sandboxName) return null;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-"));
try {
const destDir = `${tmpDir}${path.sep}`;
const result = runOpenshell(
["sandbox", "download", sandboxName, "/sandbox/.nemoclaw/config.json", destDir],
{ ignoreError: true, stdio: ["ignore", "ignore", "ignore"] },
);
if (result.status !== 0) return null;
const configPath = findSelectionConfigPath(tmpDir);
if (!configPath) return null;
const parsed = parseSelectionConfigJson(fs.readFileSync(configPath, "utf-8"));
if (!parsed || typeof parsed !== "object") return null;
return parsed;
} 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 isOpenclawReady(sandboxName) {
return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName));
}
Expand Down Expand Up @@ -2276,52 +2396,44 @@ async function createSandbox(
const needsProviderMigration =
hasMessagingTokens &&
messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name));

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;
}
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 {
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 selectionDrift = getSelectionDrift(sandboxName, provider, model);

if (existingSandboxState === "ready" && !isRecreateSandbox()) {
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 (selectionDrift.changed) {
const confirmed = await confirmRecreateForSelectionDrift(
sandboxName,
selectionDrift,
provider,
model,
);
const normalizedAnswer = answer.trim().toLowerCase();
if (normalizedAnswer === "n" || normalizedAnswer === "no") {
console.log(" Aborting onboarding.");
if (!confirmed) {
console.error(" Aborted. Existing sandbox left unchanged.");
process.exit(1);
}
} else {
// Upsert messaging providers even on reuse so credential changes take
// effect without requiring a full sandbox recreation. Only the
// --provider attachment flags need to be on the create path.
upsertMessagingProviders(messagingTokenDefs);
ensureDashboardForward(sandboxName, chatUiUrl);
if (isNonInteractive()) {
note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`);
} else {
console.log(` Sandbox '${sandboxName}' already exists and is ready.`);
console.log(" Reusing existing sandbox.");
console.log(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it instead.");
}
return sandboxName;
}
}

if (needsProviderMigration) {
console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`);
console.log(" Recreating to ensure credentials flow through the provider pipeline.");
if (existingSandboxState === "ready" && needsProviderMigration) {
note(` Sandbox '${sandboxName}' exists — recreating to attach messaging providers.`);
} else if (existingSandboxState === "ready" && selectionDrift.changed) {
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
4 changes: 4 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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 and logs the reason.
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
Loading