Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
90 changes: 90 additions & 0 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3303,6 +3303,55 @@ async function promptValidatedSandboxName() {

// ── Step 5: Sandbox ──────────────────────────────────────────────

/**
* Render the configuration summary shown before the destructive sandbox build.
* Extracted from confirmOnboardConfiguration() for direct unit testing — see #2165.
*
* Fields:
* - credentialEnv: env-var name of the API key (e.g. "NVIDIA_API_KEY").
* Rendered with the fixed credentials.json location so
* users can see where the key was stored.
* - notes: additional bullet lines shown under the summary
* (e.g. "~6 minutes on this host"). Each note rendered
* as "Note: <text>" so it's visually distinct.
*/
function formatOnboardConfigSummary({
provider,
model,
credentialEnv = null,
webSearchConfig,
enabledChannels,
sandboxName,
notes = [],
}) {
const bar = ` ${"─".repeat(50)}`;
const messaging =
Array.isArray(enabledChannels) && enabledChannels.length > 0
? enabledChannels.join(", ")
: "none";
const webSearch = webSearchConfig && webSearchConfig.fetchEnabled === true ? "enabled" : "disabled";
const apiKeyLine = credentialEnv
? ` API key: ${credentialEnv} (stored in ~/.nemoclaw/credentials.json)`
: ` API key: (not required for ${provider ?? "this provider"})`;
const noteLines = (Array.isArray(notes) ? notes : [])
.filter((n) => typeof n === "string" && n.length > 0)
.map((n) => ` Note: ${n}`);
return [
"",
bar,
" Review configuration",
bar,
` Provider: ${provider ?? "(unset)"}`,
` Model: ${model ?? "(unset)"}`,
apiKeyLine,
` Web search: ${webSearch}`,
` Messaging: ${messaging}`,
` Sandbox name: ${sandboxName}`,
...noteLines,
bar,
].join("\n");
Comment thread
latenighthackathon marked this conversation as resolved.
Resolved
}

// eslint-disable-next-line complexity
async function createSandbox(
gpu,
Expand All @@ -3322,6 +3371,7 @@ async function createSandbox(
sandboxNameOverride ?? (await promptValidatedSandboxName()),
"sandbox name",
);

const effectivePort = agent ? agent.forwardPort : CONTROL_UI_PORT;
const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${effectivePort}`;

Expand Down Expand Up @@ -6629,6 +6679,45 @@ async function onboard(opts = {}) {
}
}
}
// Prompt for the sandbox name before configureWebSearch and
// setupMessagingChannels so the review gate below can run BEFORE any
// credentials for Brave Search or messaging tokens are persisted.
// Scope note: inference credentials collected in the earlier step are
// already on disk; clear them with `nemoclaw credentials reset <KEY>`
// if the user aborts. See #2165 (reporter) and #2221 (CodeRabbit).
if (!sandboxName) {
sandboxName = await promptValidatedSandboxName();
}

console.log(
formatOnboardConfigSummary({
provider,
model,
credentialEnv,
webSearchConfig,
enabledChannels:
selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null,
sandboxName,
notes: ["Sandbox build takes ~6 minutes on this host."],
}),
);
console.log(" Web search and messaging channels will be prompted next.");
if (!isNonInteractive() && !dangerouslySkipPermissions) {
const answer = (await promptOrDefault(" Apply this configuration? [Y/n]: ", null, "y"))
.trim()
.toLowerCase();
if (answer === "n" || answer === "no") {
console.log(" Aborted. Re-run `nemoclaw onboard` to start over.");
console.log(
" Inference credentials entered so far are stored in ~/.nemoclaw/credentials.json —",
);
console.log(
" clear them with `nemoclaw credentials reset <KEY>` if you no longer want them.",
);
process.exit(0);
}
}

let nextWebSearchConfig = webSearchConfig;
if (nextWebSearchConfig) {
note(" [resume] Revalidating Brave Search configuration for sandbox recreation.");
Expand Down Expand Up @@ -6834,6 +6923,7 @@ module.exports = {
setupInference,
setupMessagingChannels,
setupNim,
formatOnboardConfigSummary,
isInferenceRouteReady,
isNonInteractive,
isOpenclawReady,
Expand Down
68 changes: 68 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
summarizeProbeFailure,
shouldIncludeBuildContextPath,
writeSandboxConfigSyncFile,
formatOnboardConfigSummary,
} from "../dist/lib/onboard";
import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context";
import { buildWebSearchDockerConfig } from "../dist/lib/web-search";
Expand Down Expand Up @@ -5364,4 +5365,71 @@ const { createSandbox } = require(${onboardPath});
"pullAndResolveBaseImageDigest must be called BEFORE patchStagedDockerfile — regression #1904",
);
});

it("formatOnboardConfigSummary renders all collected fields (#2165)", () => {
const summary = formatOnboardConfigSummary({
provider: "gemini-api",
model: "gemini-2.5-flash",
credentialEnv: "GEMINI_API_KEY",
webSearchConfig: { fetchEnabled: true },
enabledChannels: ["telegram", "slack"],
sandboxName: "my-assistant",
notes: ["Sandbox build takes ~6 minutes on this host."],
});

assert.ok(summary.includes("Review configuration"), "summary has review heading");
assert.ok(summary.includes("gemini-api"), "summary includes provider");
assert.ok(summary.includes("gemini-2.5-flash"), "summary includes model");
assert.ok(
summary.includes("GEMINI_API_KEY (stored in ~/.nemoclaw/credentials.json)"),
"summary shows API key env var + storage location",
);
assert.ok(summary.includes("enabled"), "summary includes web-search enabled");
assert.ok(summary.includes("telegram, slack"), "summary lists enabled channels");
assert.ok(summary.includes("my-assistant"), "summary shows sandbox name");
assert.ok(
summary.includes("Note: Sandbox build takes ~6 minutes on this host."),
"summary renders notes under sandbox name",
);

// No messaging, no web search → "none" / "disabled"
const bareSummary = formatOnboardConfigSummary({
provider: "nvidia-prod",
model: "nvidia/nemotron-3-super-120b-a12b",
credentialEnv: "NVIDIA_API_KEY",
webSearchConfig: null,
enabledChannels: [],
sandboxName: "test",
});
assert.ok(bareSummary.includes("Messaging: none"), "empty channels renders as 'none'");
assert.ok(
bareSummary.includes("Web search: disabled"),
"null webSearch renders as 'disabled'",
);

// No credentialEnv → "(not required for <provider>)" placeholder
const localSummary = formatOnboardConfigSummary({
provider: "ollama-local",
model: "llama3:8b",
credentialEnv: null,
webSearchConfig: null,
enabledChannels: [],
sandboxName: "local",
});
assert.ok(
localSummary.includes("(not required for ollama-local)"),
"null credentialEnv falls back to a provider-specific message",
);

// Missing provider/model → "(unset)" placeholder, not "undefined"
const orphanSummary = formatOnboardConfigSummary({
provider: null,
model: null,
webSearchConfig: null,
enabledChannels: null,
sandboxName: "orphan",
});
assert.ok(!orphanSummary.includes("undefined"), "null fields never render as 'undefined'");
assert.ok(orphanSummary.includes("(unset)"), "null fields fall back to '(unset)'");
});
});