Skip to content
24 changes: 19 additions & 5 deletions docs/_ext/search_assets/modules/SearchEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,19 +495,33 @@ class SearchEngine {
return this.getDocumentAudience(doc);
}

/**
* Extract safe Lunr query terms from user input.
*/
getSafeSearchTerms(query) {
const matches = String(query || '').toLowerCase().match(/[a-z0-9][a-z0-9._-]{0,63}/g);
return matches ? matches.slice(0, 10) : [];
}

/**
* Perform search with multiple strategies
*/
performMultiStrategySearch(query) {
const terms = this.getSafeSearchTerms(query);
if (terms.length === 0) return [];

const phrase = terms.join(' ');
const wildcardTerms = terms.map(term => `${term}*`).join(' ');
const fuzzyTerms = terms.map(term => `${term}~2`).join(' ');
const strategies = [
// Exact phrase search with wildcards
`"${query}" ${query}*`,
`"${phrase}" ${wildcardTerms}`,
// Fuzzy search with wildcards
`${query}* ${query}~2`,
`${wildcardTerms} ${fuzzyTerms}`,
// Individual terms with boost
query.split(/\s+/).map(term => `${term}*`).join(' '),
// Fallback: just the query
query
wildcardTerms,
// Fallback: sanitized terms only
phrase
];

let allResults = [];
Expand Down
17 changes: 16 additions & 1 deletion nemoclaw-blueprint/scripts/slack-channel-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@
'An API error occurred: invalid_auth',
];

function mentionsSlackHost(value) {
var tokens = String(value || '').split(/\s+/);
for (var i = 0; i < tokens.length; i++) {
var candidate = tokens[i].replace(/^[<("']+|[>),."']+$/g, '');
try {
var parsed = new URL(candidate);
var host = parsed.hostname.toLowerCase();
if (host === 'slack.com' || host.endsWith('.slack.com')) return true;
} catch (_e) {
if (/^(?:[a-z0-9-]+\.)*slack\.com(?::\d+)?$/i.test(candidate)) return true;
}
}
return false;
}

function isSlackRejection(reason) {
if (!reason) return false;

Expand All @@ -63,7 +78,7 @@
// servers, the error comes from the HTTP client (CONNECT tunnel
// failure), not from @slack/ code. The stack won't contain @slack/
// but the error message or URL may reference the Slack hostname.
if (msg.indexOf('slack.com') !== -1) {
if (mentionsSlackHost(msg)) {
return true;
}

Expand Down
14 changes: 9 additions & 5 deletions nemoclaw/src/onboard/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -138,10 +145,7 @@ function ensureConfigDir(): void {
try {
mkdirSync(configDir, { recursive: true });
} catch {
configDir = join(tmpdir(), ".nemoclaw");
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
configDir = mkdtempSync(join(tmpdir(), "nemoclaw-config-"));
}
}
configDirCreated = true;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/internal/installer/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@ export default class InternalInstallerPlanCommand extends NemoClawCommand {
});

if (flags.json) this.logJson(plan);
else console.log(`Installer plan: ref '${plan.installRef}', version '${plan.installerVersion}'`);
else console.log("Installer plan built. Re-run with --json for redacted details.");
}
}
5 changes: 3 additions & 2 deletions src/lib/actions/dev/npm-link-or-shim.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { randomUUID } from "node:crypto";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
Expand Down Expand Up @@ -116,15 +117,15 @@ export function runNpmLinkOrShim(
((dir: string) =>
path.join(
dir,
`nemoclaw.tmp.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`,
`nemoclaw.tmp.${process.pid}.${Date.now()}.${randomUUID()}`,
));
const run = deps.run ?? defaultRun;
const commandPath = deps.commandPath ?? defaultCommandPath;

if (env.NEMOCLAW_INSTALLING) return { status: 0 };

if (!isExecutable(binPath)) {
logError(`[nemoclaw] cannot expose CLI: ${binPath} is missing or not executable`);
logError("[nemoclaw] cannot expose CLI: launcher is missing or not executable");
return { status: 0 };
}

Expand Down
7 changes: 4 additions & 3 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import * as nim from "../../inference/nim";
import * as policies from "../../policy";
import { parseLiveSandboxNames } from "../../runtime-recovery";
import * as sandboxVersion from "../../sandbox/version";
import { redact } from "../../security/redact";
import type { Session } from "../../state/onboard-session";
import * as onboardSession from "../../state/onboard-session";
import * as registry from "../../state/registry";
Expand All @@ -60,7 +61,7 @@ const agentRuntime = require("../../../../bin/lib/agent-runtime");
* Emit timestamped rebuild diagnostics when verbose rebuild logging is enabled.
*/
function _rebuildLog(msg: string) {
console.error(` ${D}[rebuild ${new Date().toISOString()}] ${msg}${R}`);
console.error(` ${D}[rebuild ${new Date().toISOString()}] ${redact(msg)}${R}`);
}

/**
Expand Down Expand Up @@ -122,7 +123,7 @@ function preflightHermesProviderCredentials(
nonEmptyString(process.env[hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV]) ||
nonEmptyString(process.env.NEMOCLAW_PROVIDER_KEY);
log(
`Hermes Provider rebuild preflight: OpenShell provider missing; ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} env=${envKey ? "present" : "missing"}`,
`Hermes Provider rebuild preflight: OpenShell provider missing; API key env=${envKey ? "present" : "missing"}`,
);
if (envKey) {
try {
Expand All @@ -145,7 +146,7 @@ function preflightHermesProviderCredentials(
console.error(" Hermes Provider credentials must be stored in OpenShell, not host-side files.");
if (authMethod === "api_key") {
console.error(
` Export ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} and rerun rebuild, or re-run ${CLI_NAME} onboard to register it.`,
` Export the Hermes Provider API key and rerun rebuild, or re-run ${CLI_NAME} onboard to register it.`,
);
} else {
console.error(` Re-run ${CLI_NAME} onboard interactively to authorize Hermes Provider and register it with OpenShell.`);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/adapters/openshell/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe("openshell helpers", () => {
exit: exitWithCode,
}),
).toThrow("exit:1");
expect(errors).toEqual([" Failed to start openshell status: spawn EACCES"]);
expect(errors).toEqual([" Failed to start OpenShell command: spawn EACCES"]);
});

it("treats capture spawn failures as fatal errors", () => {
Expand All @@ -218,7 +218,7 @@ describe("openshell helpers", () => {
exit: exitWithCode,
}),
).toThrow("exit:1");
expect(errors).toEqual([" Failed to start openshell status: spawn ENOENT"]);
expect(errors).toEqual([" Failed to start OpenShell command: spawn ENOENT"]);
});

it("reads the installed openshell version through the capture helper", () => {
Expand Down
9 changes: 4 additions & 5 deletions src/lib/adapters/openshell/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,12 @@ export function versionGte(left = "0.0.0", right = "0.0.0"): boolean {
}

function handleSpawnError(
binary: string,
args: string[],
_binary: string,
_args: string[],
error: Error,
opts: OpenshellSpawnOptions,
): never {
const command = [binary, ...args].join(" ");
(opts.errorLine ?? console.error)(` Failed to start ${command}: ${error.message}`);
(opts.errorLine ?? console.error)(` Failed to start OpenShell command: ${error.message}`);
return (opts.exit ?? ((code) => process.exit(code)))(1);
}

Expand Down Expand Up @@ -135,7 +134,7 @@ export function runOpenshellCommand(
}
if (result.status !== 0 && !opts.ignoreError) {
(opts.errorLine ?? console.error)(
` Command failed (exit ${result.status}): openshell ${args.join(" ")}`,
` OpenShell command failed (exit ${result.status})`,
);
return (opts.exit ?? ((code) => process.exit(code)))(result.status || 1);
}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/cli/nemoclaw-oclif-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class TestCommand extends NemoClawCommand {
public fail(lines: readonly string[], code?: number): void {
this.failWithLines(lines, code);
}

public json(value: unknown): void {
this.logJson(value);
}
}

function makeCommand(): TestCommand {
Expand Down Expand Up @@ -54,4 +58,14 @@ describe("NemoClawCommand", () => {
expect(process.exitCode).toBe(9);
expect(error.mock.calls).toEqual([["line 1"], ["line 2"]]);
});

it("redacts sensitive JSON output before logging", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

makeCommand().json({ provider: "build", apiKey: "nvapi-" + "a".repeat(24) });

expect(log).toHaveBeenCalledWith(
JSON.stringify({ provider: "build", apiKey: "<REDACTED>" }, null, 2),
);
});
});
4 changes: 3 additions & 1 deletion src/lib/cli/nemoclaw-oclif-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { Command, Flags } from "@oclif/core";

import { redactForLog } from "../security/redact";

export type CommandExitResult = {
exitCode?: number | null;
message?: string | null;
Expand All @@ -21,7 +23,7 @@ export abstract class NemoClawCommand extends Command {
};

protected logJson(json: unknown): void {
console.log(JSON.stringify(json, null, 2));
console.log(JSON.stringify(redactForLog(json), null, 2));
}

protected setExitCode(code: number): void {
Expand Down
11 changes: 5 additions & 6 deletions src/lib/diagnostics/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { platform, tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";

Expand Down Expand Up @@ -314,8 +314,9 @@ function collectSandboxInternals(

section("Sandbox Internals");

// Generate temporary SSH config
const sshConfigPath = join(tmpdir(), `nemoclaw-ssh-${String(Date.now())}`);
// Generate temporary SSH config in a private directory.
const sshConfigDir = mkdtempSync(join(tmpdir(), "nemoclaw-ssh-"));
const sshConfigPath = join(sshConfigDir, "config");
try {
const sshResult = spawnSync("openshell", ["sandbox", "ssh-config", sandboxName], {
timeout: TIMEOUT_MS,
Expand Down Expand Up @@ -358,9 +359,7 @@ function collectSandboxInternals(
]);
}
} finally {
if (existsSync(sshConfigPath)) {
unlinkSync(sshConfigPath);
}
rmSync(sshConfigDir, { force: true, recursive: true });
}
}

Expand Down
10 changes: 5 additions & 5 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,7 +1527,7 @@ async function replaceNamedCredential(
saveCredential(envName, key);
process.env[envName] = key;
console.log("");
console.log(` ${envName} staged. Onboarding will register it with the OpenShell gateway.`);
console.log(" Credential staged. Onboarding will register it with the OpenShell gateway.");
console.log("");
return key;
}
Expand Down Expand Up @@ -1938,7 +1938,7 @@ function verifyCompatibleEndpointSandboxSmoke(options: {
!providerDetails.includes(options.credentialEnv)
) {
console.warn(
` ⚠ Gateway provider '${options.provider}' did not report ${options.credentialEnv}.`,
` ⚠ Gateway provider '${options.provider}' did not report the selected credential binding.`,
);
}

Expand Down Expand Up @@ -5384,12 +5384,12 @@ async function createSandbox(
.filter(Boolean);
for (const serverId of serverIds) {
if (!DISCORD_SNOWFLAKE_RE.test(serverId)) {
console.warn(` Warning: Discord server ID '${serverId}' does not look like a snowflake.`);
console.warn(" Warning: configured Discord server ID does not look like a snowflake.");
}
}
for (const userId of userIds) {
if (!DISCORD_SNOWFLAKE_RE.test(userId)) {
console.warn(` Warning: Discord user ID '${userId}' does not look like a snowflake.`);
console.warn(" Warning: configured Discord user ID does not look like a snowflake.");
}
}
const requireMention = process.env.DISCORD_REQUIRE_MENTION !== "0";
Expand Down Expand Up @@ -6521,7 +6521,7 @@ async function setupNim(
if (isNonInteractive()) {
if (!resolveHermesNousApiKey()) {
console.error(
` ${HERMES_NOUS_API_KEY_CREDENTIAL_ENV} (or NEMOCLAW_PROVIDER_KEY) is required for Hermes Provider Nous API Key in non-interactive mode.`,
" Hermes Provider Nous API Key is required in non-interactive mode.",
);
process.exit(1);
}
Expand Down
26 changes: 20 additions & 6 deletions src/lib/onboard/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1321,12 +1321,26 @@ export function probeContainerDns(opts: ProbeContainerDnsOpts = {}): DnsProbeRes
// begins with `Server: ... / Address: <ip>:53` — proves only that we
// reached *something* claiming to be a resolver, not that we got an
// answer. Real success requires either an actual `Name:`+`Address:`
// resolution pair OR an NXDOMAIN response body.
const probeAnswered =
/^Server:\s*\S/im.test(output) &&
((/\bName:\s*\S/i.test(output) && /\bAddress:\s*\d/.test(output)) ||
/server can't find\b.*NXDOMAIN/i.test(output));
if (probeAnswered) {
// resolution pair OR an NXDOMAIN response body. Keep this line-based so
// CodeQL does not treat partial host regexes as URL validation.
const outputLines = output.split(/\r?\n/).map((line) => line.trim());
const hasResolverHeader = outputLines.some((line) => {
const fields = line.split(/\s+/);
return fields[0] === "Server:" && Boolean(fields[1]);
});
const hasResolvedName = outputLines.some((line) => {
const fields = line.split(/\s+/);
return fields[0] === "Name:" && Boolean(fields[1]);
});
const hasAddress = outputLines.some((line) => {
const fields = line.split(/\s+/);
return fields[0] === "Address:" && /^\d/.test(fields[1] ?? "");
});
const hasNxdomainAnswer = outputLines.some((line) => {
const lower = line.toLowerCase();
return lower.includes("server can't find") && lower.includes("nxdomain");
});
if (hasResolverHeader && ((hasResolvedName && hasAddress) || hasNxdomainAnswer)) {
return { ok: true };
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard/summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ describe("onboard summary helpers", () => {
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 (staged for OpenShell gateway registration)"),
"summary shows API key env var + staging state",
summary.includes("configured for OpenShell gateway registration"),
"summary shows API key staging state without printing env var names",
);
assert.ok(summary.includes("enabled"), "summary includes web-search enabled");
assert.ok(summary.includes("telegram, slack"), "summary lists enabled channels");
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function formatOnboardConfigSummary({
? " Nous API key: host-managed; sandbox receives inference placeholder only"
: " Nous OAuth: host-managed; sandbox receives inference placeholder only"
: credentialEnv
? ` API key: ${credentialEnv} (staged for OpenShell gateway registration)`
? " API key: configured for OpenShell gateway registration"
: ` API key: (not required for ${provider ?? "this provider"})`;
const noteLines = (Array.isArray(notes) ? notes : [])
.filter((note) => typeof note === "string" && note.length > 0)
Expand Down
Loading
Loading