Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
61 changes: 61 additions & 0 deletions .agents/skills/nemoclaw-deploy-remote/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ The legacy compatibility flow performs the following steps on the VM:

By default, the compatibility wrapper asks Brev to provision on `gcp`. Override this with `NEMOCLAW_BREV_PROVIDER` if you need a different Brev cloud provider.

If you configured a Telegram bot token but not an allowlist yet, the bridge stays disabled.
Save `ALLOWED_CHAT_IDS` with `nemoclaw telegram allow <chat-id>` or run discovery mode with `nemoclaw start --discover-chat-id` to enable it.

## Step 3: Connect to the Remote Sandbox

After deployment finishes, the deploy command opens an interactive shell inside the remote sandbox.
Expand Down Expand Up @@ -174,6 +177,64 @@ It does not affect Telegram connectivity.
$ nemoclaw start
```

The `start` command launches the following services:

- The Telegram bridge forwards messages between Telegram and the agent.
- The cloudflared tunnel provides external access to the sandbox.

The Telegram bridge starts only when the following are configured:

- `TELEGRAM_BOT_TOKEN`
- `NVIDIA_API_KEY`
- `ALLOWED_CHAT_IDS`

If you do not know your Telegram chat ID yet, start the bridge in discovery-only mode:

```console
$ nemoclaw start --discover-chat-id
```

Then send any message to the bot. The bridge replies with your chat ID and does not forward the message to the agent.

## Step 13: Verify the Services

Check that the Telegram bridge is running:

```console
$ nemoclaw status
```

The output shows the status of all auxiliary services.

## Step 14: Send a Message

Open Telegram, find your bot, and send a message.
The bridge forwards the message to the OpenClaw agent inside the sandbox and returns the agent response.

## Step 15: Allow Telegram Chats by Chat ID

Save the Telegram chat IDs allowed to interact with the agent:

```console
$ nemoclaw telegram allow 123456789,987654321
$ nemoclaw start
```

To inspect or clear the saved allowlist:

```console
$ nemoclaw telegram show
$ nemoclaw telegram clear
```

## Step 16: Stop the Services

To stop the Telegram bridge and all other auxiliary services:

```console
$ nemoclaw stop
```

## Reference

- [Sandbox Image Hardening](references/sandbox-hardening.md)
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/nemoclaw-reference/references/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ The following environment variables configure optional services and local access

| Variable | Purpose |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. |
| `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. |
| `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. |
| `ALLOWED_CHAT_IDS` | Comma-separated list of Telegram chat IDs allowed to message the agent. Required for normal Telegram bridge forwarding. |
| `CHAT_UI_URL` | URL for the optional chat UI endpoint. |
| `NEMOCLAW_DISABLE_DEVICE_AUTH` | Build-time-only toggle that disables gateway device pairing when set to `1` before the sandbox image is created. |

Expand Down
26 changes: 26 additions & 0 deletions .agents/skills/nemoclaw-reference/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,32 @@ Start optional host auxiliary services. This is the cloudflared tunnel when `clo
$ nemoclaw start
```

Use discovery-only mode to have the bot reply with your Telegram chat ID without forwarding messages to the agent:

```console
$ nemoclaw start --discover-chat-id
```

The Telegram bridge requires `TELEGRAM_BOT_TOKEN`, `NVIDIA_API_KEY`, and an `ALLOWED_CHAT_IDS` allowlist for normal operation.

### `nemoclaw telegram`

Manage the Telegram bridge allowlist.

| Subcommand | Description |
|------------|-------------|
| `allow <id>` | Save one or more Telegram chat IDs in the allowlist |
| `show` | Display the current Telegram allowlist |
| `clear` | Remove all saved Telegram chat IDs |
| `discover` | Start the bridge in discovery-only mode to reveal your chat ID |

```console
$ nemoclaw telegram allow 123456789
$ nemoclaw telegram show
$ nemoclaw telegram clear
$ nemoclaw telegram discover
```

### `nemoclaw stop`

Stop host auxiliary services started by `nemoclaw start` (for example cloudflared).
Expand Down
12 changes: 12 additions & 0 deletions .agents/skills/nemoclaw-reference/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ The status command detects the sandbox context and reports "active (inside sandb

Run `openshell sandbox list` on the host to check the underlying sandbox state.

### `openclaw update` hangs or times out inside the sandbox

This is expected for the current NemoClaw deployment model.
NemoClaw installs `openclaw` into the sandbox image at build time, so the CLI is image-pinned rather than updated in place inside a running sandbox.

Do not run `openclaw update` inside the sandbox.
Instead:

1. Upgrade to a NemoClaw release that includes the newer `openclaw` version.
2. If you build NemoClaw from source, bump the pinned `openclaw` version in `Dockerfile.base` and rebuild the sandbox base image.
3. Back up any workspace files you need, then recreate the sandbox so it uses the rebuilt image.

### Inference requests time out

Verify that the inference provider endpoint is reachable from the host.
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ config = { \
'agents': {'defaults': {'model': {'primary': primary_model_ref}}}, \
'models': {'mode': 'merge', 'providers': providers}, \
'channels': dict({'defaults': {'configWrites': False}}, **_ch_cfg), \
'update': {'checkOnStart': False}, \
'gateway': { \
'mode': 'local', \
'controlUi': { \
Expand Down
119 changes: 65 additions & 54 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function envInt(name, fallback) {
const n = Number(raw);
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback;
}
const { ROOT, SCRIPTS, redact, run, runCapture, shellQuote } = require("./runner");
const { ROOT, SCRIPTS, redact, run, runCapture, runFile, shellQuote } = require("./runner");
const { stageOptimizedSandboxBuildContext } = require("./sandbox-build-context");
const {
getDefaultOllamaModel,
Expand Down Expand Up @@ -56,6 +56,7 @@ const {
getMemoryInfo,
planHostRemediation,
} = require("./preflight");
const { validateSandboxName } = require("./sandbox-names");

// Typed modules (compiled from src/lib/*.ts → dist/lib/*.js)
const gatewayState = require("../../dist/lib/gateway-state");
Expand Down Expand Up @@ -773,12 +774,18 @@ async function promptBraveSearchApiKey() {
}
}

async function ensureValidatedBraveSearchCredential() {
let apiKey = getCredential(webSearch.BRAVE_API_KEY_ENV);
let usingSavedKey = Boolean(apiKey);
async function ensureValidatedBraveSearchCredential(nonInteractive = isNonInteractive()) {
const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV);
let apiKey = savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]);
let usingSavedKey = Boolean(savedApiKey);

while (true) {
if (!apiKey) {
if (nonInteractive) {
throw new Error(
"Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.",
);
}
apiKey = await promptBraveSearchApiKey();
usingSavedKey = false;
}
Expand All @@ -798,6 +805,12 @@ async function ensureValidatedBraveSearchCredential() {
console.error(` ${validation.message}`);
}

if (nonInteractive) {
throw new Error(
validation.message || "Brave Search API key validation failed in non-interactive mode.",
);
}

const action = await promptBraveSearchRecovery(validation);
if (action === "skip") {
console.log(" Skipping Brave Web Search setup.");
Expand Down Expand Up @@ -1912,22 +1925,21 @@ async function promptValidatedSandboxName() {
"NEMOCLAW_SANDBOX_NAME",
"my-assistant",
);
const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase();
const sandboxName = (nameAnswer || "my-assistant").trim();

// Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens,
// must start with a letter (not a digit) to satisfy Kubernetes naming.
if (/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) {
return sandboxName;
}

console.error(` Invalid sandbox name: '${sandboxName}'`);
if (/^[0-9]/.test(sandboxName)) {
console.error(" Names must start with a letter, not a digit.");
} else {
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" must start with a letter, and end with a letter or number.");
try {
return validateSandboxName(sandboxName);
} catch (err) {
console.error(` ${err.message}`);
if (/reserved by the CLI/.test(err.message)) {
console.error(" Choose a different name to avoid colliding with top-level commands.");
} else if (/^[0-9]/.test(sandboxName)) {
console.error(" Names must start with a letter, not a digit.");
} else {
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" must start with a letter, and end with a letter or number.");
}
}

// Non-interactive runs cannot re-prompt — abort so the caller can fix the
// NEMOCLAW_SANDBOX_NAME env var and retry.
if (isNonInteractive()) {
Expand Down Expand Up @@ -1958,7 +1970,10 @@ async function createSandbox(
) {
step(6, 8, "Creating sandbox");

const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName());
const sandboxName =
sandboxNameOverride !== null && sandboxNameOverride !== undefined
? validateSandboxName(String(sandboxNameOverride).trim())
: await promptValidatedSandboxName();
const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`;

// Check whether messaging providers will be needed — this must happen before
Expand Down Expand Up @@ -2225,11 +2240,11 @@ async function createSandbox(
// or seeing 502/503 errors during initial load.
console.log(" Waiting for NemoClaw dashboard to become ready...");
for (let i = 0; i < 15; i++) {
const readyMatch = runCapture(
`openshell sandbox exec ${shellQuote(sandboxName)} curl -sf http://localhost:18789/ 2>/dev/null || echo "no"`,
const readyMatch = runCaptureOpenshell(
["sandbox", "exec", sandboxName, "curl", "-sf", `http://localhost:${CONTROL_UI_PORT}/`],
{ ignoreError: true },
);
if (readyMatch && !readyMatch.includes("no")) {
if (readyMatch) {
console.log(" ✓ Dashboard is live");
break;
}
Expand All @@ -2254,10 +2269,9 @@ async function createSandbox(
// DNS proxy — run a forwarder in the sandbox pod so the isolated
// sandbox namespace can resolve hostnames (fixes #626).
console.log(" Setting up sandbox DNS proxy...");
run(
`bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${shellQuote(GATEWAY_NAME)} ${shellQuote(sandboxName)} 2>&1 || true`,
{ ignoreError: true },
);
runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], {
ignoreError: true,
});

// Check that messaging providers exist in the gateway (sandbox attachment
// cannot be verified via CLI yet — only gateway-level existence is checked).
Expand Down Expand Up @@ -4023,35 +4037,13 @@ async function onboard(opts = {}) {
break;
}

if (webSearchConfig) {
note(" [resume] Revalidating Brave Search configuration.");
const braveApiKey = await ensureValidatedBraveSearchCredential();
if (braveApiKey) {
webSearchConfig = { fetchEnabled: true };
onboardSession.updateSession((current) => {
current.webSearchConfig = webSearchConfig;
return current;
});
note(" [resume] Reusing Brave Search configuration.");
} else {
webSearchConfig = await configureWebSearch(null);
onboardSession.updateSession((current) => {
current.webSearchConfig = webSearchConfig;
return current;
});
}
} else {
webSearchConfig = await configureWebSearch(webSearchConfig);
onboardSession.updateSession((current) => {
current.webSearchConfig = webSearchConfig;
return current;
});
}

const sandboxReuseState = getSandboxReuseState(sandboxName);
const resumeSandbox =
resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready";
if (resumeSandbox) {
if (webSearchConfig) {
note(" [resume] Reusing Brave Search configuration already baked into the sandbox.");
}
skippedStepMessage("sandbox", sandboxName);
} else {
if (resume && session?.steps?.sandbox?.status === "complete") {
Expand All @@ -4067,20 +4059,37 @@ async function onboard(opts = {}) {
}
}
}
let nextWebSearchConfig = webSearchConfig;
if (nextWebSearchConfig) {
note(" [resume] Revalidating Brave Search configuration for sandbox recreation.");
const braveApiKey = await ensureValidatedBraveSearchCredential();
nextWebSearchConfig = braveApiKey ? { fetchEnabled: true } : null;
if (nextWebSearchConfig) {
note(" [resume] Reusing Brave Search configuration.");
}
} else {
nextWebSearchConfig = await configureWebSearch(null);
}
const enabledChannels = await setupMessagingChannels();

startRecordedStep("sandbox", { sandboxName, provider, model });
sandboxName = await createSandbox(
gpu,
model,
provider,
preferredInferenceApi,
sandboxName,
webSearchConfig,
nextWebSearchConfig,
enabledChannels,
fromDockerfile,
);
onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer });
webSearchConfig = nextWebSearchConfig;
onboardSession.markStepComplete("sandbox", {
sandboxName,
provider,
model,
nimContainer,
webSearchConfig,
});
}

const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName);
Expand Down Expand Up @@ -4151,8 +4160,10 @@ module.exports = {
compactText,
copyBuildContextDir,
classifySandboxCreateFailure,
configureWebSearch,
createSandbox,
formatEnvAssignment,
ensureValidatedBraveSearchCredential,
getFutureShellPathHint,
getGatewayStartEnv,
getGatewayReuseState,
Expand Down
Loading