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
6 changes: 6 additions & 0 deletions .agents/skills/nemoclaw-user-reference/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ 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.
Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts.
Use `--agent <name>` to target a specific installed agent profile during onboarding.

If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`).
Socket Mode requires both tokens.
Expand All @@ -120,6 +121,11 @@ NemoClaw bakes those values into the sandbox image as Discord guild workspace co
If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot.
Guild responses remain mention-gated by default unless you opt into all-message replies.

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.
The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`).
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ 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.
Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts.
Use `--agent <name>` to target a specific installed agent profile during onboarding.

If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`).
Socket Mode requires both tokens.
Expand All @@ -142,6 +143,11 @@ NemoClaw bakes those values into the sandbox image as Discord guild workspace co
If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot.
Guild responses remain mention-gated by default unless you opt into all-message replies.

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.
The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`).
Expand Down
185 changes: 159 additions & 26 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,112 @@ 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,
unknown: false,
};
}

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 @@ -3302,6 +3408,8 @@ 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;

// Detect whether any messaging credential has been rotated since the
// sandbox was created. Provider credentials are resolved once at sandbox
Expand All @@ -3313,37 +3421,60 @@ async function createSandbox(
if (!isRecreateSandbox() && !needsProviderMigration && !credentialRotation.changed) {
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 Down Expand Up @@ -3397,6 +3528,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 (credentialRotation.changed) {
// Message already printed above during backup.
} else if (existingSandboxState === "ready") {
Expand Down
Loading
Loading