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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ One Render web service running nanobot's `gateway` and its bundled WebUI. You ch

This is not a free deploy. Expect:

- **Render Starter plan** — ~$7/mo for the web service.
- **Render Starter plan** — ~$7/mo for the web service. Runs the core experience well; resource-intensive tasks (npm-based CLI Apps, image rendering) may need a larger plan — see the note under [Deploy](#deploy).
- **1 GB persistent disk** — ~$0.25/mo for sessions and memory.
- **Anthropic API usage** — billed separately by Anthropic based on your agent's LLM calls.

Expand All @@ -35,7 +35,7 @@ This is not a free deploy. Expect:
5. The WebUI shows an access prompt — paste the same `NANOBOT_WEB_TOKEN` to sign in. It's stored only in your browser.
6. Send a message. You're talking to your agent.

**Note:** If you hit an error like `No space left on device` after performing a resource-intensive dask, upgrade your persistent disk size to 5 GB or larger in the **Disk** page in your Render dashboard.
**Note:** The Starter plan comfortably runs the core experience — chat, web search/fetch, and persistent memory. Resource-intensive actions, such as installing npm-based CLI Apps or generating/rendering images, can briefly overwhelm the small instance: it may trigger a ~15s restart before recovering, or fail with `No space left on device`. To run these reliably, upgrade to a larger plan (e.g. **Standard**, 2 GB) under **Settings → Instance Type**, and/or increase your persistent disk to 5 GB or larger on the **Disk** page, in your Render dashboard.

## Environment variables

Expand Down
30 changes: 22 additions & 8 deletions nanobot/apps/cli/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -857,10 +857,15 @@ def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
package = str(app.get("npm_package") or "")
if not package:
raise CliAppError("registry entry has no npm_package")
# Lightweight flags flatten npm's CPU/network burst so a global install
# doesn't starve a small instance's health check into a restart:
# --no-audit/--no-fund skip an extra audit pass; --maxsockets caps
# concurrent connections. Uninstall doesn't need them.
light = ["--no-audit", "--no-fund", "--maxsockets", "4"]
if action == "install":
return [npm, "install", "-g", package]
return [npm, "install", "-g", package, *light]
if action == "update":
return [npm, "install", "-g", package + "@latest"]
return [npm, "install", "-g", package + "@latest", *light]
return [npm, "uninstall", "-g", package]

def _cleanup_stale_npm_install(self, app: dict[str, Any]) -> bool:
Expand Down Expand Up @@ -944,12 +949,21 @@ def _argv_for_action(
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
command = subprocess.list2cmdline(argv)
logger.info("CLI Apps: running {}", command)
result = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
)
try:
result = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
logger.warning("CLI Apps: command timed out after {}s: {}", timeout, command)
raise CliAppError(
f"Timed out after {timeout}s. CLI-app installs are resource-intensive and "
"can overwhelm a small instance. Upgrade your Render instance to a larger "
"plan and try again. See the README.",
status=503,
) from exc
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
output = (result.stderr or result.stdout or "").strip()
if output:
Expand Down
75 changes: 74 additions & 1 deletion tests/cli_apps/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,16 @@ def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[st
nonlocal install_attempts
if argv == [npm, "root", "-g"]:
return subprocess.CompletedProcess(argv, 0, stdout=str(global_root), stderr="")
if argv == [npm, "install", "-g", "hyperframes"]:
if argv == [
npm,
"install",
"-g",
"hyperframes",
"--no-audit",
"--no-fund",
"--maxsockets",
"4",
]:
install_attempts += 1
if install_attempts == 1:
return subprocess.CompletedProcess(
Expand Down Expand Up @@ -954,3 +963,67 @@ def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[st
sys.executable,
"suno-cli",
]


def test_npm_argv_install_and_update_use_lightweight_flags(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
npm = str(tmp_path / "bin" / "npm")
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: npm if command == "npm" else None,
)
app = {"package_manager": "npm", "npm_package": "hyperframes"}

# install/update carry lightweight flags to flatten the CPU/network burst that
# otherwise starves a small instance's health check.
assert manager._npm_argv(app, "install") == [
npm,
"install",
"-g",
"hyperframes",
"--no-audit",
"--no-fund",
"--maxsockets",
"4",
]
assert manager._npm_argv(app, "update") == [
npm,
"install",
"-g",
"hyperframes@latest",
"--no-audit",
"--no-fund",
"--maxsockets",
"4",
]
# uninstall stays minimal — install-only flags don't apply.
assert manager._npm_argv(app, "uninstall") == [npm, "uninstall", "-g", "hyperframes"]


def test_run_argv_timeout_raises_friendly_cli_app_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.apps.cli import service as cli_service

manager = _manager(tmp_path)

def fake_run(
argv: list[str],
*,
capture_output: bool,
text: bool,
timeout: int,
) -> subprocess.CompletedProcess[str]:
raise subprocess.TimeoutExpired(cmd=argv, timeout=timeout)

monkeypatch.setattr(cli_service.subprocess, "run", fake_run)

with pytest.raises(CliAppError) as excinfo:
manager._run_argv(["npm", "install", "-g", "hyperframes"], timeout=5)

assert excinfo.value.status == 503
assert "upgrade" in excinfo.value.message.lower()
Loading