diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44cee897..3795c63b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Requirements: ## Adding a Skill -Skills live inside their sandbox's `skills/` directory (e.g., `sandboxes/openclaw/skills/my-skill/`). Each skill should include: +Skills live inside their sandbox's `skills/` directory (e.g., `sandboxes/my-sandbox/skills/my-skill/`). Each skill should include: - A `SKILL.md` describing what it does and when to use it - Any supporting files the skill needs - A README with usage examples diff --git a/README.md b/README.md index dd8b2f4b..be06938b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [OpenShell](https://github.com/NVIDIA/OpenShell) is the runtime environment for autonomous agents -- the infrastructure where they live, work, and verify. It provides a programmable factory where agents can generate synthetic data to fix edge cases and safely iterate through thousands of failures in isolated sandboxes. The core engine includes the sandbox runtime, policy engine, gateway (with k3s harness), privacy router, and CLI. -This repo is the community ecosystem around OpenShell -- a hub for contributed skills, sandbox images, launchables, and integrations that extend its capabilities. For the core engine, docs, and published artifacts (PyPI, containers, binaries), see the [OpenShell](https://github.com/NVIDIA/OpenShell) repo. +This repo is the community ecosystem around OpenShell -- a hub for contributed skills, sandbox images, and integrations that extend its capabilities. For the core engine, docs, and published artifacts (PyPI, containers, binaries), see the [OpenShell](https://github.com/NVIDIA/OpenShell) repo. > Alpha software — single-player mode. OpenShell is proof-of-life: one developer, one environment, one gateway. We are building toward multi-tenant enterprise deployments, but the starting point is getting your own environment up and running. Expect rough edges. Bring your agent. @@ -15,7 +15,6 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s | Directory | Description | | ------------ | --------------------------------------------------------------------------------- | -| `brev/` | [Brev](https://brev.dev) launchable for one-click cloud deployment of OpenShell | | `sandboxes/` | Pre-built sandbox images for domain-specific workloads (each with its own skills) | ### Sandboxes @@ -23,10 +22,11 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s | Sandbox | Description | | ----------------------- | ------------------------------------------------------------ | | `sandboxes/base/` | Foundational image with system tools, users, and dev environment | +| `sandboxes/droid/` | Android automation and mobile testing workflows | +| `sandboxes/gemini/` | Gemini CLI workflows | +| `sandboxes/nvidia-gpu/` | GPU-enabled VM sandbox image with NVIDIA userspace tooling | | `sandboxes/ollama/` | Ollama for local and cloud LLMs with Claude Code, Codex, OpenCode pre-installed | | `sandboxes/sdg/` | Synthetic data generation workflows | -| `sandboxes/openclaw/` | OpenClaw -- open agent manipulation and control | -| `sandboxes/nvidia-gpu/` | GPU-enabled VM sandbox image with NVIDIA userspace tooling | ## Getting Started @@ -36,24 +36,13 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s - Docker or a compatible container runtime - NVIDIA GPU with appropriate drivers (for GPU-accelerated images) -### Quick Start with Brev - -Skip the setup and launch OpenShell Community on a fully configured Brev instance, whether you want to use Brev as a remote OpenShell gateway with or without GPU accelerators, or as an all-in-one playground for sandboxes, inference, and UI workflows. - -| Instance | Best For | Deploy | -| -------- | -------- | ------ | -| CPU-only | Remote OpenShell gateway deployments, external inference endpoints, remote APIs, and lighter-weight sandbox workflows | Deploy on Brev | -| NVIDIA H100 | All-in-one OpenShell playgrounds, locally hosted LLM endpoints, GPU-heavy sandboxes, and higher-throughput agent workloads | Deploy on Brev | - -After the Brev instance is ready, access the Welcome UI to inject provider keys and access your Openclaw sandbox. - ### Using Sandboxes ```bash -openshell sandbox create --from openclaw +openshell sandbox create --from ollama ``` -The `--from` flag accepts any sandbox defined under `sandboxes/` (e.g., `openclaw`, `ollama`, `sdg`), a local path, or a container image reference. +The `--from` flag accepts any sandbox defined under `sandboxes/` (e.g., `ollama`, `sdg`), a local path, or a container image reference. ### Ollama Sandbox diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 025d97f6..fd58ef36 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -95,14 +95,6 @@ Package: vim-tiny License: Vim URL: https://packages.ubuntu.com/noble/vim-tiny -================================================================================ -NPM Packages -================================================================================ - -Package: openclaw -License: Apache-2.0 -URL: https://www.npmjs.com/package/openclaw - ================================================================================ GitHub Actions ================================================================================ diff --git a/brev/.gitignore b/brev/.gitignore deleted file mode 100644 index 54affb1c..00000000 --- a/brev/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -brev-start-vm.sh -reset.sh \ No newline at end of file diff --git a/brev/launch.sh b/brev/launch.sh deleted file mode 100755 index 6040400e..00000000 --- a/brev/launch.sh +++ /dev/null @@ -1,732 +0,0 @@ - #!/usr/bin/env bash - - # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - # SPDX-License-Identifier: Apache-2.0 - - set -euo pipefail - - SOURCE_PATH="${BASH_SOURCE[0]-}" - if [[ -z "$SOURCE_PATH" || "$SOURCE_PATH" == "bash" || "$SOURCE_PATH" == "-bash" ]]; then - SCRIPT_DIR="$PWD" - else - SCRIPT_DIR="$(cd "$(dirname "$SOURCE_PATH")" && pwd)" - fi - SCRIPT_REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - REPO_ROOT="" - WELCOME_UI_DIR="" - - PORT="${PORT:-8081}" - CLI_BIN="${CLI_BIN:-}" - CLI_RELEASE_TAG="${CLI_RELEASE_TAG:-devel}" - AUTO_INSTALL_CLI="${AUTO_INSTALL_CLI:-1}" - GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${GITHUB_PAT:-}}}" - COMMUNITY_REPO="${COMMUNITY_REPO:-NVIDIA/OpenShell-Community}" - COMMUNITY_REF="${COMMUNITY_REF:-${COMMUNITY_BRANCH:-}}" - CLONE_ROOT="${CLONE_ROOT:-/home/ubuntu}" - CLONE_DIR="${CLONE_DIR:-$CLONE_ROOT/OpenShell-Community}" - GATEWAY_LOG="${GATEWAY_LOG:-/tmp/openshell-gateway.log}" - WELCOME_UI_LOG="${WELCOME_UI_LOG:-/tmp/welcome-ui.log}" - LAUNCH_LOG="${LAUNCH_LOG:-/tmp/openshell-launch.log}" - WAIT_TIMEOUT_SECS="${WAIT_TIMEOUT_SECS:-30}" - CLI_RETRY_COUNT="${CLI_RETRY_COUNT:-5}" - CLI_RETRY_DELAY_SECS="${CLI_RETRY_DELAY_SECS:-3}" - GHCR_LOGIN="${GHCR_LOGIN:-auto}" - GHCR_USER="${GHCR_USER:-}" - DEFAULT_NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:latest" - if [[ -n "${NEMOCLAW_IMAGE+x}" ]]; then - NEMOCLAW_IMAGE_EXPLICIT=1 - else - NEMOCLAW_IMAGE_EXPLICIT=0 - fi - NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-$DEFAULT_NEMOCLAW_IMAGE}" - SKIP_NEMOCLAW_IMAGE_BUILD="${SKIP_NEMOCLAW_IMAGE_BUILD:-}" - CLUSTER_CONTAINER_NAME="${CLUSTER_CONTAINER_NAME:-openshell-cluster-openshell}" - - mkdir -p "$(dirname "$LAUNCH_LOG")" - touch "$LAUNCH_LOG" - exec > >(tee -a "$LAUNCH_LOG") 2>&1 - - log() { - printf '[launch.sh] %s\n' "$*" - } - - require_non_root() { - if [[ "$(id -u)" -eq 0 ]]; then - log "Do not run the full launcher as root." - log "Run it as the target user and let the script use sudo only where required." - exit 1 - fi - } - - step() { - printf '\n[launch.sh] === %s ===\n' "$*" - } - - require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - log "Missing required command: $1" - exit 1 - fi - } - - repo_has_welcome_ui() { - [[ -d "$1/brev/welcome-ui" ]] - } - - wait_for_tcp_port() { - local port="$1" - local timeout_secs="${2:-30}" - local start_ts - start_ts="$(date +%s)" - - while true; do - if (echo >"/dev/tcp/127.0.0.1/$port") >/dev/null 2>&1; then - return 0 - fi - - if (( "$(date +%s)" - start_ts >= timeout_secs )); then - return 1 - fi - - sleep 1 - done - } - - wait_for_log_pattern() { - local logfile="$1" - local pattern="$2" - local timeout_secs="${3:-30}" - local start_ts - start_ts="$(date +%s)" - - while true; do - if [[ -f "$logfile" ]] && grep -q "$pattern" "$logfile"; then - return 0 - fi - - if (( "$(date +%s)" - start_ts >= timeout_secs )); then - return 1 - fi - - sleep 1 - done - } - - retry_cli() { - local attempt=1 - local max_attempts="${CLI_RETRY_COUNT}" - local delay_secs="${CLI_RETRY_DELAY_SECS}" - - while true; do - if "$@"; then - return 0 - fi - - if (( attempt >= max_attempts )); then - return 1 - fi - - log "Command failed, retrying (${attempt}/${max_attempts}): $*" - sleep "$delay_secs" - attempt=$((attempt + 1)) - done - } - - detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo "x86_64" ;; - aarch64|arm64) echo "aarch64" ;; - *) - log "Unsupported architecture: $(uname -m)" - exit 1 - ;; - esac - } - - ensure_gh() { - if command -v gh >/dev/null 2>&1; then - log "GitHub CLI already installed." - return - fi - - log "Installing GitHub CLI..." - require_cmd sudo - require_cmd apt-get - sudo apt-get update - sudo apt-get install -y gh - } - - gh_auth_if_needed() { - if ! command -v gh >/dev/null 2>&1; then - return - fi - - if gh auth status >/dev/null 2>&1; then - return - fi - - if [[ -z "$GITHUB_TOKEN" ]]; then - log "GitHub CLI is unauthenticated. Continuing without auth." - return - fi - - log "Authenticating GitHub CLI from environment token..." - if ! printf '%s\n' "$GITHUB_TOKEN" | gh auth login --with-token >/dev/null 2>&1; then - log "GitHub authentication failed." - exit 1 - fi - } - - resolve_ghcr_user() { - if [[ -n "$GHCR_USER" ]]; then - return 0 - fi - - if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then - GHCR_USER="$(gh api user -q .login 2>/dev/null || true)" - fi - - if [[ -z "$GHCR_USER" ]]; then - GHCR_USER="${GITHUB_USER:-${USER:-}}" - fi - - [[ -n "$GHCR_USER" ]] - } - - docker_login_ghcr_for_user() { - local login_user="$1" - - if [[ "$login_user" == "root" ]]; then - log "Logging into ghcr.io as $GHCR_USER for root ..." - if printf '%s\n' "$GITHUB_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1; then - log "GHCR login succeeded for root." - return 0 - fi - log "GHCR login failed for root." - return 1 - fi - - log "Logging into ghcr.io as $GHCR_USER for user $login_user ..." - if [[ "$login_user" == "$(id -un)" ]]; then - if printf '%s\n' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1; then - log "GHCR login succeeded for user $login_user." - return 0 - fi - log "GHCR login failed for user $login_user." - return 1 - fi - - if sudo -H -u "$login_user" env GITHUB_TOKEN="$GITHUB_TOKEN" GHCR_USER="$GHCR_USER" bash -lc \ - 'printf "%s\n" "$GITHUB_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1'; then - log "GHCR login succeeded for user $login_user." - return 0 - fi - log "GHCR login failed for user $login_user." - return 1 - } - - docker_login_ghcr_if_needed() { - local login_failed=0 - - if [[ "$GHCR_LOGIN" == "0" || "$GHCR_LOGIN" == "false" || "$GHCR_LOGIN" == "no" ]]; then - log "Skipping GHCR login by configuration." - return - fi - - if [[ -z "$GITHUB_TOKEN" ]]; then - log "No GitHub token available; skipping GHCR login." - return - fi - - if ! command -v docker >/dev/null 2>&1; then - log "Docker not available; skipping GHCR login." - return - fi - - if ! resolve_ghcr_user; then - log "Could not determine GHCR username; skipping GHCR login." - return - fi - - docker_login_ghcr_for_user "root" || login_failed=1 - - if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then - docker_login_ghcr_for_user "$SUDO_USER" || login_failed=1 - elif [[ "$(id -un)" != "root" ]]; then - docker_login_ghcr_for_user "$(id -un)" || login_failed=1 - fi - - if [[ "$login_failed" -ne 0 ]]; then - log "One or more GHCR logins failed. Continuing, but private image pulls may fail." - fi - } - - should_build_nemoclaw_image() { - if [[ "$SKIP_NEMOCLAW_IMAGE_BUILD" == "1" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "true" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "yes" ]]; then - return 1 - fi - [[ -n "$COMMUNITY_REF" && "$COMMUNITY_REF" != "main" ]] - } - - maybe_use_branch_local_nemoclaw_tag() { - if ! should_build_nemoclaw_image; then - return - fi - - if [[ "$NEMOCLAW_IMAGE_EXPLICIT" == "1" || "$NEMOCLAW_IMAGE" != "$DEFAULT_NEMOCLAW_IMAGE" ]]; then - return - fi - - NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:local-dev" - log "Using non-main branch NeMoClaw image tag: $NEMOCLAW_IMAGE" - } - - build_nemoclaw_image_if_needed() { - local docker_cmd=() - local image_context="$REPO_ROOT/sandboxes/openclaw-nvidia" - local dockerfile_path="$image_context/Dockerfile" - - if ! should_build_nemoclaw_image; then - if [[ "$SKIP_NEMOCLAW_IMAGE_BUILD" == "1" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "true" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "yes" ]]; then - log "Skipping local NeMoClaw image build by override (SKIP_NEMOCLAW_IMAGE_BUILD=${SKIP_NEMOCLAW_IMAGE_BUILD})." - else - log "Skipping local NeMoClaw image build (COMMUNITY_REF=${COMMUNITY_REF:-})." - fi - return - fi - - if [[ ! -f "$dockerfile_path" ]]; then - log "NeMoClaw Dockerfile not found: $dockerfile_path" - exit 1 - fi - - if command -v docker >/dev/null 2>&1; then - docker_cmd=(docker) - elif command -v sudo >/dev/null 2>&1; then - docker_cmd=(sudo docker) - else - log "Docker is required to build the NeMoClaw sandbox image." - exit 1 - fi - - log "Building local NeMoClaw image for non-main ref '$COMMUNITY_REF': $NEMOCLAW_IMAGE" - if ! "${docker_cmd[@]}" build \ - --pull \ - --tag "$NEMOCLAW_IMAGE" \ - --file "$dockerfile_path" \ - "$image_context"; then - log "Local NeMoClaw image build failed." - exit 1 - fi - - log "Local NeMoClaw image ready: $NEMOCLAW_IMAGE" - } - - resolve_docker_cmd() { - if command -v docker >/dev/null 2>&1; then - printf 'docker' - return 0 - fi - if command -v sudo >/dev/null 2>&1; then - printf 'sudo docker' - return 0 - fi - return 1 - } - - resolve_cluster_container_name() { - local docker_bin - - if [[ -n "$CLUSTER_CONTAINER_NAME" ]]; then - printf '%s' "$CLUSTER_CONTAINER_NAME" - return 0 - fi - - docker_bin="$(resolve_docker_cmd)" || return 1 - - CLUSTER_CONTAINER_NAME="$($docker_bin ps --format '{{.Names}}\t{{.Image}}' | awk '$1 ~ /^openshell-cluster-/ { print $1; exit }')" - if [[ -z "$CLUSTER_CONTAINER_NAME" ]]; then - CLUSTER_CONTAINER_NAME="$($docker_bin ps --format '{{.Names}}\t{{.Image}}' | awk '$2 ~ /ghcr.io\\/nvidia\\/openshell\\/cluster/ { print $1; exit }')" - fi - - [[ -n "$CLUSTER_CONTAINER_NAME" ]] - } - - import_nemoclaw_image_into_cluster_if_needed() { - local docker_bin cluster_name - - if ! should_build_nemoclaw_image && [[ "$NEMOCLAW_IMAGE_EXPLICIT" != "1" ]]; then - log "Skipping cluster image import; using registry-backed image: $NEMOCLAW_IMAGE" - return - fi - - docker_bin="$(resolve_docker_cmd)" || { - log "Docker not available; skipping cluster image import." - return - } - - if ! $docker_bin image inspect "$NEMOCLAW_IMAGE" >/dev/null 2>&1; then - log "Local NeMoClaw image not present on host; skipping cluster image import: $NEMOCLAW_IMAGE" - return - fi - - if ! cluster_name="$(resolve_cluster_container_name)"; then - log "OpenShell cluster container not found; skipping cluster image import." - return - fi - - log "Importing NeMoClaw image into cluster containerd: $NEMOCLAW_IMAGE -> $cluster_name" - if ! $docker_bin save "$NEMOCLAW_IMAGE" | $docker_bin exec -i "$cluster_name" sh -lc 'ctr -n k8s.io images import -'; then - log "Failed to import NeMoClaw image into cluster containerd." - exit 1 - fi - - if ! $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | awk '{print \$1}' | grep -Fx '$NEMOCLAW_IMAGE' >/dev/null"; then - log "Imported image tag not found in cluster containerd: $NEMOCLAW_IMAGE" - log "Cluster image list:" - $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | grep 'sandboxes/openclaw-nvidia' || true" - exit 1 - fi - - log "Cluster image import complete: $NEMOCLAW_IMAGE" - } - - checkout_repo_ref() { - if [[ -z "$COMMUNITY_REF" ]]; then - return - fi - - require_cmd git - log "Checking out OpenShell-Community ref: $COMMUNITY_REF" - - git -C "$CLONE_DIR" fetch --all --tags --prune - - if git -C "$CLONE_DIR" show-ref --verify --quiet "refs/remotes/origin/$COMMUNITY_REF"; then - git -C "$CLONE_DIR" checkout -B "$COMMUNITY_REF" "origin/$COMMUNITY_REF" - return - fi - - if git -C "$CLONE_DIR" show-ref --verify --quiet "refs/tags/$COMMUNITY_REF"; then - git -C "$CLONE_DIR" checkout --detach "refs/tags/$COMMUNITY_REF" - return - fi - - if git -C "$CLONE_DIR" rev-parse --verify --quiet "$COMMUNITY_REF^{commit}" >/dev/null; then - git -C "$CLONE_DIR" checkout --detach "$COMMUNITY_REF" - return - fi - - git -C "$CLONE_DIR" fetch origin "$COMMUNITY_REF" - git -C "$CLONE_DIR" checkout --detach FETCH_HEAD - } - - clone_repo_if_needed() { - if repo_has_welcome_ui "$CLONE_DIR"; then - log "Using existing repo checkout at $CLONE_DIR" - checkout_repo_ref - return - fi - - require_cmd git - - if [[ -e "$CLONE_DIR" ]]; then - log "Clone target exists but is not a valid repo checkout: $CLONE_DIR" - exit 1 - fi - - mkdir -p "$CLONE_ROOT" - - if [[ -n "$GITHUB_TOKEN" ]]; then - log "Cloning ${COMMUNITY_REPO} into $CLONE_DIR with token auth..." - if [[ -n "$COMMUNITY_REF" ]]; then - git clone --branch "$COMMUNITY_REF" "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" \ - || git clone "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" - else - git clone "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" - fi - else - log "Cloning ${COMMUNITY_REPO} into $CLONE_DIR..." - if [[ -n "$COMMUNITY_REF" ]]; then - git clone --branch "$COMMUNITY_REF" "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" \ - || git clone "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" - else - git clone "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" - fi - fi - - checkout_repo_ref - } - - install_cli_from_release() { - local arch tmpdir repo pattern archive candidate - - ensure_gh - gh_auth_if_needed - - arch="$(detect_arch)" - tmpdir="$(mktemp -d)" - - for candidate in openshell nemoclaw; do - case "$candidate" in - openshell) repo="NVIDIA/OpenShell" ;; - nemoclaw) repo="NVIDIA/NemoClaw" ;; - esac - - pattern="${candidate}-${arch}-unknown-linux-musl.tar.gz" - log "Trying CLI download: ${repo} ${CLI_RELEASE_TAG} ${pattern}" - if gh release download "$CLI_RELEASE_TAG" --repo "$repo" --pattern "$pattern" --dir "$tmpdir" >/dev/null 2>&1; then - archive="$tmpdir/$pattern" - tar xzf "$archive" -C "$tmpdir" - sudo install -m 755 "$tmpdir/$candidate" "/usr/local/bin/$candidate" - CLI_BIN="$candidate" - log "Installed CLI from release: $CLI_BIN" - rm -rf "$tmpdir" - return 0 - fi - done - - rm -rf "$tmpdir" - log "Unable to install CLI from GitHub releases." - exit 1 - } - - resolve_cli() { - log "Checking for installed CLI binaries..." - - if [[ -n "$CLI_BIN" ]]; then - require_cmd "$CLI_BIN" - log "Using CLI from CLI_BIN: $CLI_BIN" - return - fi - - if command -v openshell >/dev/null 2>&1; then - CLI_BIN="openshell" - log "Detected installed CLI: $CLI_BIN" - return - fi - - if command -v nemoclaw >/dev/null 2>&1; then - CLI_BIN="nemoclaw" - log "Detected installed CLI: $CLI_BIN" - return - fi - - if [[ "$AUTO_INSTALL_CLI" != "1" ]]; then - log "Neither openshell nor nemoclaw is installed." - exit 1 - fi - - install_cli_from_release - } - - ensure_cli_compat_aliases() { - local cli_path - - cli_path="$(command -v "$CLI_BIN")" - - if [[ "$CLI_BIN" == "openshell" ]] && ! command -v nemoclaw >/dev/null 2>&1; then - sudo ln -sf "$cli_path" /usr/local/bin/nemoclaw - log "Created compatibility alias: nemoclaw -> openshell" - fi - - if [[ "$CLI_BIN" == "nemoclaw" ]] && ! command -v openshell >/dev/null 2>&1; then - sudo ln -sf "$cli_path" /usr/local/bin/openshell - log "Created compatibility alias: openshell -> nemoclaw" - fi - } - - resolve_repo_root() { - if repo_has_welcome_ui "$SCRIPT_REPO_ROOT"; then - REPO_ROOT="$SCRIPT_REPO_ROOT" - elif repo_has_welcome_ui "$PWD"; then - REPO_ROOT="$PWD" - else - clone_repo_if_needed - REPO_ROOT="$CLONE_DIR" - fi - - WELCOME_UI_DIR="$REPO_ROOT/brev/welcome-ui" - } - - ensure_node() { - if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then - log "Node.js already installed: $(node --version)" - log "npm already installed: $(npm --version)" - return - fi - - log "Installing Node.js LTS via nvm..." - require_cmd curl - curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" - # shellcheck disable=SC1090 - . "$NVM_DIR/nvm.sh" - nvm install --lts - } - - set_inference_route() { - # Set the default inference route to one model (nvidia-endpoints + kimi-k2.5). - # Canonical CLI per brev/welcome-ui/SERVER_ARCHITECTURE.md: cluster inference set - # (CLI_BIN is either "openshell" or "nemoclaw"; both accept the same subcommands.) - # Try canonical first, then "inference set" as fallback for other CLI versions. - log "Configuring inference route..." - - if "$CLI_BIN" cluster inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 --no-verify >/dev/null 2>&1; then - log "Configured inference via '$CLI_BIN cluster inference set'." - return - fi - - if "$CLI_BIN" inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 --no-verify >/dev/null 2>&1; then - log "Configured inference via '$CLI_BIN inference set'." - return - fi - - log "Unable to configure inference route with either 'cluster inference set' or 'inference set'." - exit 1 - } - - run_provider_create_or_replace() { - local name="$1" - shift - - log "Configuring provider: $name" - if retry_cli "$CLI_BIN" provider create --name "$name" "$@" >/dev/null 2>&1; then - log "Created provider: $name" - return - fi - - log "Provider create failed for $name. Replacing existing provider..." - retry_cli "$CLI_BIN" provider delete "$name" >/dev/null 2>&1 || true - retry_cli "$CLI_BIN" provider create --name "$name" "$@" - log "Recreated provider: $name" - } - - wait_for_gateway_cli() { - log "Waiting for gateway CLI operations to stabilize..." - if retry_cli "$CLI_BIN" provider list --names >/dev/null 2>&1; then - log "Gateway CLI is responsive." - return - fi - - log "Gateway CLI did not stabilize. Last gateway log lines:" - tail -n 50 "$GATEWAY_LOG" || true - exit 1 - } - - start_gateway() { - : > "$GATEWAY_LOG" - log "Resetting gateway state if it already exists..." - log "Gateway log: $GATEWAY_LOG" - - if "$CLI_BIN" gateway destroy >> "$GATEWAY_LOG" 2>&1; then - log "Existing gateway destroyed." - else - log "Gateway destroy returned non-zero. Continuing with fresh start." - fi - - log "Starting gateway..." - if ! "$CLI_BIN" gateway start 2>&1 | tee -a "$GATEWAY_LOG"; then - log "Gateway start failed. Last log lines:" - tail -n 50 "$GATEWAY_LOG" || true - exit 1 - fi - - if ! wait_for_log_pattern "$GATEWAY_LOG" "Gateway .* ready\\|Active gateway set" "$WAIT_TIMEOUT_SECS"; then - log "Gateway did not become ready within ${WAIT_TIMEOUT_SECS}s. Last log lines:" - tail -n 50 "$GATEWAY_LOG" || true - exit 1 - fi - - log "Gateway reported ready." - wait_for_gateway_cli - } - - install_ui_deps() { - require_cmd npm - cd "$WELCOME_UI_DIR" - - log "Installing welcome UI dependencies in $WELCOME_UI_DIR" - if [[ -f package-lock.json ]]; then - npm ci - else - npm install - fi - } - - start_welcome_ui() { - cd "$WELCOME_UI_DIR" - - : > "$WELCOME_UI_LOG" - log "Starting welcome UI in background..." - log "Welcome UI log: $WELCOME_UI_LOG" - - nohup env \ - PORT="$PORT" \ - REPO_ROOT="$REPO_ROOT" \ - CLI_BIN="$CLI_BIN" \ - NEMOCLAW_IMAGE="$NEMOCLAW_IMAGE" \ - node server.js >> "$WELCOME_UI_LOG" 2>&1 & - WELCOME_UI_PID=$! - export WELCOME_UI_PID - log "Welcome UI PID: $WELCOME_UI_PID" - - if ! wait_for_tcp_port "$PORT" "$WAIT_TIMEOUT_SECS"; then - log "Welcome UI did not open port $PORT within ${WAIT_TIMEOUT_SECS}s. Last log lines:" - tail -n 100 "$WELCOME_UI_LOG" || true - exit 1 - fi - - log "Welcome UI started at http://localhost:${PORT}" - } - - main() { - require_non_root - require_cmd tar - require_cmd sudo - - step "Resolving repo" - resolve_repo_root - step "Resolving CLI" - resolve_cli - ensure_cli_compat_aliases - maybe_use_branch_local_nemoclaw_tag - step "Authenticating registries" - docker_login_ghcr_if_needed - step "Preparing NeMoClaw image" - build_nemoclaw_image_if_needed - step "Ensuring Node.js" - ensure_node - - log "Using repo root: $REPO_ROOT" - if [[ -n "$COMMUNITY_REF" ]]; then - log "Using community ref: $COMMUNITY_REF" - fi - log "Using CLI: $CLI_BIN" - - step "Starting gateway" - start_gateway - step "Importing NeMoClaw image into cluster" - import_nemoclaw_image_into_cluster_if_needed - - step "Configuring providers" - run_provider_create_or_replace \ - nvidia-endpoints \ - --type nvidia \ - --credential NVIDIA_API_KEY=unused \ - --config NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 - - set_inference_route - - step "Installing welcome UI dependencies" - install_ui_deps - step "Starting welcome UI" - start_welcome_ui - - step "Ready" - log "Gateway log: $GATEWAY_LOG" - log "Welcome UI log: $WELCOME_UI_LOG" - log "Open http://localhost:${PORT}" - } - - main "$@" diff --git a/brev/welcome-ui/.gitignore b/brev/welcome-ui/.gitignore deleted file mode 100644 index fb8a94f9..00000000 --- a/brev/welcome-ui/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -__pycache__/ -*.pyc -.vite/ -*.log diff --git a/brev/welcome-ui/OpenShell-Icon-Logo.svg b/brev/welcome-ui/OpenShell-Icon-Logo.svg deleted file mode 100644 index 91e389d3..00000000 --- a/brev/welcome-ui/OpenShell-Icon-Logo.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - diff --git a/brev/welcome-ui/OpenShell-Icon.svg b/brev/welcome-ui/OpenShell-Icon.svg deleted file mode 100644 index 81bcd2c8..00000000 --- a/brev/welcome-ui/OpenShell-Icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/brev/welcome-ui/SERVER_ARCHITECTURE.md b/brev/welcome-ui/SERVER_ARCHITECTURE.md deleted file mode 100644 index a9543ab6..00000000 --- a/brev/welcome-ui/SERVER_ARCHITECTURE.md +++ /dev/null @@ -1,1393 +0,0 @@ -# OpenShell Welcome UI — `server.py` Complete Architecture Reference - -> **Purpose:** This document provides an exhaustive, implementation-level description of `server.py` so that a software engineer can faithfully recreate it in Node.js with log-streaming support. Every endpoint, state machine, threading model, edge case, and dependency is documented. - ---- - -## Table of Contents - -1. [High-Level Architecture](#1-high-level-architecture) -2. [Configuration & Environment Variables](#2-configuration--environment-variables) -3. [Server Bootstrap & Lifecycle](#3-server-bootstrap--lifecycle) -4. [Routing System](#4-routing-system) -5. [State Machines](#5-state-machines) -6. [API Endpoints — Complete Reference](#6-api-endpoints--complete-reference) -7. [Reverse Proxy (HTTP + WebSocket)](#7-reverse-proxy-http--websocket) -8. [Template Rendering System (YAML → HTML)](#8-template-rendering-system-yaml--html) -9. [Policy Management Pipeline](#9-policy-management-pipeline) -10. [Provider CRUD System](#10-provider-crud-system) -11. [Cluster Inference Management](#11-cluster-inference-management) -12. [Caching Layer](#12-caching-layer) -13. [Brev Integration & URL Building](#13-brev-integration--url-building) -14. [Threading Model](#14-threading-model) -15. [Frontend Contract (app.js)](#15-frontend-contract-appjs) -16. [External CLI Dependencies](#16-external-cli-dependencies) -17. [File Dependencies & Paths](#17-file-dependencies--paths) -18. [Gotchas, Edge Cases & Migration Warnings](#18-gotchas-edge-cases--migration-warnings) -19. [Node.js Migration Checklist](#19-nodejs-migration-checklist) - ---- - -## 1. High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────────────┐ -│ BROWSER (User) │ -│ │ -│ index.html + app.js + styles.css │ -│ │ │ -│ │ fetch() / WebSocket │ -│ ▼ │ -├─────────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ welcome-ui server.py (port 8081) │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ -│ │ │ Static │ │ API Layer │ │ Reverse Proxy │ │ │ -│ │ │ Files │ │ │ │ (HTTP + WebSocket) │ │ │ -│ │ │ │ │ 9 endpoints │ │ │ │ │ -│ │ │ index.html│ │ + CORS │ │ → localhost:18789 │ │ │ -│ │ │ app.js │ │ + JSON I/O │ │ (sandbox) │ │ │ -│ │ │ styles.css│ │ │ │ │ │ │ -│ │ └──────────┘ └──────┬───────┘ └──────────┬───────────┘ │ │ -│ │ │ │ │ │ -│ │ ▼ ▼ │ │ -│ │ ┌────────────────┐ ┌──────────────────────┐ │ │ -│ │ │ nemoclaw CLI │ │ sandbox container │ │ │ -│ │ │ (subprocess) │ │ │ │ │ -│ │ │ │ │ policy-proxy.js:18789│ │ │ -│ │ │ • sandbox │ │ ↓ │ │ │ -│ │ │ • provider │ │ openclaw gw:18788 │ │ │ -│ │ │ • policy │ │ │ │ │ -│ │ │ • cluster │ └──────────────────────┘ │ │ -│ │ └────────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────────┘ -``` - -### Dual-Mode Behavior - -The server operates in **two distinct modes** depending on sandbox readiness: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ REQUEST ARRIVES │ -│ │ │ -│ ▼ │ -│ Is sandbox ready? │ -│ (status == "running" │ -│ OR gateway log sentinel found │ -│ AND port 18789 is open) │ -│ │ │ │ -│ YES NO │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────┐ ┌───────────────────┐ │ -│ │ PROXY MODE │ │ WELCOME UI MODE │ │ -│ │ │ │ │ │ -│ │ Forward ALL │ │ API endpoints │ │ -│ │ requests to │ │ Static files │ │ -│ │ sandbox on │ │ Templated HTML │ │ -│ │ port 18789 │ │ │ │ -│ │ │ │ (index.html with │ │ -│ │ EXCEPT: │ │ YAML modal │ │ -│ │ /api/* still│ │ injected) │ │ -│ │ handled │ │ │ │ -│ │ locally │ │ │ │ -│ └─────────────┘ └───────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -**CRITICAL:** API endpoints (`/api/*`) are ALWAYS handled locally, even in proxy mode. The proxy only kicks in for non-API paths when the sandbox is ready. WebSocket upgrades are always proxied when the sandbox is ready. - ---- - -## 2. Configuration & Environment Variables - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `8081` | Server listen port | -| `REPO_ROOT` | `../../` (relative to `server.py`) | Repository root for locating sandbox config | -| `BREV_ENV_ID` | `""` | Brev cloud environment ID (set by Brev platform) | - -### Derived Paths (Computed at Module Load) - -| Constant | Value | Description | -|----------|-------|-------------| -| `ROOT` | `os.path.dirname(os.path.abspath(__file__))` | Directory containing `server.py` | -| `REPO_ROOT` | env or `ROOT/../../` | Repository root | -| `SANDBOX_DIR` | `REPO_ROOT/sandboxes/openclaw-nvidia` | Sandbox image source directory | -| `POLICY_FILE` | `SANDBOX_DIR/policy.yaml` | Source policy for gateway creation | -| `LOG_FILE` | `/tmp/nemoclaw-sandbox-create.log` | Sandbox creation log (written by subprocess) | -| `PROVIDER_CONFIG_CACHE` | `/tmp/nemoclaw-provider-config-cache.json` | Provider config values cache | -| `OTHER_AGENTS_YAML` | `ROOT/other-agents.yaml` | YAML modal definition file | -| `INFERENCE_PROVIDERS_YAML` | `ROOT/inference-providers.yaml` | Inference provider picker and per-partner instructions | -| `NCP_LOGOS_DIR` | `SANDBOX_DIR/ncp-logos` | Partner and NVIDIA logos served at `/ncp-logos/*` | -| `NEMOCLAW_IMAGE` | `ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:local` | Optional image override | -| `SANDBOX_PORT` | `18789` | Port the sandbox listens on (localhost) | - -### Hardcoded Constants - -| Constant | Value | Purpose | -|----------|-------|---------| -| `_ANSI_RE` | `r"\x1b\[[0-9;]*[a-zA-Z]"` | Regex to strip ANSI escape codes from CLI output | -| `_COPY_BTN_SVG` | SVG markup | Copy button icon injected into YAML-rendered HTML | - ---- - -## 3. Server Bootstrap & Lifecycle - -### Startup Sequence - -``` -main() - │ - ├── 1. _bootstrap_config_cache() - │ If /tmp/nemoclaw-provider-config-cache.json does NOT exist: - │ Write defaults for: - │ - nvidia-endpoints → NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 - │ If it already exists: skip (no-op) - │ - ├── 2. Create ThreadingHTTPServer on ("", PORT) - │ - Binds to all interfaces (0.0.0.0) - │ - Uses the Handler class (extends SimpleHTTPRequestHandler) - │ - ThreadingHTTPServer spawns a new thread per incoming request - │ - └── 3. server.serve_forever() - Blocks the main thread, dispatches requests to Handler threads -``` - -### Handler Initialization - -Each request creates a new `Handler` instance: -- `Handler.__init__` calls `SimpleHTTPRequestHandler.__init__` with `directory=ROOT` -- This means static files are served from the same directory as `server.py` -- Instance variable `_proxy_response = False` tracks whether we're in proxy mode (to suppress CORS/cache headers) - ---- - -## 4. Routing System - -### Master Router: `_route()` - -All HTTP methods (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`) are aliased to `_route()`: - -```python -do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = lambda self: self._route() -def do_OPTIONS(self): return self._route() -``` - -### Routing Priority (Evaluated Top-to-Bottom) - -``` -1. Detect Brev ID from Host header (always, every request) - -2. WebSocket Upgrade + sandbox ready → _proxy_websocket() - -3. OPTIONS → 204 No Content (CORS preflight) - -4. GET /api/sandbox-status → _handle_sandbox_status() -5. GET /api/connection-details → _handle_connection_details() -6. POST /api/install-openclaw → _handle_install_openclaw() -7. POST /api/policy-sync → _handle_policy_sync() -8. POST /api/inject-key → _handle_inject_key() -9. GET /api/providers → _handle_providers_list() -10. POST /api/providers → _handle_provider_create() -11. PUT /api/providers/{name} → _handle_provider_update(name) -12. DELETE /api/providers/{name} → _handle_provider_delete(name) -13. GET /api/cluster-inference → _handle_cluster_inference_get() -14. POST /api/cluster-inference → _handle_cluster_inference_set() - -15. If sandbox ready → _proxy_to_sandbox() [ALL non-API requests] - -16. GET/HEAD for /, /index.html → _serve_templated_index() -17. GET/HEAD for other paths → SimpleHTTPRequestHandler.do_GET() [static files] - -18. Fallback → 404 -``` - -### CRITICAL ROUTING DETAIL - -The path is extracted by splitting on `?` — only the path portion is used for routing: -```python -path = self.path.split("?")[0] -``` - -But the **full** `self.path` (including query string) is forwarded when proxying to the sandbox. - -### Provider Route Matching - -Provider routes use a regex pattern: `r"^/api/providers/[\w-]+$"` -- Matches alphanumeric characters, underscores, and hyphens -- The provider name is extracted via `path.split("/")[-1]` - -### Default Headers (on ALL non-proxy responses) - -``` -Cache-Control: no-cache, no-store, must-revalidate -Access-Control-Allow-Origin: * -Access-Control-Allow-Methods: GET, POST, OPTIONS -Access-Control-Allow-Headers: Content-Type -``` - -These are added in `end_headers()` UNLESS `self._proxy_response` is `True`. - ---- - -## 5. State Machines - -### 5.1 Sandbox State Machine - -``` -Global: _sandbox_state (dict, protected by _sandbox_lock) -{ - "status": "idle" | "creating" | "running" | "error", - "pid": int | None, // PID of the nemoclaw sandbox create process - "url": str | None, // OpenClaw URL (set when running) - "error": str | None, // Error message (set when error) -} -``` - -``` - ┌──────┐ POST /api/install-openclaw - │ idle │ ──────────────────────────────►┌──────────┐ - └──────┘ │ creating │ - ▲ └────┬─────┘ - │ │ - │ ┌──────────┼──────────┐ - │ │ │ │ - │ ▼ │ ▼ - │ ┌─────────┐ │ ┌─────────┐ - │ │ running │ │ │ error │ - │ └─────────┘ │ └─────────┘ - │ │ - │ _sandbox_ready() can also │ - │ transition idle/creating → running │ - │ if gateway log ready + port open │ - └─────────────────────────────────────────┘ - (no automatic recovery from error) -``` - -**State Transition Rules:** - -| From | To | Trigger | -|------|----|---------| -| `idle` | `creating` | `_run_sandbox_create()` starts | -| `creating` | `running` | Gateway log sentinel found + port 18789 open + token extracted | -| `creating` | `error` | Process exits non-zero OR 120s timeout OR exception | -| `idle`/`creating` | `running` | `_sandbox_ready()` detects gateway log + open port (race recovery) | - -**IMPORTANT:** There is NO transition from `error` back to `idle`. A retry requires a page reload / re-trigger from the frontend. The `resetInstall()` in the frontend calls `POST /api/install-openclaw` again, but the server state remains in `error` — the install endpoint only checks for `creating` and `running` (returns 409), so an `error` state allows re-triggering. - -### 5.2 Key Injection State Machine - -``` -Global: _inject_key_state (dict, protected by _inject_key_lock) -{ - "status": "idle" | "injecting" | "done" | "error", - "error": str | None, - "key_hash": str | None, // SHA-256 hex digest of the injected key -} -``` - -``` - ┌──────┐ POST /api/inject-key (new key) - │ idle │ ──────────────────────────────────►┌───────────┐ - └──────┘ │ injecting │ - └─────┬─────┘ - │ - ┌──────────┼──────────┐ - │ │ - ▼ ▼ - ┌──────────┐ ┌─────────┐ - │ done │ │ error │ - └──────────┘ └─────────┘ - │ │ - │ POST /api/inject-key - │ (different key) - └──────────►┌───────────┐ - │ injecting │ - └───────────┘ -``` - -**Key deduplication:** If the same key (by SHA-256 hash) is submitted: -- While `injecting` → returns `202 {"ok": true, "started": true}` (no new thread) -- While `done` → returns `200 {"ok": true, "already": true}` (no new thread) - ---- - -## 6. API Endpoints — Complete Reference - -### 6.1 `GET /api/sandbox-status` - -**Purpose:** Poll sandbox readiness and key injection status. - -**Side Effects:** May transition sandbox state from `idle`/`creating` to `running` if readiness signals are detected. - -**Response (200):** -```json -{ - "status": "idle" | "creating" | "running" | "error", - "url": "https:///#token=abc123" | null, - "error": "error message" | null, - "key_injected": true | false, - "key_inject_error": "error message" | null -} -``` - -**Readiness Check Logic (executed EVERY poll):** -1. Read `_sandbox_state` under lock -2. If status is `creating` or `idle`: - a. Check if `LOG_FILE` contains sentinel string `"OpenClaw gateway starting in background"` - b. Check if port 18789 is open via TCP connect (1s timeout) - c. If BOTH true → read token from log, build URL, transition to `running` -3. Read `_inject_key_state` under lock for `key_injected` and `key_inject_error` - -**IMPORTANT:** The sandbox URL is built using `_build_openclaw_url(token)` which points to the welcome-ui server itself (port 8081), NOT directly to port 18789. This is because the welcome-ui reverse-proxies to the sandbox, keeping the browser on a single origin. - ---- - -### 6.2 `POST /api/install-openclaw` - -**Purpose:** Trigger sandbox creation in a background thread. - -**Request Body:** None required (Content-Type: application/json header sent by frontend but body is empty). - -**Guard Conditions:** -- If status is `creating` → `409 {"ok": false, "error": "Sandbox is already being created"}` -- If status is `running` → `409 {"ok": false, "error": "Sandbox is already running"}` -- Status `idle` or `error` → proceeds - -**Response (200):** -```json -{"ok": true} -``` - -**Background Thread (`_run_sandbox_create`):** - -``` -Step 1: Set state to "creating" -Step 2: _cleanup_existing_sandbox() - → runs: openshell sandbox delete openclaw-nvidia - → ignores all errors (best-effort cleanup) -Step 3: Build chat UI URL (no token yet) -Step 4: _generate_gateway_policy() - → Read POLICY_FILE (sandboxes/openclaw-nvidia/policy.yaml) - → Strip "inference" and "process" fields from the YAML - → Write stripped YAML to a tempfile - → Return tempfile path (or None if source not found) -Step 5: Build and run command: - openshell sandbox create \ - --name openclaw-nvidia \ - --from nemoclaw \ - --forward 18789 \ - [--policy ] \ - -- env CHAT_UI_URL= openclaw-nvidia-start -Step 6: Stream stdout (merged with stderr) to LOG_FILE and to stderr - → Uses subprocess.Popen with stdout=PIPE, stderr=STDOUT - → A daemon thread reads lines and writes to both destinations -Step 7: Wait for process to exit -Step 8: If exit code != 0 → status = "error", store last 2000 chars of log -Step 9: If exit code == 0 → poll for readiness (120s deadline): - Loop every 3s: - - Check _gateway_log_ready() (sentinel in log file) - - Check _port_open("127.0.0.1", 18789) - - If both: extract token from log, build URL, status = "running" - If deadline expires → status = "error", "Timed out..." -Step 10: Cleanup temp policy file -``` - -**CRITICAL DETAILS:** -- `start_new_session=True` on the Popen call — the subprocess gets its own process group -- The streamer thread is a daemon thread — won't prevent server shutdown -- Policy file cleanup happens even if the process fails -- Token extraction retries up to 5 times with 1s delays after readiness is detected - ---- - -### 6.3 `POST /api/inject-key` - -**Purpose:** Asynchronously update the OpenShell provider credential with an API key. - -**Request Body:** -```json -{"key": "nvapi-xxxxx"} -``` - -**Validation:** -- Empty body → `400 {"ok": false, "error": "empty body"}` -- Invalid JSON → `400 {"ok": false, "error": "invalid JSON"}` -- Missing/empty key → `400 {"ok": false, "error": "missing key"}` - -**Deduplication (by SHA-256 hash of the key):** -- Same key already done → `200 {"ok": true, "already": true}` -- Same key currently injecting → `202 {"ok": true, "started": true}` - -**Response (202):** -```json -{"ok": true, "started": true} -``` - -**Background Thread (`_run_inject_key`):** -``` -Step 1: Log receipt (hash prefix) -Step 2: Run CLI command: - nemoclaw provider update nvidia-endpoints \ - --credential NVIDIA_API_KEY= \ - --config NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 - Timeout: 120s -Step 3: If success: - - Cache config under name "nvidia-endpoints" - - State → "done" - If failure: - - State → "error" with stderr/stdout message -``` - ---- - -### 6.4 `POST /api/policy-sync` - -**Purpose:** Push a policy YAML to the OpenShell gateway via the host-side CLI. - -**Request Body:** Raw YAML text (Content-Type is not checked, but body is read as UTF-8). - -**Validation:** -- Empty body (Content-Length: 0) → `400 {"ok": false, "error": "empty body"}` -- Missing `version:` field in body text → `400 {"ok": false, "error": "invalid policy: missing version field"}` - -**Processing Pipeline (`_sync_policy_to_gateway`):** -``` -Step 1: Read request body -Step 2: Strip "inference" and "process" fields from the YAML - → Uses _strip_policy_fields(yaml_text, extra_fields=("process",)) - → If PyYAML available: parse → remove keys → dump - → If PyYAML unavailable: line-by-line regex stripping -Step 3: Write stripped YAML to tempfile -Step 4: Run CLI: - nemoclaw policy set nemoclaw --policy - Timeout: 30s -Step 5: Parse output for version number and policy hash: - → regex: r"version\s+(\d+)" - → regex: r"hash:\s*([a-f0-9]+)" -Step 6: Cleanup tempfile (always, even on failure — in finally block) -``` - -**Response (200 on success, 502 on failure):** -```json -// Success: -{"ok": true, "applied": true, "version": 3, "policy_hash": "abc123def"} - -// Failure: -{"ok": false, "error": "CLI error message"} -``` - ---- - -### 6.5 `GET /api/connection-details` - -**Purpose:** Return hostname and connection instructions for CLI users. - -**No request body.** - -**Response (200):** -```json -{ - "hostname": "my-host.example.com", - "gatewayUrl": "https://8080-xxx.brevlab.com", - "gatewayPort": 8080, - "instructions": { - "install": "curl -fsSL https://github.com/NVIDIA/OpenShell/releases/download/devel/install.sh | sh", - "connect": "openshell gateway add https://8080-xxx.brevlab.com", - "createSandbox": "openshell sandbox create -- claude", - "tui": "openshell term" - } -} -``` - -**URL Building:** -- If Brev ID available → `https://8080-{brev_id}.brevlab.com` -- Otherwise → `http://{hostname}:8080` - -**Hostname Resolution:** -1. Try `hostname -f` (subprocess, 5s timeout) -2. Fallback to `socket.getfqdn()` - ---- - -### 6.6 `GET /api/providers` - -**Purpose:** List all configured OpenShell providers with their details. - -**Processing:** -``` -Step 1: Run: nemoclaw provider list --names - → Parse output: one provider name per line -Step 2: For each name, run: nemoclaw provider get - → Parse structured text output (see parsing below) -Step 3: Merge with config cache values -``` - -**Provider Detail Parsing (`_parse_provider_detail`):** - -The CLI outputs text like: -``` -Id: abc-123 -Name: nvidia-endpoints -Type: nvidia -Credential keys: NVIDIA_API_KEY -Config keys: NVIDIA_BASE_URL -``` - -Parsing rules: -- Lines are ANSI-stripped first -- Each line is matched by prefix: `Id:`, `Name:`, `Type:`, `Credential keys:`, `Config keys:` -- `Credential keys` and `Config keys` are comma-separated lists -- Value `` maps to empty array -- If `Name:` is not found in output → parsed result is `None` (provider skipped) - -**Config Cache Merge:** -After parsing, if the provider name has an entry in the config cache, a `configValues` key is added to the provider object. - -**Response (200):** -```json -{ - "ok": true, - "providers": [ - { - "id": "abc-123", - "name": "nvidia-endpoints", - "type": "nvidia", - "credentialKeys": ["NVIDIA_API_KEY"], - "configKeys": ["NVIDIA_BASE_URL"], - "configValues": {"NVIDIA_BASE_URL": "https://integrate.api.nvidia.com/v1"} - } - ] -} -``` - -**Error Response (502):** -```json -{"ok": false, "error": "CLI error message"} -``` - ---- - -### 6.7 `POST /api/providers` - -**Purpose:** Create a new provider. - -**Request Body:** -```json -{ - "name": "my-provider", - "type": "openai", - "credentials": {"OPENAI_API_KEY": "sk-xxx"}, - "config": {"OPENAI_BASE_URL": "https://api.openai.com/v1"} -} -``` - -**Validation:** -- No body or invalid JSON → `400` -- Missing `name` or `type` → `400 {"ok": false, "error": "name and type are required"}` - -**IMPORTANT QUIRK:** If no credentials are provided, a placeholder is used: -``` ---credential PLACEHOLDER=unused -``` -This is because the `nemoclaw provider create` CLI requires at least one credential argument. - -**CLI Command:** -``` -nemoclaw provider create --name --type \ - --credential KEY1=VAL1 --credential KEY2=VAL2 \ - --config KEY1=VAL1 --config KEY2=VAL2 -``` - -**Side Effect:** Config values are cached if provided. - -**Response (200):** `{"ok": true}` -**Error (400/502):** `{"ok": false, "error": "..."}` - ---- - -### 6.8 `PUT /api/providers/{name}` - -**Purpose:** Update an existing provider. - -**Request Body:** -```json -{ - "type": "openai", - "credentials": {"OPENAI_API_KEY": "sk-new-key"}, - "config": {"OPENAI_BASE_URL": "https://api.openai.com/v1"} -} -``` - -**Validation:** -- No body or invalid JSON → `400` -- Missing `type` → `400 {"ok": false, "error": "type is required"}` - -**CLI Command:** -``` -nemoclaw provider update --type \ - --credential KEY1=VAL1 \ - --config KEY1=VAL1 -``` - -**Side Effect:** Config values are cached if provided. - -**Response (200):** `{"ok": true}` - ---- - -### 6.9 `DELETE /api/providers/{name}` - -**Purpose:** Delete a provider. - -**CLI Command:** -``` -nemoclaw provider delete -``` - -**Side Effect:** Removes provider from config cache. - -**Response (200):** `{"ok": true}` - ---- - -### 6.10 `GET /api/cluster-inference` - -**Purpose:** Get current cluster inference configuration. - -**CLI Command:** -``` -nemoclaw cluster inference get -``` - -**Output Parsing (`_parse_cluster_inference`):** -``` -Provider: nvidia-endpoints -Model: meta/llama-3.1-70b-instruct -Version: 2 -``` -- Lines are ANSI-stripped -- Matched by prefix: `Provider:`, `Model:`, `Version:` -- Version is parsed as integer (defaults to 0) - -**Special Case:** If CLI returns non-zero and stderr contains "not configured" or "not found": -```json -{"ok": true, "providerName": null, "modelId": "", "version": 0} -``` - -**Response (200):** -```json -{ - "ok": true, - "providerName": "nvidia-endpoints", - "modelId": "meta/llama-3.1-70b-instruct", - "version": 2 -} -``` - ---- - -### 6.11 `POST /api/cluster-inference` - -**Purpose:** Set cluster inference configuration. - -**Request Body:** -```json -{ - "providerName": "nvidia-endpoints", - "modelId": "meta/llama-3.1-70b-instruct" -} -``` - -**Validation:** -- Missing `providerName` → `400` -- Missing `modelId` → `400` - -**CLI Command:** -``` -nemoclaw cluster inference set --provider --model -``` - -**Response (200):** -```json -{ - "ok": true, - "providerName": "nvidia-endpoints", - "modelId": "meta/llama-3.1-70b-instruct", - "version": 3 -} -``` - ---- - -## 7. Reverse Proxy (HTTP + WebSocket) - -### 7.1 HTTP Proxy (`_proxy_to_sandbox`) - -**Triggered when:** `_sandbox_ready()` returns `True` AND the request path is NOT an `/api/*` route. - -**Flow:** -``` -1. Open HTTP connection to 127.0.0.1:18789 (timeout=120s) -2. Read request body if Content-Length header exists -3. Copy all request headers EXCEPT: - - "Host" → replaced with "127.0.0.1:18789" -4. Forward request (method, path+query, body, headers) to upstream -5. Read complete upstream response body -6. Set _proxy_response = True (suppresses CORS/cache headers) -7. Write response status, non-hop-by-hop headers, and Content-Length -8. Write response body -9. Close connection -``` - -**Hop-by-Hop Headers Filtered:** -```python -frozenset(("connection", "keep-alive", "proxy-authenticate", - "proxy-authorization", "te", "trailers", - "transfer-encoding", "upgrade")) -``` - -**IMPORTANT:** `Content-Length` from the upstream response is ALSO filtered and replaced with the actual length of `resp_body`. This handles cases where the upstream uses chunked encoding. - -**Error Handling:** If anything fails → `502 "Sandbox unavailable"`. Connection is always closed after proxy. - -**CRITICAL for Node.js:** The Python implementation reads the ENTIRE response body into memory before forwarding. For log streaming support, the Node.js version should use `pipe()` / streaming instead. - -### 7.2 WebSocket Proxy (`_proxy_websocket`) - -**Triggered when:** `Upgrade: websocket` header is present AND `_sandbox_ready()` returns `True`. - -**This is checked BEFORE any API route matching — WebSocket upgrades take priority.** - -**Flow:** -``` -1. Open raw TCP connection to 127.0.0.1:18789 (timeout=5s) -2. Reconstruct the HTTP upgrade request manually: - - Request line: "GET /path HTTP/1.1\r\n" - - All headers forwarded, EXCEPT Host → replaced with "127.0.0.1:18789" - - Terminated by "\r\n" -3. Send raw bytes to upstream -4. Create two daemon threads for bidirectional piping: - - Thread 1: client → upstream (recv 64KB chunks, sendall) - - Thread 2: upstream → client (recv 64KB chunks, sendall) -5. Join both threads with 7200s (2 hour) timeout -6. Close upstream socket -7. Set self.close_connection = True -``` - -**Error Handling:** -- Connection failure → `502 "Sandbox unavailable"` -- Pipe errors silently caught (connection broken = normal WS close) -- `socket.SHUT_WR` called on the destination when source closes - -**CRITICAL for Node.js:** -- The `connection` object (`self.connection`) is the raw socket from the HTTP server -- The Python implementation manually reconstructs HTTP headers — Node.js `http` module provides the `upgrade` event with `socket` and `head` buffer which simplifies this -- The 64KB chunk size (`65536`) is a performance consideration -- The 2-hour timeout is important for long-running WebSocket connections - ---- - -## 8. Template Rendering System (YAML → HTML) - -### Overview - -The server renders `other-agents.yaml` into HTML at startup and injects it into `index.html`, replacing the `{{OTHER_AGENTS_MODAL}}` placeholder. - -### Caching - -```python -_rendered_index: str | None = None # Module-level cache -``` - -The rendered HTML is cached globally and only computed once (on first request). This means changes to `other-agents.yaml` or `index.html` require a server restart. - -### YAML Schema (`other-agents.yaml`) - -```yaml -title: "Modal Title" # Modal heading -intro: "Introductory paragraph text" # Supports raw HTML -steps: # Array of instruction sections - - title: "Step Title" # Auto-numbered (1., 2., etc.) - commands: # Commands shown in code block - - "plain command string" # Simple string → - - cmd: "command text" # Dict form with optional fields - comment: "Comment above cmd" # → # Comment - id: "html-element-id" # → id attribute on - copyable: false # Show copy button? (default: false) - copy_button_id: "btn-id" # HTML id for the copy button - block_id: "block-id" # HTML id for the code-block div - description: "Text below block" # Supports raw HTML -``` - -### Rendering Rules - -1. **Commands** are rendered inside a `
`: - - String commands → `{html_escaped}` - - Dict commands with `comment` → `# {html_escaped}` on separate line - - Dict commands with `id` → `{html_escaped}` - - Multiple commands in a step are separated by double newlines (`\n\n`) - - Multiple entries within a single command dict are separated by single newlines - -2. **Copy buttons** logic: - - If `copyable: true` AND `copy_button_id` is set → button with that ID - - If `copyable: true` AND single command AND no button ID → button with `data-copy="{raw_cmd}"` - - If `copyable: true` AND multiple commands AND no button ID → button with no data-copy (copies entire block text) - -3. **HTML escaping:** All command text and comments are escaped via `html.escape()`. - -4. **Fallback:** If YAML fails to parse or PyYAML is not installed, the placeholder is replaced with an HTML comment: `` - -### Inference Provider Picker (`inference-providers.yaml`) - -The server also renders `inference-providers.yaml` into HTML and injects it into `index.html`, replacing the `{{INFERENCE_PROVIDER_PICKER}}` placeholder. This provides: - -- **Picker screen:** "Choose your inference provider" with NVIDIA (free) in its own row and paid partners in a 5×2 grid. Each tile has `data-provider-id` for JS. -- **Partner instruction blocks:** For each partner, a `div#provider-instructions-{id}` with title, intro, and steps (same schema as other-agents steps). Shown when the user clicks a partner. - -**YAML schema:** Top-level `nvidia: { displayName, logoFile }` and `partners: [ { id, name, logoFile, instructions: { title, intro?, steps[] } } ]`. Logo filenames refer to files under `NCP_LOGOS_DIR`, served at `GET /ncp-logos/`. - -**Fallback:** If the file is missing or invalid, the placeholder is replaced with `` and the Install OpenClaw modal shows only the NVIDIA API key view (no picker). - -### NCP Logos Route (`GET /ncp-logos/*`) - -In welcome-ui mode (before sandbox is ready), `GET /ncp-logos/` serves static files from `SANDBOX_DIR/ncp-logos/`. Path traversal is rejected; only files under that directory are served. Used for NVIDIA and partner logos in the provider picker. MIME type for `.webp` is `image/webp`. - ---- - -## 9. Policy Management Pipeline - -### Policy Field Stripping (`_strip_policy_fields`) - -This function removes top-level YAML fields that the gateway doesn't understand: -- Always removes: `inference` -- Optionally removes additional fields (e.g., `process`) - -**Two implementations (auto-selected):** - -1. **PyYAML available:** Parse → dict.pop() → dump - - Preserves YAML structure perfectly - - `default_flow_style=False, sort_keys=False` for readable output - -2. **PyYAML unavailable:** Line-by-line regex stripping - - Detects top-level keys by matching `^{key}:` at line start - - Skips all indented continuation lines (starts with space/tab or is blank) - - Stops skipping when a non-indented, non-blank line is found - -### Gateway Policy Generation (`_generate_gateway_policy`) - -Used during sandbox creation only: -1. Read `POLICY_FILE` (source policy.yaml) -2. Strip `inference` and `process` fields -3. Write to a temp file (`tempfile.mkstemp`) -4. Return temp file path (caller must delete) - -### Policy Sync (`_sync_policy_to_gateway`) - -Used for runtime policy updates: -1. Strip `inference` and `process` fields from incoming YAML -2. Write to temp file -3. Run `nemoclaw policy set nemoclaw --policy ` (30s timeout) -4. Parse output for version and hash -5. Always delete temp file (in `finally` block) - ---- - -## 10. Provider CRUD System - -### Architecture - -``` -┌──────────────────────────────────────────────────────┐ -│ Provider CRUD │ -│ │ -│ ┌─────────────┐ ┌──────────────────────────┐ │ -│ │ nemoclaw CLI │◄──│ server.py subprocess calls│ │ -│ │ │ │ │ │ -│ │ provider │ │ CREATE: --name --type │ │ -│ │ list │ │ --credential │ │ -│ │ get │ │ --config │ │ -│ │ create │ │ UPDATE: name --type │ │ -│ │ update │ │ --credential │ │ -│ │ delete │ │ --config │ │ -│ └─────────────┘ │ DELETE: name │ │ -│ └──────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Config Value Cache (JSON file) │ │ -│ │ /tmp/nemoclaw-provider-config-cache.json │ │ -│ │ │ │ -│ │ The CLI does NOT return config VALUES, │ │ -│ │ only config KEYS. So we cache values on │ │ -│ │ create/update and merge them into GET │ │ -│ │ responses. │ │ -│ └──────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ -``` - -### Why the Cache Exists - -The `nemoclaw provider get` CLI only returns config **key names**, not their values. The server maintains a separate JSON file cache to remember config values that were set during `create` and `update` operations. This cache is: -- Read on every `GET /api/providers` request -- Written on every `POST` (create) and `PUT` (update) that includes config values -- Cleaned up on `DELETE` -- Bootstrapped at server startup with a default for `nvidia-endpoints` - ---- - -## 11. Cluster Inference Management - -Simple CRUD wrapper around: -- `nemoclaw cluster inference get` -- `nemoclaw cluster inference set --provider --model ` - -Output is parsed the same way as provider detail (line-by-line, prefix matching, ANSI stripping). - ---- - -## 12. Caching Layer - -### Provider Config Cache - -**File:** `/tmp/nemoclaw-provider-config-cache.json` - -**Format:** -```json -{ - "nvidia-endpoints": { - "NVIDIA_BASE_URL": "https://integrate.api.nvidia.com/v1" - }, - "my-custom-provider": { - "CUSTOM_URL": "https://example.com" - } -} -``` - -**Operations:** -| Function | Behavior | -|----------|----------| -| `_read_config_cache()` | Read JSON file, return `{}` on `FileNotFoundError` or `JSONDecodeError` | -| `_write_config_cache(cache)` | Write JSON file, silently ignore `OSError` | -| `_cache_provider_config(name, config)` | Read → merge → write | -| `_remove_cached_provider(name)` | Read → pop → write | -| `_bootstrap_config_cache()` | Only writes default if file doesn't exist | - -### Rendered Index Cache - -**Variable:** `_rendered_index` (module-level `str | None`) - -Computed once on first request, never invalidated. Contains the full `index.html` with the YAML modal HTML injected. - ---- - -## 13. Brev Integration & URL Building - -### Brev ID Detection - -The server needs the Brev environment ID to build externally-reachable URLs. It obtains this from two sources: - -1. **Environment Variable:** `BREV_ENV_ID` (set by the Brev platform at container start) -2. **Host Header Detection:** Extracted from incoming request `Host` headers matching `\d+-(.+?)\.brevlab\.com` - -```python -def _extract_brev_id(host: str) -> str: - """Example: '80810-abcdef123.brevlab.com' → 'abcdef123'""" - match = re.match(r"\d+-(.+?)\.brevlab\.com", host) - return match.group(1) if match else "" -``` - -Detection is **idempotent** — once a Brev ID is detected from a Host header, it's cached globally and never overwritten. - -### URL Building - -`buildOpenclawUrl(token, req)` is now request-aware and prefers the browser-visible welcome UI origin. - -Resolution order: - -1. `CHAT_UI_URL` environment override, if set -2. `X-Forwarded-Proto` + `X-Forwarded-Host` from the incoming request -3. Incoming request `Host` -4. Last detected public welcome UI base URL cached from prior requests -5. Brev fallback: `https://80810-{brev_id}.brevlab.com/` -6. Local fallback: `http://127.0.0.1:{PORT}/` - -If a token is present, it is appended as a URL fragment: `#token=...` - -**The URL points to the welcome-ui server itself**, not directly to port 18789. This is critical because: -- Brev's port-forwarding creates subdomains per port -- Cross-origin requests between Brev port subdomains are blocked -- By proxying through port 8081, the browser stays on one origin - -### Connection Details URL (for CLI users) - -``` -Gateway URL: - If Brev ID: https://8080-{brev_id}.brevlab.com - Else: http://{hostname}:8080 -``` - -This is a DIFFERENT port (8080) — the OpenShell gateway itself, not the welcome-ui. - ---- - -## 14. Threading Model - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ THREADING MODEL │ -│ │ -│ Main Thread │ -│ └── server.serve_forever() │ -│ └── ThreadingHTTPServer spawns one thread per request │ -│ │ -│ Background Threads (daemon=True): │ -│ ├── _run_sandbox_create (spawned by POST /api/install-openclaw)│ -│ │ └── _stream_output (reads subprocess stdout → log file) │ -│ ├── _run_inject_key (spawned by POST /api/inject-key) │ -│ └── (WebSocket pipe threads) (two per WS connection) │ -│ │ -│ Locks: │ -│ ├── _sandbox_lock (protects _sandbox_state dict) │ -│ └── _inject_key_lock (protects _inject_key_state dict) │ -│ │ -│ IMPORTANT: No lock protects the config cache file. │ -│ Concurrent writes could corrupt it (unlikely in practice). │ -└──────────────────────────────────────────────────────────────────┘ -``` - -**Key Threading Details:** -- `ThreadingHTTPServer` = one thread per connection (not per request) -- All background threads are daemon threads → they die when the main thread exits -- The subprocess for sandbox creation uses `start_new_session=True` → it gets its own process group and survives if the server thread dies -- WebSocket pipe threads have a 2-hour (7200s) join timeout -- The `_stream_output` thread for subprocess output uses line-buffered reads - ---- - -## 15. Frontend Contract (app.js) - -### API Call Flow - -``` -Page Load - │ - ├── checkExistingSandbox() - │ GET /api/sandbox-status - │ → If "running" + url: show modal, mark ready - │ → If "creating": show modal, start polling - │ - ├── User clicks "Install OpenClaw" card - │ → Show install modal - │ → triggerInstall() - │ POST /api/install-openclaw - │ → On success: startPolling() - │ - ├── Polling (every 3000ms) - │ GET /api/sandbox-status - │ → "running": mark ready, update UI - │ → "error": show error, stop polling - │ → "creating": keep polling - │ - ├── User types API key - │ → Debounced (300ms) submitKeyForInjection() - │ POST /api/inject-key {key: "nvapi-..."} - │ → keyInjected tracked via sandbox-status polling - │ - ├── When sandboxReady + keyValid + keyInjected: - │ → "Open OpenShell" button enabled - │ → Click opens: sandboxUrl + ?nvapi= in new tab - │ - └── User clicks "Other Agents" card - → loadConnectionDetails() - GET /api/connection-details - → Show instructions modal -``` - -### Five-State CTA Button - -| State | Condition | Label | Enabled | -|-------|-----------|-------|---------| -| 1 | API key empty + tasks running | "Waiting for API key..." | No | -| 2 | API key valid + tasks running | "Provisioning Sandbox..." | No (spinner) | -| 3 | API key empty + tasks done | "Waiting for API key..." | No | -| 4 | API key valid + sandbox ready + key not injected | "Configuring API key..." | No (spinner) | -| 5 | API key valid + sandbox ready + key injected | "Open OpenShell" | Yes | - -### API Key Validation - -```javascript -function isApiKeyValid() { - const v = apiKeyInput.value.trim(); - return v.startsWith("nvapi-") || v.startsWith("sk-"); -} -``` - -Accepts NVIDIA API keys (`nvapi-`) and OpenAI-style keys (`sk-`). - ---- - -## 16. External CLI Dependencies - -All CLI commands are executed via `subprocess.run()` or `subprocess.Popen()`. Every command below MUST be available on the system `PATH`: - -| Command | Timeout | Used By | -|---------|---------|---------| -| `openshell sandbox create --name ... --from ... --forward ... [--policy ...] -- env ... openclaw-nvidia-start` | None (Popen, waited manually) | `_run_sandbox_create` | -| `openshell sandbox delete openclaw-nvidia` | 30s | `_cleanup_existing_sandbox` | -| `nemoclaw provider list --names` | 30s | `_handle_providers_list` | -| `nemoclaw provider get ` | 30s | `_handle_providers_list` | -| `nemoclaw provider create --name --type --credential K=V --config K=V` | 30s | `_handle_provider_create` | -| `nemoclaw provider update --type --credential K=V --config K=V` | 30s | `_handle_provider_update`, `_run_inject_key` | -| `nemoclaw provider delete ` | 30s | `_handle_provider_delete` | -| `nemoclaw policy set --policy ` | 30s | `_sync_policy_to_gateway` | -| `nemoclaw cluster inference get` | 30s | `_handle_cluster_inference_get` | -| `nemoclaw cluster inference set --provider

--model ` | 30s | `_handle_cluster_inference_set` | -| `hostname -f` | 5s | `_get_hostname` | - ---- - -## 17. File Dependencies & Paths - -### Files Read - -| Path | When | Required | -|------|------|----------| -| `ROOT/index.html` | First request to `/` | Yes | -| `ROOT/other-agents.yaml` | First request to `/` | No (graceful fallback) | -| `ROOT/inference-providers.yaml` | First request to `/` | No (graceful fallback) | -| `SANDBOX_DIR/ncp-logos/*` | `GET /ncp-logos/` | No (optional logos) | -| `ROOT/styles.css` | Static file serving | Yes (for UI) | -| `ROOT/app.js` | Static file serving | Yes (for UI) | -| `SANDBOX_DIR/policy.yaml` | Sandbox creation | No (graceful fallback) | -| `/tmp/nemoclaw-sandbox-create.log` | Readiness checks, token extraction | Created by server | -| `/tmp/nemoclaw-provider-config-cache.json` | Provider CRUD | Created by server | - -### Files Written - -| Path | When | Format | -|------|------|--------| -| `/tmp/nemoclaw-sandbox-create.log` | During sandbox creation | Text (subprocess output) | -| `/tmp/nemoclaw-provider-config-cache.json` | Provider CRUD, bootstrap | JSON | -| `/tmp/sandbox-policy-*.yaml` | Sandbox creation (temp) | YAML | -| `/tmp/policy-sync-*.yaml` | Policy sync (temp) | YAML | - -### Token Extraction from Log - -```python -re.search(r"token=([A-Za-z0-9_\-]+)", content) -``` - -The token is found in URLs printed by the `openclaw-nvidia-start.sh` script inside the sandbox. - -### Gateway Readiness Sentinel - -```python -"OpenClaw gateway starting in background" in f.read() -``` - -This exact string is printed by `openclaw-nvidia-start.sh` after the OpenClaw gateway has been backgrounded. - ---- - -## 18. Gotchas, Edge Cases & Migration Warnings - -### 18.1 Proxy Mode Suppresses Default Headers - -When `_proxy_response = True`, the `end_headers()` method does NOT add CORS or Cache-Control headers. This flag is set to `True` before writing proxy response headers and reset to `False` in the `finally` block. If this is not handled correctly, proxy responses will get double headers. - -### 18.2 WebSocket Detection Before Route Matching - -WebSocket upgrade requests are checked BEFORE any API route. This means if a WebSocket upgrade request is sent to `/api/sandbox-status`, it will be proxied to the sandbox (if ready) instead of handled as an API call. This is intentional — the sandbox's OpenClaw UI uses WebSockets. - -### 18.3 Sandbox Ready Check Is Polled from Multiple Paths - -`_sandbox_ready()` is called: -1. In the routing function (to decide proxy vs. welcome-ui mode) -2. In `/api/sandbox-status` handler (with slightly different logic) -3. Both can trigger the `idle`/`creating` → `running` transition - -This means the sandbox can be detected as running even if `_run_sandbox_create` hasn't finished its own polling loop yet. - -### 18.4 No Body Parsing for OPTIONS - -OPTIONS requests return `204` immediately with CORS headers. No body parsing occurs. - -### 18.5 Config Cache Race Condition - -The provider config cache (`/tmp/nemoclaw-provider-config-cache.json`) has no file locking. Concurrent requests that modify different providers could overwrite each other's changes. In practice this is rare since provider CRUD is typically sequential. - -### 18.6 ANSI Stripping Is Critical - -All CLI output parsing MUST strip ANSI escape codes first. The `nemoclaw` CLI may use colored output even when stdout is a pipe. The regex used: -``` -\x1b\[[0-9;]*[a-zA-Z] -``` - -### 18.7 Policy Stripping Has Two Code Paths - -If PyYAML is not installed, the policy field stripping falls back to regex-based line stripping. The Node.js version should always use a YAML parser (like `js-yaml`) since it will be available in the Node ecosystem. - -### 18.8 Subprocess Environment - -The sandbox creation subprocess inherits the full environment (`os.environ.copy()`). No additional env vars are injected through the subprocess env — they're passed via the `-- env VAR=VAL` syntax in the command itself. - -### 18.9 The `--from` Flag Changed - -The code has a commented-out line: -```python -# "--from", NEMOCLAW_IMAGE, -``` -And uses instead: -```python -"--from", "nemoclaw", -``` -This means it uses a local sandbox name rather than a container image reference. - -### 18.10 Inject Key Hardcodes Provider Name - -The `_run_inject_key` function hardcodes `nvidia-endpoints` as the provider name. This is not configurable via the API. - -### 18.11 Error State Truncation - -When sandbox creation fails, only the last 2000 characters of the log are stored: -```python -_sandbox_state["error"] = f.read()[-2000:] -``` - -### 18.12 Static File Serving Falls Through to SimpleHTTPRequestHandler - -For non-API, non-index paths when the sandbox is NOT ready, Python's built-in `SimpleHTTPRequestHandler` serves files from the `ROOT` directory. This supports directory listing and MIME type detection. The Node.js equivalent would be `express.static()` or similar. - -### 18.13 Host Header Rewriting in Proxy - -Both HTTP and WebSocket proxies rewrite the `Host` header to `127.0.0.1:18789`. All other headers are forwarded as-is. This is critical because the upstream may validate the Host header. - -### 18.14 Connection Closure After Proxy - -Both HTTP and WebSocket proxy handlers set `self.close_connection = True`, forcing the connection closed after each proxied request. This prevents HTTP keep-alive from causing issues with the proxy. - -### 18.15 Process Group Isolation - -`start_new_session=True` on the sandbox creation Popen means the subprocess and all its children are in a separate process group. Sending SIGTERM to the server won't kill the sandbox creation process. - -### 18.16 Key Hash Is SHA-256 - -```python -hashlib.sha256(key.encode()).hexdigest() -``` - -The full hex digest is stored, but only the first 12 characters are logged for debugging. - -### 18.17 Log Streaming Gap for Node.js - -The Python server writes sandbox creation output to `/tmp/nemoclaw-sandbox-create.log` but does NOT stream it to the frontend. The frontend polls `/api/sandbox-status` every 3 seconds for status only. **For the Node.js version, you should add a log-streaming endpoint** (e.g., SSE or WebSocket on `/api/sandbox-logs`) that tails the log file in real-time. - -### 18.18 Temp File Cleanup Patterns - -- **Sandbox creation:** Temp policy file is cleaned up in the main flow after `proc.wait()`, but could be leaked if an exception occurs before that point. -- **Policy sync:** Temp file is cleaned up in a `finally` block — always cleaned up. - -The Node.js version should use `try/finally` or `process.on('exit')` to ensure cleanup. - ---- - -## 19. Node.js Migration Checklist - -### Must-Have Functionality - -- [ ] HTTP server on configurable port (default 8081) -- [ ] `ThreadingHTTPServer` equivalent — Node.js is single-threaded but async; use `http.createServer()` which handles concurrency via the event loop -- [ ] All 11 API endpoints with identical request/response contracts -- [ ] Static file serving from the same directory -- [ ] Template rendering: `{{OTHER_AGENTS_MODAL}}` injection from YAML -- [ ] Reverse proxy (HTTP) to localhost:18789 -- [ ] Reverse proxy (WebSocket) to localhost:18789 -- [ ] Subprocess execution for all `nemoclaw` CLI commands -- [ ] State machines for sandbox and key injection (use in-memory objects) -- [ ] Provider config cache (JSON file read/write) -- [ ] Brev ID detection from Host header -- [ ] CORS headers on all non-proxy responses -- [ ] ANSI code stripping for CLI output parsing - -### New Feature: Log Streaming - -- [ ] Add `GET /api/sandbox-logs` endpoint (SSE or WebSocket) -- [ ] Tail `/tmp/nemoclaw-sandbox-create.log` in real-time -- [ ] Stream subprocess output directly to connected clients -- [ ] Consider using `child_process.spawn()` with piped stdout for real-time streaming -- [ ] Frontend should connect to log stream when install is triggered - -### Recommended Node.js Libraries - -| Purpose | Recommended Package | -|---------|-------------------| -| HTTP server | Built-in `http` module or Express | -| Static files | `express.static()` or `serve-static` | -| WebSocket proxy | `http-proxy` or manual with `net` module | -| YAML parsing | `js-yaml` | -| Subprocess | Built-in `child_process` (`spawn`, `execFile`) | -| HTML escaping | `he` or `escape-html` | -| CORS | `cors` middleware (if Express) or manual headers | -| SSE (log streaming) | Manual implementation or `better-sse` | -| File watching (logs) | `fs.watch()` or `chokidar` for tail -f behavior | -| Temp files | Built-in `os.tmpdir()` + `fs.mkdtemp()` | - -### Architecture Differences to Watch - -1. **Python threads → Node.js async/await:** Python uses threads for background work. Node.js should use `child_process.spawn()` with event-driven I/O. - -2. **Synchronous file reads in Python → async in Node.js:** Several functions (`_read_config_cache`, `_gateway_log_ready`, `_read_openclaw_token`) read files synchronously. In Node.js, use async versions to avoid blocking the event loop. - -3. **Global mutable state with locks → No locks needed in Node.js:** Since Node.js is single-threaded (event loop), you don't need locks for `_sandbox_state` and `_inject_key_state`. Simple objects work, but be careful with async operations that could interleave. - -4. **SimpleHTTPRequestHandler → Express static middleware:** Python's built-in static file handler supports directory listing and content-type detection. Ensure the Node.js equivalent handles the same MIME types. - -5. **subprocess.run() blocking → child_process.execFile() callback/promise:** All CLI calls in Python use blocking `subprocess.run()`. In Node.js, wrap `child_process.execFile()` in promises. - -6. **subprocess.Popen with streaming → child_process.spawn() with pipe:** The sandbox creation process uses line-by-line output streaming. In Node.js, `spawn()` gives you stdout/stderr as streams. - -7. **HTTP proxy reads full body → Node.js can stream:** The Python proxy reads the entire response body before forwarding. Node.js should pipe the response stream directly for better performance and to support log streaming. - ---- - -## Appendix A: Complete Request/Response Matrix - -| Method | Path | Status Codes | Auth | Body In | Body Out | -|--------|------|-------------|------|---------|----------| -| GET | `/api/sandbox-status` | 200 | No | None | JSON | -| POST | `/api/install-openclaw` | 200, 409 | No | None | JSON | -| POST | `/api/inject-key` | 200, 202, 400 | No | JSON | JSON | -| POST | `/api/policy-sync` | 200, 400, 502 | No | YAML text | JSON | -| GET | `/api/connection-details` | 200 | No | None | JSON | -| GET | `/api/providers` | 200, 502 | No | None | JSON | -| POST | `/api/providers` | 200, 400, 502 | No | JSON | JSON | -| PUT | `/api/providers/{name}` | 200, 400, 502 | No | JSON | JSON | -| DELETE | `/api/providers/{name}` | 200, 400, 502 | No | None | JSON | -| GET | `/api/cluster-inference` | 200, 400, 502 | No | None | JSON | -| POST | `/api/cluster-inference` | 200, 400, 502 | No | JSON | JSON | -| OPTIONS | any | 204 | No | None | None | -| GET/HEAD | `/`, `/index.html` | 200 | No | None | HTML | -| GET/HEAD | `/*.css`, `/*.js` | 200/404 | No | None | Static | -| * | any (sandbox ready) | varies | No | Proxied | Proxied | - -## Appendix B: Log Format Reference - -All server logging goes to `stderr` with prefixed tags: - -| Prefix | Source | -|--------|--------| -| `[welcome-ui]` | General server messages, proxy errors | -| `[sandbox]` | Lines from sandbox creation subprocess | -| `[inject-key HH:MM:SS]` | Key injection lifecycle | -| `[policy-sync HH:MM:SS]` | Policy sync lifecycle | - -Timestamps use `time.strftime("%H:%M:%S")` (local time, no date). diff --git a/brev/welcome-ui/__tests__/brev-detection.test.js b/brev/welcome-ui/__tests__/brev-detection.test.js deleted file mode 100644 index 8da45318..00000000 --- a/brev/welcome-ui/__tests__/brev-detection.test.js +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeEach } from 'vitest'; -import serverModule from '../server.js'; -const { extractBrevId, maybeDetectBrevId, buildOpenclawUrl, _resetForTesting, PORT } = serverModule; - -// === TC-B01 through TC-B10: Brev ID detection and URL building === - -describe("extractBrevId", () => { - it("TC-B01: extracts ID from 80810-abcdef123.brevlab.com", () => { - expect(extractBrevId("80810-abcdef123.brevlab.com")).toBe("abcdef123"); - }); - - it("TC-B02: extracts ID from 8080-xyz.brevlab.com", () => { - expect(extractBrevId("8080-xyz.brevlab.com")).toBe("xyz"); - }); - - it("TC-B03: localhost:8081 returns empty string", () => { - expect(extractBrevId("localhost:8081")).toBe(""); - }); - - it("TC-B04: non-matching host returns empty string", () => { - expect(extractBrevId("example.com")).toBe(""); - expect(extractBrevId("")).toBe(""); - expect(extractBrevId("some.other.domain")).toBe(""); - }); -}); - -describe("maybeDetectBrevId + buildOpenclawUrl", () => { - beforeEach(() => { - _resetForTesting(); - }); - - function makeReq(host, forwardedProto = null, forwardedHost = null) { - const headers = { host }; - if (forwardedProto) headers["x-forwarded-proto"] = forwardedProto; - if (forwardedHost) headers["x-forwarded-host"] = forwardedHost; - return { headers }; - } - - it("TC-B05: detection is idempotent (once set, never overwritten)", () => { - maybeDetectBrevId("80810-first-id.brevlab.com"); - maybeDetectBrevId("80810-second-id.brevlab.com"); - const url = buildOpenclawUrl(null); - expect(url).toContain("first-id"); - expect(url).not.toContain("second-id"); - }); - - it("TC-B06: request host takes priority when deriving URL", () => { - const req = makeReq("sandbox-preview.example.net", "https"); - expect(buildOpenclawUrl(null, req)).toBe("https://sandbox-preview.example.net/"); - }); - - it("TC-B07: forwarded host/proto are honored for external URL building", () => { - const req = makeReq("127.0.0.1:8081", "https", "80810-myenv.brevlab.com"); - expect(buildOpenclawUrl("tok123", req)).toBe( - "https://80810-myenv.brevlab.com/#token=tok123" - ); - }); - - it("TC-B08: with Brev ID fallback, URL uses https://80810-{id}.brevlab.com/", () => { - maybeDetectBrevId("80810-myenv.brevlab.com"); - expect(buildOpenclawUrl(null)).toBe("https://80810-myenv.brevlab.com/"); - }); - - it("TC-B09: with Brev ID fallback + token, URL appends fragment token", () => { - maybeDetectBrevId("80810-myenv.brevlab.com"); - expect(buildOpenclawUrl("tok123")).toBe( - "https://80810-myenv.brevlab.com/#token=tok123" - ); - }); - - it("TC-B10: without request context or Brev ID, URL falls back to local 127.0.0.1", () => { - const url = buildOpenclawUrl(null); - expect(url).toBe(`http://127.0.0.1:${PORT}/`); - }); - - it("TC-B11: detected Brev ID still supplies fallback when request context is absent", () => { - maybeDetectBrevId("80810-detected.brevlab.com"); - const url = buildOpenclawUrl(null); - expect(url).toContain("detected"); - }); - - it("TC-B12: buildOpenclawUrl still uses welcome-ui port family, not gateway port family", () => { - maybeDetectBrevId("80810-env123.brevlab.com"); - const url = buildOpenclawUrl(null); - expect(url).toContain("80810"); - expect(url).not.toContain("8080-"); - }); -}); diff --git a/brev/welcome-ui/__tests__/cli-parsing.test.js b/brev/welcome-ui/__tests__/cli-parsing.test.js deleted file mode 100644 index 50edb541..00000000 --- a/brev/welcome-ui/__tests__/cli-parsing.test.js +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from 'vitest'; -import setupModule from './setup.js'; -const { FIXTURES } = setupModule; -import serverModule from '../server.js'; -const { stripAnsi, parseProviderDetail, parseClusterInference } = serverModule; - -// === TC-CL01 through TC-CL12: CLI output parsing === - -describe("stripAnsi", () => { - it("TC-CL01: strips green color code", () => { - expect(stripAnsi("\x1b[32mhello\x1b[0m")).toBe("hello"); - }); - - it("TC-CL02: strips reset code", () => { - expect(stripAnsi("text\x1b[0m more")).toBe("text more"); - }); - - it("TC-CL03: strips bold red code", () => { - expect(stripAnsi("\x1b[1;31merror\x1b[0m")).toBe("error"); - }); - - it("TC-CL04: passes through text without ANSI codes unchanged", () => { - const plain = "No colors here at all."; - expect(stripAnsi(plain)).toBe(plain); - }); -}); - -describe("parseProviderDetail", () => { - it("TC-CL05: parses complete provider output", () => { - const result = parseProviderDetail(FIXTURES.providerGetOutput); - expect(result).toEqual({ - id: "abc-123", - name: "nvidia-endpoints", - type: "openai", - credentialKeys: ["OPENAI_API_KEY"], - configKeys: ["OPENAI_BASE_URL"], - }); - }); - - it("TC-CL06: for credential keys maps to empty array", () => { - const result = parseProviderDetail(FIXTURES.providerGetNone); - expect(result.credentialKeys).toEqual([]); - }); - - it("TC-CL07: comma-separated config keys parsed into array", () => { - const output = [ - "Name: multi", - "Type: custom", - "Config keys: KEY1, KEY2, KEY3", - ].join("\n"); - const result = parseProviderDetail(output); - expect(result.configKeys).toEqual(["KEY1", "KEY2", "KEY3"]); - }); - - it("TC-CL08: output missing Name line returns null", () => { - const output = "Id: abc\nType: openai\n"; - expect(parseProviderDetail(output)).toBeNull(); - }); - - it("TC-CL09: ANSI codes in output are stripped before parsing", () => { - const result = parseProviderDetail(FIXTURES.providerGetAnsi); - expect(result).not.toBeNull(); - expect(result.name).toBe("nvidia-endpoints"); - expect(result.type).toBe("openai"); - }); -}); - -describe("parseClusterInference", () => { - it("TC-CL10: parses Provider, Model, Version lines", () => { - const result = parseClusterInference(FIXTURES.clusterInferenceOutput); - expect(result).toEqual({ - providerName: "nvidia-endpoints", - modelId: "meta/llama-3.1-70b-instruct", - version: 2, - }); - }); - - it("TC-CL11: non-integer version defaults to 0", () => { - const output = "Provider: test\nModel: m\nVersion: abc\n"; - const result = parseClusterInference(output); - expect(result.version).toBe(0); - }); - - it("TC-CL12: missing Provider line returns null", () => { - const output = "Model: m\nVersion: 1\n"; - expect(parseClusterInference(output)).toBeNull(); - }); -}); diff --git a/brev/welcome-ui/__tests__/cluster-inference.test.js b/brev/welcome-ui/__tests__/cluster-inference.test.js deleted file mode 100644 index 8ae5f773..00000000 --- a/brev/welcome-ui/__tests__/cluster-inference.test.js +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { server, _resetForTesting, _setMocksForTesting } = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles, FIXTURES } = setupModule; -const request = supertest; - -// === TC-CI01 through TC-CI10: Cluster inference === - -describe("GET /api/cluster-inference", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - afterAll(() => { server.close(); }); - - it("TC-CI01: returns parsed providerName, modelId, version on success", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, FIXTURES.clusterInferenceOutput, ""); - }); - - const res = await request(server).get("/api/cluster-inference"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.providerName).toBe("nvidia-endpoints"); - expect(res.body.modelId).toBe("meta/llama-3.1-70b-instruct"); - expect(res.body.version).toBe(2); - }); - - it("TC-CI02: returns nulls when 'not configured' in stderr", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "cluster inference not configured"); - }); - - const res = await request(server).get("/api/cluster-inference"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.providerName).toBeNull(); - expect(res.body.modelId).toBe(""); - expect(res.body.version).toBe(0); - }); - - it("TC-CI03: returns nulls when 'not found' in stderr", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "inference config not found"); - }); - - const res = await request(server).get("/api/cluster-inference"); - expect(res.status).toBe(200); - expect(res.body.providerName).toBeNull(); - }); - - it("TC-CI04: returns 400 on other CLI errors", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "unexpected error occurred"); - }); - - const res = await request(server).get("/api/cluster-inference"); - expect(res.status).toBe(400); - expect(res.body.ok).toBe(false); - }); - - it("TC-CI05: ANSI codes in output are stripped before parsing", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, FIXTURES.clusterInferenceAnsi, ""); - }); - - const res = await request(server).get("/api/cluster-inference"); - expect(res.status).toBe(200); - expect(res.body.providerName).toBe("nvidia-endpoints"); - expect(res.body.modelId).toBe("meta/llama-3.1-70b-instruct"); - }); -}); - -describe("POST /api/cluster-inference", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - it("TC-CI06: returns 200 with parsed output on success", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "Provider: my-prov\nModel: llama\nVersion: 1\n", ""); - }); - - const res = await request(server) - .post("/api/cluster-inference") - .send({ providerName: "my-prov", modelId: "llama" }); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - }); - - it("TC-CI07: returns 400 when providerName missing", async () => { - const res = await request(server) - .post("/api/cluster-inference") - .send({ modelId: "llama" }); - expect(res.status).toBe(400); - expect(res.body.error).toContain("providerName"); - }); - - it("TC-CI08: returns 400 when modelId missing", async () => { - const res = await request(server) - .post("/api/cluster-inference") - .send({ providerName: "prov" }); - expect(res.status).toBe(400); - expect(res.body.error).toContain("modelId"); - }); - - it("TC-CI09: returns 400 on CLI failure", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "set failed"); - }); - - const res = await request(server) - .post("/api/cluster-inference") - .send({ providerName: "p", modelId: "m" }); - expect(res.status).toBe(400); - }); - - it("TC-CI10: calls CLI (openshell or nemoclaw) with cluster inference set or inference set, --provider, --model, --no-verify", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .post("/api/cluster-inference") - .send({ providerName: "test-prov", modelId: "test-model" }); - - const setCall = execFile.mock.calls.find( - (c) => (c[0] === "openshell" || c[0] === "nemoclaw") && c[1]?.includes("inference") && c[1]?.includes("set") - ); - expect(setCall).toBeDefined(); - const args = setCall[1]; - expect(args).toContain("--provider"); - expect(args).toContain("test-prov"); - expect(args).toContain("--model"); - expect(args).toContain("test-model"); - expect(args).toContain("--no-verify"); - }); -}); diff --git a/brev/welcome-ui/__tests__/config-cache.test.js b/brev/welcome-ui/__tests__/config-cache.test.js deleted file mode 100644 index e5e6ecd4..00000000 --- a/brev/welcome-ui/__tests__/config-cache.test.js +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeEach } from 'vitest'; -import fs from 'fs'; -import setupModule from './setup.js'; -const { CACHE_FILE, cleanTempFiles, readCacheFile } = setupModule; -import serverModule from '../server.js'; -const { readConfigCache, writeConfigCache, cacheProviderConfig, removeCachedProvider, bootstrapConfigCache } = serverModule; - -// === TC-CC01 through TC-CC10: Provider config cache === - -describe("config cache", () => { - beforeEach(() => { - cleanTempFiles(); - }); - - it("TC-CC01: bootstrapConfigCache writes default when file doesn't exist", () => { - bootstrapConfigCache(); - const cache = readCacheFile(); - expect(cache).not.toBeNull(); - expect(cache["nvidia-endpoints"]).toBeDefined(); - }); - - it("TC-CC02: bootstrapConfigCache is no-op when file already exists", () => { - fs.writeFileSync(CACHE_FILE, JSON.stringify({ custom: { x: 1 } })); - bootstrapConfigCache(); - const cache = readCacheFile(); - expect(cache).toEqual({ custom: { x: 1 } }); - }); - - it("TC-CC03: default bootstrap content seeds NVIDIA endpoints provider", () => { - bootstrapConfigCache(); - const cache = readCacheFile(); - expect(cache).toEqual({ - "nvidia-endpoints": { - NVIDIA_BASE_URL: "https://integrate.api.nvidia.com/v1", - }, - }); - }); - - it("TC-CC04: readConfigCache returns {} on missing file", () => { - expect(readConfigCache()).toEqual({}); - }); - - it("TC-CC05: readConfigCache returns {} on invalid JSON", () => { - fs.writeFileSync(CACHE_FILE, "not valid json!!!"); - expect(readConfigCache()).toEqual({}); - }); - - it("TC-CC06: writeConfigCache writes valid JSON", () => { - const data = { test: { KEY: "val" } }; - writeConfigCache(data); - const raw = fs.readFileSync(CACHE_FILE, "utf-8"); - expect(JSON.parse(raw)).toEqual(data); - }); - - it("TC-CC07: writeConfigCache silently ignores write errors", () => { - // Write to a path that can't be written to shouldn't throw - // (the function catches internally). We verify no exception escapes. - expect(() => writeConfigCache({ a: 1 })).not.toThrow(); - }); - - it("TC-CC08: cacheProviderConfig merges new config into existing cache", () => { - writeConfigCache({ existing: { A: "1" } }); - cacheProviderConfig("new-provider", { B: "2" }); - const cache = readCacheFile(); - expect(cache.existing).toEqual({ A: "1" }); - expect(cache["new-provider"]).toEqual({ B: "2" }); - }); - - it("TC-CC09: removeCachedProvider removes entry and preserves others", () => { - writeConfigCache({ keep: { A: "1" }, remove: { B: "2" } }); - removeCachedProvider("remove"); - const cache = readCacheFile(); - expect(cache.keep).toEqual({ A: "1" }); - expect(cache.remove).toBeUndefined(); - }); - - it("TC-CC10: concurrent cache operations don't crash", () => { - expect(() => { - for (let i = 0; i < 20; i++) { - cacheProviderConfig(`p${i}`, { val: i }); - } - }).not.toThrow(); - const cache = readCacheFile(); - expect(cache.p19).toEqual({ val: 19 }); - }); -}); diff --git a/brev/welcome-ui/__tests__/connection-details.test.js b/brev/welcome-ui/__tests__/connection-details.test.js deleted file mode 100644 index 7a8c41c3..00000000 --- a/brev/welcome-ui/__tests__/connection-details.test.js +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { - server, - _resetForTesting, - _setMocksForTesting, - maybeDetectBrevId, -} = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles } = setupModule; -const request = supertest; - -// === TC-CD01 through TC-CD06: Connection details === - -describe("GET /api/connection-details", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (cmd === "hostname") { - return cb(null, "myhost.example.com\n", ""); - } - cb(null, "", ""); - }); - }); - - afterAll(() => { server.close(); }); - - it("TC-CD01: returns hostname, gatewayUrl, gatewayPort=8080, and instructions", async () => { - const res = await request(server).get("/api/connection-details"); - expect(res.status).toBe(200); - expect(res.body.hostname).toBeDefined(); - expect(res.body.gatewayUrl).toBeDefined(); - expect(res.body.gatewayPort).toBe(8080); - expect(res.body.instructions).toBeDefined(); - expect(res.body.instructions.install).toContain("curl"); - expect(res.body.instructions.connect).toContain("openshell gateway add"); - expect(res.body.instructions.createSandbox).toContain("openshell sandbox create"); - expect(res.body.instructions.tui).toBe("openshell term"); - }); - - it("TC-CD02: with Brev ID, gatewayUrl is https://8080-{id}.brevlab.com", async () => { - maybeDetectBrevId("8081-testenv.brevlab.com"); - const res = await request(server).get("/api/connection-details"); - expect(res.body.gatewayUrl).toBe("https://8080-testenv.brevlab.com"); - }); - - it("TC-CD03: without Brev ID, gatewayUrl is http://{hostname}:8080", async () => { - const res = await request(server).get("/api/connection-details"); - expect(res.body.gatewayUrl).toMatch(/^http:\/\/.*:8080$/); - }); - - it("TC-CD04: hostname -f success uses its output", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (cmd === "hostname") { - return cb(null, "resolved.host.name\n", ""); - } - cb(null, "", ""); - }); - - const res = await request(server).get("/api/connection-details"); - expect(res.body.hostname).toBe("resolved.host.name"); - }); - - it("TC-CD05: hostname -f failure falls back to os.hostname()", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (cmd === "hostname") { - const err = new Error("fail"); - err.code = 1; - return cb(err, "", ""); - } - cb(null, "", ""); - }); - - const res = await request(server).get("/api/connection-details"); - expect(res.body.hostname).toBeDefined(); - expect(res.body.hostname.length).toBeGreaterThan(0); - }); - - it("TC-CD06: instructions contain exact CLI strings", async () => { - const res = await request(server).get("/api/connection-details"); - expect(res.body.instructions.install).toBe( - "curl -fsSL https://github.com/NVIDIA/OpenShell/releases/download/devel/install.sh | sh" - ); - expect(res.body.instructions.createSandbox).toBe( - "openshell sandbox create -- claude" - ); - expect(res.body.instructions.tui).toBe("openshell term"); - }); -}); diff --git a/brev/welcome-ui/__tests__/inject-key.test.js b/brev/welcome-ui/__tests__/inject-key.test.js deleted file mode 100644 index e5039786..00000000 --- a/brev/welcome-ui/__tests__/inject-key.test.js +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; -import crypto from 'crypto'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { - server, - _resetForTesting, - _setMocksForTesting, - injectKeyState, - hashKey, -} = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles, FIXTURES } = setupModule; -const request = supertest; - -// === TC-K01 through TC-K16: Key injection === - -describe("POST /api/inject-key", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - }); - - afterAll(() => { server.close(); }); - - it("TC-K01: returns 202 {ok:true,started:true} for valid key", async () => { - const res = await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - expect(res.status).toBe(202); - expect(res.body.ok).toBe(true); - expect(res.body.started).toBe(true); - }); - - it("TC-K02: returns 400 for empty body", async () => { - const res = await request(server) - .post("/api/inject-key") - .set("Content-Type", "application/json") - .send(""); - expect(res.status).toBe(400); - }); - - it("TC-K03: returns 400 for invalid JSON body", async () => { - const res = await request(server) - .post("/api/inject-key") - .set("Content-Type", "application/json") - .send("not json!"); - expect(res.status).toBe(400); - expect(res.body.error).toContain("invalid JSON"); - }); - - it("TC-K04: returns 400 for missing key field", async () => { - const res = await request(server) - .post("/api/inject-key") - .send({ notkey: "value" }); - expect(res.status).toBe(400); - expect(res.body.error).toContain("missing key"); - }); - - it("TC-K05: returns 400 for empty/whitespace-only key", async () => { - const res = await request(server) - .post("/api/inject-key") - .send({ key: " " }); - expect(res.status).toBe(400); - }); -}); - -describe("inject-key deduplication", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - }); - - it("TC-K06: same key while injecting returns 202 (no new process)", async () => { - injectKeyState.status = "injecting"; - injectKeyState.keyHash = hashKey(FIXTURES.sampleApiKey); - const res = await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - expect(res.status).toBe(202); - expect(res.body.started).toBe(true); - }); - - it("TC-K07: same key after done returns 200 {already:true}", async () => { - injectKeyState.status = "done"; - injectKeyState.keyHash = hashKey(FIXTURES.sampleApiKey); - const res = await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - expect(res.status).toBe(200); - expect(res.body.already).toBe(true); - }); - - it("TC-K08: different key after done starts new injection", async () => { - injectKeyState.status = "done"; - injectKeyState.keyHash = hashKey(FIXTURES.sampleApiKey); - const res = await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey2 }); - expect(res.status).toBe(202); - expect(res.body.started).toBe(true); - }); - - it("TC-K09: different key while injecting starts new injection", async () => { - injectKeyState.status = "injecting"; - injectKeyState.keyHash = hashKey(FIXTURES.sampleApiKey); - const res = await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey2 }); - expect(res.status).toBe(202); - expect(res.body.started).toBe(true); - }); -}); - -describe("inject-key background process", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - it("TC-K10: updates default NVIDIA endpoints provider with the submitted key", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - - // Wait for the async background call - await new Promise((r) => setTimeout(r, 100)); - - const updateCalls = execFile.mock.calls.filter( - (c) => c[0] === "nemoclaw" && c[1]?.includes("update") - ); - expect(updateCalls.length).toBeGreaterThanOrEqual(1); - - const endpointsArgs = updateCalls.find((c) => c[1].includes("nvidia-endpoints"))?.[1] || []; - expect(endpointsArgs).toContain("nvidia-endpoints"); - expect(endpointsArgs.some((a) => a.startsWith("NVIDIA_API_KEY="))).toBe(true); - expect(endpointsArgs.some((a) => a.includes("integrate.api.nvidia.com"))).toBe(true); - }); - - it("TC-K11: on CLI success, state becomes done", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "updated", ""); - }); - - await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - - await new Promise((r) => setTimeout(r, 200)); - expect(injectKeyState.status).toBe("done"); - }); - - it("TC-K12: on CLI failure, state becomes error", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.includes("update")) { - const err = new Error("fail"); - err.code = 1; - return cb(err, "", "provider not found"); - } - cb(null, "", ""); - }); - - await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - - await new Promise((r) => setTimeout(r, 200)); - expect(injectKeyState.status).toBe("error"); - expect(injectKeyState.error).toBeDefined(); - }); - - it("TC-K13: on CLI exception, state becomes error", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.includes("update")) { - throw new Error("spawn failed"); - } - cb(null, "", ""); - }); - - await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - - await new Promise((r) => setTimeout(r, 200)); - // The error is caught by the .catch() handler in runInjectKey - expect(["error", "injecting"]).toContain(injectKeyState.status); - }); -}); - -describe("key hashing", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - execFile.mockClear(); - }); - - it("TC-K14: key hash is SHA-256 hex digest", () => { - const key = "test-key-123"; - const expected = crypto.createHash("sha256").update(key).digest("hex"); - expect(hashKey(key)).toBe(expected); - }); - - it("TC-K15: identical keys produce same hash", () => { - expect(hashKey("abc")).toBe(hashKey("abc")); - }); - - it("TC-K16: provider updates cover nvidia-endpoints", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .post("/api/inject-key") - .send({ key: FIXTURES.sampleApiKey }); - - await new Promise((r) => setTimeout(r, 100)); - - const updateCalls = execFile.mock.calls.filter( - (c) => c[0] === "nemoclaw" && c[1]?.includes("update") - ); - expect(updateCalls.some((c) => c[1].includes("nvidia-endpoints"))).toBe(true); - }); -}); diff --git a/brev/welcome-ui/__tests__/policy-strip.test.js b/brev/welcome-ui/__tests__/policy-strip.test.js deleted file mode 100644 index 69a6ff84..00000000 --- a/brev/welcome-ui/__tests__/policy-strip.test.js +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from 'vitest'; -import setupModule from './setup.js'; -const { FIXTURES } = setupModule; -import serverModule from '../server.js'; -const { stripPolicyFields } = serverModule; - -// === TC-PS01 through TC-PS08: Policy field stripping === - -describe("stripPolicyFields", () => { - it("TC-PS01: strips inference top-level key", () => { - const result = stripPolicyFields(FIXTURES.validPolicyYaml); - expect(result).not.toMatch(/^inference:/m); - expect(result).not.toContain("model: gpt-4"); - }); - - it("TC-PS02: strips process key when specified as extra field", () => { - const result = stripPolicyFields(FIXTURES.validPolicyYaml, ["process"]); - expect(result).not.toMatch(/^process:/m); - expect(result).not.toContain("run_as_user"); - }); - - it("TC-PS03: preserves all other top-level keys", () => { - const result = stripPolicyFields(FIXTURES.validPolicyYaml, ["process"]); - expect(result).toContain("version:"); - expect(result).toContain("filesystem_policy:"); - expect(result).toContain("network_policies:"); - }); - - it("TC-PS04: handles nested YAML under stripped keys (entire subtree removed)", () => { - const yaml = [ - "version: 1", - "inference:", - " model: gpt-4", - " nested:", - " deep: value", - "other: kept", - ].join("\n"); - const result = stripPolicyFields(yaml); - expect(result).not.toContain("model:"); - expect(result).not.toContain("deep:"); - expect(result).toContain("other:"); - }); - - it("TC-PS05: empty YAML input returns minimal output", () => { - const result = stripPolicyFields(""); - expect(typeof result).toBe("string"); - }); - - it("TC-PS06: YAML with only stripped fields returns minimal output", () => { - const yaml = "inference:\n model: gpt-4\n"; - const result = stripPolicyFields(yaml); - expect(result).not.toContain("inference:"); - expect(result).not.toContain("model:"); - }); - - it("TC-PS07: output is readable YAML format", () => { - const result = stripPolicyFields(FIXTURES.validPolicyYaml, ["process"]); - // Should not use inline flow style - expect(result).not.toContain("{"); - expect(result).toContain("version:"); - }); - - it("TC-PS08: strips correctly with indented sub-keys", () => { - const yaml = [ - "version: 1", - "process:", - " run_as_user: sandbox", - " run_as_group: sandbox", - "filesystem_policy:", - " include_workdir: true", - ].join("\n"); - const result = stripPolicyFields(yaml, ["process"]); - expect(result).not.toContain("process:"); - expect(result).not.toContain("run_as_user"); - expect(result).toContain("filesystem_policy:"); - expect(result).toContain("include_workdir"); - }); -}); diff --git a/brev/welcome-ui/__tests__/policy-sync.test.js b/brev/welcome-ui/__tests__/policy-sync.test.js deleted file mode 100644 index 4e2414c1..00000000 --- a/brev/welcome-ui/__tests__/policy-sync.test.js +++ /dev/null @@ -1,217 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; -import fs from 'fs'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { server, _resetForTesting, _setMocksForTesting } = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles, FIXTURES } = setupModule; -const request = supertest; - -// === TC-P01 through TC-P12: Policy sync === - -describe("POST /api/policy-sync", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - afterAll(() => { server.close(); }); - - it("TC-P01: returns 400 for empty body", async () => { - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(""); - expect(res.status).toBe(400); - expect(res.body.error).toContain("empty body"); - }); - - it("TC-P02: returns 400 for body missing version field", async () => { - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send("name: test\nvalue: 123\n"); - expect(res.status).toBe(400); - expect(res.body.error).toContain("missing version"); - }); - - it("TC-P03: returns 200 with applied=true on CLI success", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, FIXTURES.policySyncSuccess, ""); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.applied).toBe(true); - expect(res.body.version).toBe(3); - expect(res.body.policy_hash).toBe("deadbeef01234567"); - }); - - it("TC-P04: returns 502 on CLI failure", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("CLI failed"); - err.code = 1; - cb(err, "", "policy set failed: sandbox not found"); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.status).toBe(502); - expect(res.body.ok).toBe(false); - expect(res.body.error).toBeDefined(); - }); - - it("TC-P05: strips inference field from input YAML", async () => { - let writtenArgs; - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - writtenArgs = args; - cb(null, "version 1\nhash: abc\n", ""); - }); - - await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - - // The CLI is called with a temp file. We verify the call was made. - expect(execFile).toHaveBeenCalled(); - const policyCalls = execFile.mock.calls.filter( - (c) => c[0] === "nemoclaw" && c[1]?.includes("policy") - ); - expect(policyCalls.length).toBeGreaterThanOrEqual(1); - }); - - it("TC-P06: strips process field from input YAML", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "version 1\nhash: abc\n", ""); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.status).toBe(200); - }); - - it("TC-P07: writes stripped YAML to temp file and passes path to CLI", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "version 1\nhash: abc\n", ""); - }); - - await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - - const call = execFile.mock.calls.find( - (c) => c[0] === "nemoclaw" && c[1]?.includes("policy") - ); - expect(call).toBeDefined(); - const args = call[1]; - expect(args).toContain("--policy"); - const policyIdx = args.indexOf("--policy"); - const tmpPath = args[policyIdx + 1]; - expect(tmpPath).toContain("policy-sync-"); - }); - - it("TC-P08: temp file is cleaned up even on CLI failure", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "error"); - }); - - await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - - // The temp file should have been cleaned up by the finally block. - // We check that no stale policy-sync temp files remain in /tmp - const tmpFiles = fs.readdirSync("/tmp").filter((f) => f.startsWith("policy-sync-")); - expect(tmpFiles.length).toBe(0); - }); - - it("TC-P09: parses version from CLI output", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "Policy applied.\nversion 7\nhash: cafebabe\n", ""); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.body.version).toBe(7); - }); - - it("TC-P10: parses hash from CLI output", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "version 1\nhash: cafebabe\n", ""); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.body.policy_hash).toBe("cafebabe"); - }); - - it("TC-P11: returns version=0 and empty hash if regex doesn't match", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "Policy applied successfully.\n", ""); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.body.version).toBe(0); - expect(res.body.policy_hash).toBe(""); - }); - - it("TC-P12: CLI timeout returns 502 error", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("Command timed out"); - err.killed = true; - cb(err, "", ""); - }); - - const res = await request(server) - .post("/api/policy-sync") - .set("Content-Type", "text/yaml") - .send(FIXTURES.validPolicyYaml); - expect(res.status).toBe(502); - expect(res.body.ok).toBe(false); - }); -}); diff --git a/brev/welcome-ui/__tests__/providers.test.js b/brev/welcome-ui/__tests__/providers.test.js deleted file mode 100644 index a778e66a..00000000 --- a/brev/welcome-ui/__tests__/providers.test.js +++ /dev/null @@ -1,401 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { - server, - _resetForTesting, - _setMocksForTesting, - readConfigCache, - writeConfigCache, -} = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles, FIXTURES, writeCacheFile, readCacheFile } = setupModule; -const request = supertest; - -// === TC-PR01 through TC-PR24: Provider CRUD === - -describe("GET /api/providers", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - afterAll(() => { server.close(); }); - - it("TC-PR01: returns 200 with providers array", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.[1] === "list") { - return cb(null, "nvidia-endpoints\n", ""); - } - if (args?.[1] === "get") { - return cb(null, FIXTURES.providerGetOutput, ""); - } - cb(null, "", ""); - }); - - const res = await request(server).get("/api/providers"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(Array.isArray(res.body.providers)).toBe(true); - expect(res.body.providers.length).toBe(1); - expect(res.body.providers[0].name).toBe("nvidia-endpoints"); - }); - - it("TC-PR02: provider list CLI failure returns 502", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "provider list failed"); - }); - - const res = await request(server).get("/api/providers"); - expect(res.status).toBe(502); - }); - - it("TC-PR03: each provider fetched via nemoclaw provider get", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.[1] === "list") { - return cb(null, "p1\np2\n", ""); - } - if (args?.[1] === "get") { - const name = args[2]; - return cb(null, `Name: ${name}\nType: openai\n`, ""); - } - cb(null, "", ""); - }); - - const res = await request(server).get("/api/providers"); - expect(res.body.providers.length).toBe(2); - expect(res.body.providers[0].name).toBe("p1"); - expect(res.body.providers[1].name).toBe("p2"); - }); - - it("TC-PR04: provider with no config cache has no configValues", async () => { - cleanTempFiles(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.[1] === "list") return cb(null, "test-prov\n", ""); - if (args?.[1] === "get") return cb(null, "Name: test-prov\nType: custom\n", ""); - cb(null, "", ""); - }); - - const res = await request(server).get("/api/providers"); - expect(res.body.providers[0].configValues).toBeUndefined(); - }); - - it("TC-PR05: provider with config cache has configValues merged", async () => { - writeCacheFile({ "test-prov": { URL: "https://example.com" } }); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.[1] === "list") return cb(null, "test-prov\n", ""); - if (args?.[1] === "get") return cb(null, "Name: test-prov\nType: custom\n", ""); - cb(null, "", ""); - }); - - const res = await request(server).get("/api/providers"); - expect(res.body.providers[0].configValues).toEqual({ URL: "https://example.com" }); - }); - - it("TC-PR06: provider whose get fails is silently skipped", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.[1] === "list") return cb(null, "good\nbad\n", ""); - if (args?.[1] === "get") { - if (args[2] === "bad") { - const err = new Error("fail"); - err.code = 1; - return cb(err, "", "not found"); - } - return cb(null, "Name: good\nType: openai\n", ""); - } - cb(null, "", ""); - }); - - const res = await request(server).get("/api/providers"); - expect(res.body.providers.length).toBe(1); - expect(res.body.providers[0].name).toBe("good"); - }); - - it("TC-PR07: for credential/config keys maps to empty array", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - if (args?.[1] === "list") return cb(null, "empty\n", ""); - if (args?.[1] === "get") return cb(null, FIXTURES.providerGetNone, ""); - cb(null, "", ""); - }); - - const res = await request(server).get("/api/providers"); - expect(res.body.providers[0].credentialKeys).toEqual([]); - expect(res.body.providers[0].configKeys).toEqual([]); - }); -}); - -describe("POST /api/providers", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - it("TC-PR08: returns 200 {ok:true} on success", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "created", ""); - }); - - const res = await request(server) - .post("/api/providers") - .send({ name: "my-provider", type: "openai", credentials: { KEY: "val" } }); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - }); - - it("TC-PR09: returns 400 for empty/invalid JSON body", async () => { - const res = await request(server) - .post("/api/providers") - .set("Content-Type", "application/json") - .send(""); - expect(res.status).toBe(400); - }); - - it("TC-PR10: returns 400 when name missing", async () => { - const res = await request(server) - .post("/api/providers") - .send({ type: "openai" }); - expect(res.status).toBe(400); - expect(res.body.error).toContain("name"); - }); - - it("TC-PR11: returns 400 when type missing", async () => { - const res = await request(server) - .post("/api/providers") - .send({ name: "test" }); - expect(res.status).toBe(400); - expect(res.body.error).toContain("type"); - }); - - it("TC-PR12: no credentials → uses PLACEHOLDER=unused", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .post("/api/providers") - .send({ name: "test", type: "openai" }); - - const createCall = execFile.mock.calls.find( - (c) => c[0] === "nemoclaw" && c[1]?.includes("create") - ); - expect(createCall).toBeDefined(); - const args = createCall[1]; - expect(args).toContain("PLACEHOLDER=unused"); - }); - - it("TC-PR13: multiple credentials and configs passed as repeated flags", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .post("/api/providers") - .send({ - name: "test", - type: "openai", - credentials: { KEY1: "v1", KEY2: "v2" }, - config: { CFG1: "c1", CFG2: "c2" }, - }); - - const createCall = execFile.mock.calls.find( - (c) => c[0] === "nemoclaw" && c[1]?.includes("create") - ); - const args = createCall[1]; - const credFlags = args.filter((a) => a.startsWith("KEY")); - const cfgFlags = args.filter((a) => a.startsWith("CFG")); - expect(credFlags.length).toBe(2); - expect(cfgFlags.length).toBe(2); - }); - - it("TC-PR14: config values are cached on success", async () => { - cleanTempFiles(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .post("/api/providers") - .send({ name: "cached-prov", type: "openai", config: { URL: "http://x" } }); - - const cache = readCacheFile(); - expect(cache?.["cached-prov"]).toEqual({ URL: "http://x" }); - }); - - it("TC-PR15: CLI failure returns 400 with error", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "provider already exists"); - }); - - const res = await request(server) - .post("/api/providers") - .send({ name: "test", type: "openai" }); - expect(res.status).toBe(400); - expect(res.body.ok).toBe(false); - }); -}); - -describe("PUT /api/providers/{name}", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - it("TC-PR16: returns 200 {ok:true} on success", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "updated", ""); - }); - - const res = await request(server) - .put("/api/providers/my-provider") - .send({ type: "openai", credentials: { KEY: "val" } }); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - }); - - it("TC-PR17: returns 400 for missing type", async () => { - const res = await request(server) - .put("/api/providers/my-provider") - .send({ credentials: { KEY: "val" } }); - expect(res.status).toBe(400); - expect(res.body.error).toContain("type"); - }); - - it("TC-PR18: returns 400 for empty body", async () => { - const res = await request(server) - .put("/api/providers/my-provider") - .set("Content-Type", "application/json") - .send(""); - expect(res.status).toBe(400); - }); - - it("TC-PR19: config values are cached on success", async () => { - cleanTempFiles(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server) - .put("/api/providers/upd-prov") - .send({ type: "openai", config: { URL: "http://y" } }); - - const cache = readCacheFile(); - expect(cache?.["upd-prov"]).toEqual({ URL: "http://y" }); - }); - - it("TC-PR20: CLI failure returns 400", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "update failed"); - }); - - const res = await request(server) - .put("/api/providers/test") - .send({ type: "openai" }); - expect(res.status).toBe(400); - }); -}); - -describe("DELETE /api/providers/{name}", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - }); - - it("TC-PR21: returns 200 {ok:true} on success", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "deleted", ""); - }); - - const res = await request(server).delete("/api/providers/my-provider"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - }); - - it("TC-PR22: removes provider from config cache", async () => { - writeCacheFile({ "del-prov": { X: "1" }, keep: { Y: "2" } }); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - - await request(server).delete("/api/providers/del-prov"); - const cache = readCacheFile(); - expect(cache?.["del-prov"]).toBeUndefined(); - expect(cache?.keep).toEqual({ Y: "2" }); - }); - - it("TC-PR23: CLI failure returns 400", async () => { - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - const err = new Error("fail"); - err.code = 1; - cb(err, "", "delete failed"); - }); - - const res = await request(server).delete("/api/providers/test"); - expect(res.status).toBe(400); - }); -}); - -describe("provider route matching", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - execFile.mockClear(); - execFile.mockImplementation((cmd, args, opts, cb) => { - if (typeof opts === "function") { cb = opts; opts = {}; } - cb(null, "", ""); - }); - }); - - it("TC-PR24: regex accepts alphanumeric, underscores, hyphens", async () => { - const res = await request(server) - .put("/api/providers/my-provider_v2") - .send({ type: "openai" }); - expect([200, 400]).toContain(res.status); - // The route matched — didn't 404 - expect(res.status).not.toBe(404); - }); -}); diff --git a/brev/welcome-ui/__tests__/proxy-http.test.js b/brev/welcome-ui/__tests__/proxy-http.test.js deleted file mode 100644 index 7bcdb14e..00000000 --- a/brev/welcome-ui/__tests__/proxy-http.test.js +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeEach, afterAll, afterEach, vi } from 'vitest'; -import http from 'http'; -import request from 'supertest'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { server, _resetForTesting, _setMocksForTesting, sandboxState, SANDBOX_PORT } = serverModule; - -import setupModule from './setup.js'; -const { cleanTempFiles } = setupModule; - -// Create a real upstream server to proxy to -let upstream; -let upstreamPort; - -function createUpstream(handler) { - return new Promise((resolve) => { - upstream = http.createServer(handler); - upstream.listen(SANDBOX_PORT, "127.0.0.1", () => { - upstreamPort = upstream.address().port; - resolve(); - }); - }); -} - -function closeUpstream() { - return new Promise((resolve) => { - if (upstream) { - upstream.close(() => resolve()); - upstream = null; - } else { - resolve(); - } - }); -} - -// === TC-PX01 through TC-PX12: HTTP reverse proxy === - -describe("HTTP reverse proxy", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - }); - - afterEach(async () => { - await closeUpstream(); - }); - - afterAll(() => { server.close(); }); - - it("TC-PX01: non-API request proxied to sandbox when ready", async () => { - await createUpstream((req, res) => { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("upstream response"); - }); - - sandboxState.status = "running"; - const res = await request(server).get("/some/page"); - expect(res.status).toBe(200); - expect(res.text).toBe("upstream response"); - }); - - it("TC-PX02: request method is forwarded", async () => { - let receivedMethod; - await createUpstream((req, res) => { - receivedMethod = req.method; - res.writeHead(200); - res.end("ok"); - }); - - sandboxState.status = "running"; - await request(server).post("/data").send("body"); - expect(receivedMethod).toBe("POST"); - }); - - it("TC-PX03: full path + query string forwarded", async () => { - let receivedUrl; - await createUpstream((req, res) => { - receivedUrl = req.url; - res.writeHead(200); - res.end("ok"); - }); - - sandboxState.status = "running"; - await request(server).get("/path/to/resource?key=value&x=1"); - expect(receivedUrl).toBe("/path/to/resource?key=value&x=1"); - }); - - it("TC-PX04: request body is forwarded", async () => { - let receivedBody = ""; - await createUpstream((req, res) => { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - receivedBody = Buffer.concat(chunks).toString(); - res.writeHead(200); - res.end("ok"); - }); - }); - - sandboxState.status = "running"; - await request(server) - .post("/upload") - .set("Content-Type", "text/plain") - .send("hello world"); - expect(receivedBody).toBe("hello world"); - }); - - it("TC-PX05: Host header rewritten to 127.0.0.1:SANDBOX_PORT", async () => { - let receivedHost; - await createUpstream((req, res) => { - receivedHost = req.headers.host; - res.writeHead(200); - res.end("ok"); - }); - - sandboxState.status = "running"; - await request(server).get("/test"); - expect(receivedHost).toBe(`127.0.0.1:${SANDBOX_PORT}`); - }); - - it("TC-PX06: other request headers forwarded as-is", async () => { - let receivedHeaders; - await createUpstream((req, res) => { - receivedHeaders = req.headers; - res.writeHead(200); - res.end("ok"); - }); - - sandboxState.status = "running"; - await request(server) - .get("/test") - .set("X-Custom-Header", "myvalue") - .set("Authorization", "Bearer token123"); - expect(receivedHeaders["x-custom-header"]).toBe("myvalue"); - expect(receivedHeaders["authorization"]).toBe("Bearer token123"); - }); - - it("TC-PX07: hop-by-hop headers stripped from response", async () => { - await createUpstream((req, res) => { - res.writeHead(200, { - "Content-Type": "text/plain", - Connection: "keep-alive", - "Keep-Alive": "timeout=5", - "Transfer-Encoding": "chunked", - "X-Custom": "preserved", - }); - res.end("body"); - }); - - sandboxState.status = "running"; - const res = await request(server).get("/test"); - expect(res.headers["connection"]).not.toBe("keep-alive"); - expect(res.headers["keep-alive"]).toBeUndefined(); - expect(res.headers["transfer-encoding"]).toBeUndefined(); - expect(res.headers["x-custom"]).toBe("preserved"); - }); - - it("TC-PX08: upstream Content-Length replaced with actual body length", async () => { - await createUpstream((req, res) => { - const body = "exact body"; - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end(body); - }); - - sandboxState.status = "running"; - const res = await request(server).get("/test"); - expect(parseInt(res.headers["content-length"], 10)).toBe( - Buffer.byteLength("exact body") - ); - }); - - it("TC-PX09: upstream error returns 502 Sandbox unavailable", async () => { - // Don't start upstream — connection will fail - sandboxState.status = "running"; - const res = await request(server).get("/test"); - expect(res.status).toBe(502); - expect(res.text).toBe("Sandbox unavailable"); - }); - - it("TC-PX10: connection is closed after proxy request", async () => { - await createUpstream((req, res) => { - res.writeHead(200); - res.end("done"); - }); - - sandboxState.status = "running"; - const res = await request(server).get("/test"); - expect(res.status).toBe(200); - // supertest handles connection lifecycle - }); - - it("TC-PX11: proxy responses do NOT include server CORS/Cache-Control", async () => { - await createUpstream((req, res) => { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("proxied"); - }); - - sandboxState.status = "running"; - const res = await request(server).get("/test"); - // The proxy path doesn't call setDefaultHeaders. - // Upstream didn't send these headers, so they shouldn't appear. - // (The server only adds CORS/Cache-Control via setDefaultHeaders for local responses) - expect(res.headers["access-control-allow-origin"]).toBeUndefined(); - expect(res.headers["cache-control"]).toBeUndefined(); - }); - - it("TC-PX12: proxy connection timeout is 120s", async () => { - // Verify the timeout value is configured in the proxy options. - // We can't easily test 120s timeout, but we verify the proxy works - // and the timeout is set in the source code (opts.timeout = 120000). - await createUpstream((req, res) => { - res.writeHead(200); - res.end("ok"); - }); - - sandboxState.status = "running"; - const res = await request(server).get("/test"); - expect(res.status).toBe(200); - }); -}); diff --git a/brev/welcome-ui/__tests__/proxy-websocket.test.js b/brev/welcome-ui/__tests__/proxy-websocket.test.js deleted file mode 100644 index aaa2d5e0..00000000 --- a/brev/welcome-ui/__tests__/proxy-websocket.test.js +++ /dev/null @@ -1,373 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeEach, afterAll, afterEach, vi } from 'vitest'; -import http from 'http'; -import net from 'net'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import serverModule from '../server.js'; -const { server, _resetForTesting, sandboxState, SANDBOX_PORT } = serverModule; - -import setupModule from './setup.js'; -const { cleanTempFiles } = setupModule; - -let upstream; -let serverListening = false; -let serverPort; - -function startServer() { - return new Promise((resolve) => { - if (serverListening) return resolve(); - server.listen(0, "127.0.0.1", () => { - serverPort = server.address().port; - serverListening = true; - resolve(); - }); - }); -} - -function createWsUpstream() { - return new Promise((resolve) => { - upstream = net.createServer((socket) => { - // Simple echo: read HTTP upgrade request, send back 101, then echo data - let gotUpgrade = false; - let buffer = Buffer.alloc(0); - - socket.on("data", (chunk) => { - if (!gotUpgrade) { - buffer = Buffer.concat([buffer, chunk]); - const str = buffer.toString(); - if (str.includes("\r\n\r\n")) { - gotUpgrade = true; - // Send back 101 Switching Protocols - socket.write( - "HTTP/1.1 101 Switching Protocols\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n\r\n" - ); - // Any remaining data after headers is echoed back - const bodyStart = str.indexOf("\r\n\r\n") + 4; - const remaining = buffer.slice(bodyStart); - if (remaining.length > 0) { - socket.write(remaining); - } - } - } else { - // Echo back data in websocket-like fashion - socket.write(chunk); - } - }); - }); - - upstream.listen(SANDBOX_PORT, "127.0.0.1", () => { - resolve(); - }); - }); -} - -function closeUpstream() { - return new Promise((resolve) => { - if (upstream) { - upstream.close(() => resolve()); - upstream = null; - } else { - resolve(); - } - }); -} - -// === TC-WS01 through TC-WS08: WebSocket proxy === - -describe("WebSocket proxy", () => { - beforeEach(async () => { - _resetForTesting(); - cleanTempFiles(); - await startServer(); - }); - - afterEach(async () => { - await closeUpstream(); - }); - - afterAll(() => { server.close(); }); - - it("TC-WS01: WebSocket upgrade with sandbox ready is proxied", async () => { - await createWsUpstream(); - sandboxState.status = "running"; - - const result = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: serverPort, - path: "/ws", - method: "GET", - headers: { - Upgrade: "websocket", - Connection: "Upgrade", - "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", - "Sec-WebSocket-Version": "13", - }, - }); - - req.on("upgrade", (res, socket) => { - resolve({ statusCode: res.statusCode, socket }); - socket.destroy(); - }); - - req.on("error", reject); - req.setTimeout(3000, () => { - req.destroy(); - reject(new Error("timeout")); - }); - req.end(); - }); - - expect(result.statusCode).toBe(101); - }); - - it("TC-WS02: WebSocket upgrade with sandbox NOT ready returns 502", async () => { - sandboxState.status = "idle"; - - const result = await new Promise((resolve, reject) => { - const sock = net.createConnection({ port: serverPort, host: "127.0.0.1" }, () => { - sock.write( - "GET /ws HTTP/1.1\r\n" + - "Host: 127.0.0.1\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n\r\n" - ); - }); - - let data = ""; - sock.on("data", (chunk) => { - data += chunk.toString(); - if (data.includes("\r\n")) { - sock.destroy(); - resolve(data); - } - }); - - sock.on("error", reject); - sock.setTimeout(3000, () => { - sock.destroy(); - reject(new Error("timeout")); - }); - }); - - expect(result).toContain("502"); - }); - - it("TC-WS03: Host header rewritten to sandbox address in upgrade", async () => { - let receivedHeaders = ""; - await new Promise((resolve) => { - upstream = net.createServer((socket) => { - socket.on("data", (chunk) => { - receivedHeaders += chunk.toString(); - socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); - }); - }); - upstream.listen(SANDBOX_PORT, "127.0.0.1", resolve); - }); - - sandboxState.status = "running"; - - await new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: serverPort, - path: "/ws", - method: "GET", - headers: { - Upgrade: "websocket", - Connection: "Upgrade", - Host: "original.host:8081", - }, - }); - - req.on("upgrade", (res, socket) => { - socket.destroy(); - resolve(); - }); - - req.on("error", reject); - req.setTimeout(3000, () => { req.destroy(); reject(new Error("timeout")); }); - req.end(); - }); - - expect(receivedHeaders).toContain(`Host: 127.0.0.1:${SANDBOX_PORT}`); - }); - - it("TC-WS04: data flows bidirectionally", async () => { - await createWsUpstream(); - sandboxState.status = "running"; - - const result = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: serverPort, - path: "/ws", - method: "GET", - headers: { - Upgrade: "websocket", - Connection: "Upgrade", - }, - }); - - req.on("upgrade", (res, socket) => { - socket.write("ping"); - socket.on("data", (data) => { - resolve(data.toString()); - socket.destroy(); - }); - }); - - req.on("error", reject); - req.setTimeout(3000, () => { req.destroy(); reject(new Error("timeout")); }); - req.end(); - }); - - expect(result).toBe("ping"); - }); - - it("TC-WS05: client disconnect shuts down upstream", async () => { - let upstreamClosed = false; - await new Promise((resolve) => { - upstream = net.createServer((socket) => { - socket.on("data", () => { - socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); - }); - socket.on("close", () => { upstreamClosed = true; }); - }); - upstream.listen(SANDBOX_PORT, "127.0.0.1", resolve); - }); - - sandboxState.status = "running"; - - await new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: serverPort, - path: "/ws", - method: "GET", - headers: { Upgrade: "websocket", Connection: "Upgrade" }, - }); - - req.on("upgrade", (res, socket) => { - // Immediately close client side - socket.destroy(); - setTimeout(resolve, 200); - }); - - req.on("error", reject); - req.setTimeout(3000, () => { req.destroy(); reject(new Error("timeout")); }); - req.end(); - }); - - expect(upstreamClosed).toBe(true); - }); - - it("TC-WS06: upstream disconnect shuts down client", async () => { - let clientClosed = false; - await new Promise((resolve) => { - upstream = net.createServer((socket) => { - socket.on("data", () => { - socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); - // Immediately close upstream side - setTimeout(() => socket.destroy(), 100); - }); - }); - upstream.listen(SANDBOX_PORT, "127.0.0.1", resolve); - }); - - sandboxState.status = "running"; - - await new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: serverPort, - path: "/ws", - method: "GET", - headers: { Upgrade: "websocket", Connection: "Upgrade" }, - }); - - req.on("upgrade", (res, socket) => { - socket.on("close", () => { - clientClosed = true; - resolve(); - }); - }); - - req.on("error", reject); - req.setTimeout(3000, () => { req.destroy(); reject(new Error("timeout")); }); - req.end(); - }); - - expect(clientClosed).toBe(true); - }); - - it("TC-WS07: WebSocket upgrade to API path is proxied when sandbox ready", async () => { - await createWsUpstream(); - sandboxState.status = "running"; - - const result = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: serverPort, - path: "/api/sandbox-status", - method: "GET", - headers: { - Upgrade: "websocket", - Connection: "Upgrade", - }, - }); - - req.on("upgrade", (res, socket) => { - resolve({ statusCode: res.statusCode }); - socket.destroy(); - }); - - req.on("error", reject); - req.setTimeout(3000, () => { req.destroy(); reject(new Error("timeout")); }); - req.end(); - }); - - // WebSocket upgrades take priority over API routes - expect(result.statusCode).toBe(101); - }); - - it("TC-WS08: TCP connection timeout for upstream is bounded", async () => { - // Don't start upstream — connection should fail/timeout - sandboxState.status = "running"; - - const result = await new Promise((resolve, reject) => { - const sock = net.createConnection({ port: serverPort, host: "127.0.0.1" }, () => { - sock.write( - "GET /ws HTTP/1.1\r\n" + - "Host: 127.0.0.1\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n\r\n" - ); - }); - - let data = ""; - sock.on("data", (chunk) => { data += chunk.toString(); }); - sock.on("close", () => resolve(data)); - sock.on("error", reject); - sock.setTimeout(10000, () => { - sock.destroy(); - reject(new Error("test timeout")); - }); - }); - - // Client socket should be closed when upstream fails - expect(result.length).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/brev/welcome-ui/__tests__/routing.test.js b/brev/welcome-ui/__tests__/routing.test.js deleted file mode 100644 index d92bee61..00000000 --- a/brev/welcome-ui/__tests__/routing.test.js +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { server, _resetForTesting, sandboxState } = serverModule; -const request = supertest; - -// === TC-R01 through TC-R17: Routing system === - -describe("routing — method aliasing", () => { - beforeEach(() => { _resetForTesting(); }); - afterAll(() => { server.close(); }); - - it("TC-R01: GET request routes through _route()", async () => { - const res = await request(server).get("/api/sandbox-status"); - expect(res.status).toBe(200); - }); - - it("TC-R02: POST request routes through _route()", async () => { - const res = await request(server) - .post("/api/inject-key") - .send({ key: "nvapi-test" }); - expect([200, 202, 400]).toContain(res.status); - }); - - it("TC-R03: PUT request routes through _route()", async () => { - const res = await request(server) - .put("/api/providers/test-provider") - .send({ type: "openai" }); - // 400 because CLI fails, but routing works - expect([200, 400, 502]).toContain(res.status); - }); - - it("TC-R04: DELETE request routes through _route()", async () => { - const res = await request(server).delete("/api/providers/test-provider"); - expect([200, 400, 502]).toContain(res.status); - }); - - it("TC-R05: PATCH to unknown path returns 404 when sandbox not ready", async () => { - const res = await request(server).patch("/some-path"); - expect(res.status).toBe(404); - }); - - it("TC-R06: HEAD request routes through _route() (no body returned)", async () => { - const res = await request(server).head("/"); - expect(res.status).toBe(200); - expect(res.text).toBeFalsy(); - }); - - it("TC-R07: OPTIONS to any path returns 204 with CORS headers", async () => { - const res = await request(server).options("/api/sandbox-status"); - expect(res.status).toBe(204); - expect(res.headers["access-control-allow-origin"]).toBe("*"); - expect(res.headers["access-control-allow-methods"]).toContain("GET"); - expect(res.headers["access-control-allow-methods"]).toContain("POST"); - }); -}); - -describe("routing — path extraction", () => { - beforeEach(() => { _resetForTesting(); }); - - it("TC-R08: query string stripped for route matching", async () => { - const res = await request(server).get("/api/sandbox-status?foo=bar&baz=1"); - expect(res.status).toBe(200); - expect(res.body.status).toBeDefined(); - }); - - it("TC-R09: query string preserved for proxy (tested via non-API path)", async () => { - // When sandbox is NOT ready, non-API path returns static or 404 - const res = await request(server).get("/some/path?query=value"); - expect(res.status).toBe(404); - }); -}); - -describe("routing — priority", () => { - beforeEach(() => { _resetForTesting(); }); - - it("TC-R10: API routes handled locally even when sandbox is running", async () => { - sandboxState.status = "running"; - sandboxState.url = "http://127.0.0.1:8081/"; - const res = await request(server).get("/api/sandbox-status"); - expect(res.status).toBe(200); - expect(res.body.status).toBe("running"); - }); - - it("TC-R11: GET / serves templated index when sandbox NOT ready", async () => { - const res = await request(server).get("/"); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("text/html"); - expect(res.text).toContain("OpenShell"); - }); - - it("TC-R13: unknown path returns 404 when sandbox NOT ready", async () => { - const res = await request(server).get("/totally/unknown/path"); - expect(res.status).toBe(404); - }); - - it("TC-R14: unknown POST (non-API, sandbox not ready) returns 404", async () => { - const res = await request(server).post("/unknown"); - expect(res.status).toBe(404); - }); -}); - -describe("routing — default headers", () => { - beforeEach(() => { _resetForTesting(); }); - - it("TC-R15: non-proxy responses include Cache-Control no-cache", async () => { - const res = await request(server).get("/api/sandbox-status"); - expect(res.headers["cache-control"]).toContain("no-cache"); - expect(res.headers["cache-control"]).toContain("no-store"); - expect(res.headers["cache-control"]).toContain("must-revalidate"); - }); - - it("TC-R16: non-proxy responses include Access-Control-Allow-Origin *", async () => { - const res = await request(server).get("/api/sandbox-status"); - expect(res.headers["access-control-allow-origin"]).toBe("*"); - }); - - it("TC-R17: proxy responses should NOT include server CORS headers (covered in proxy tests)", () => { - // Verified in proxy-http.test.js TC-PX11 - expect(true).toBe(true); - }); -}); diff --git a/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js b/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js deleted file mode 100644 index 89a6bf9b..00000000 --- a/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js +++ /dev/null @@ -1,262 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'; -import supertest from 'supertest'; -import fs from 'fs'; -import { EventEmitter } from 'events'; - -vi.mock('child_process', () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof opts === 'function') { cb = opts; opts = {}; } - cb(null, '', ''); - }), - spawn: vi.fn(), -})); - -import { execFile, spawn } from 'child_process'; -import serverModule from '../server.js'; -const { - server, - _resetForTesting, - _setMocksForTesting, - sandboxState, - injectKeyState, - gatewayLogReady, - readOpenclawToken, -} = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles, writeLogFile, FIXTURES, LOG_FILE } = setupModule; -const request = supertest; - -// === TC-S01 through TC-S22: Sandbox lifecycle === - -describe("POST /api/install-openclaw", () => { - beforeEach(() => { - _resetForTesting(); - _setMocksForTesting({ execFile, spawn }); - cleanTempFiles(); - execFile.mockClear(); - spawn.mockClear(); - spawn.mockImplementation(() => { - const proc = new EventEmitter(); - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - proc.pid = 12345; - proc.unref = vi.fn(); - setTimeout(() => proc.emit('close', 0), 50); - return proc; - }); - }); - - afterAll(() => { server.close(); }); - - it("TC-S01: returns 200 {ok:true} when status is idle", async () => { - const res = await request(server) - .post("/api/install-openclaw") - .set("Content-Type", "application/json"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - }); - - it("TC-S02: returns 200 {ok:true} when status is error (allows retry)", async () => { - sandboxState.status = "error"; - sandboxState.error = "previous failure"; - const res = await request(server) - .post("/api/install-openclaw") - .set("Content-Type", "application/json"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - }); - - it("TC-S03: returns 409 when status is creating", async () => { - sandboxState.status = "creating"; - const res = await request(server) - .post("/api/install-openclaw") - .set("Content-Type", "application/json"); - expect(res.status).toBe(409); - expect(res.body.ok).toBe(false); - expect(res.body.error).toContain("already being created"); - }); - - it("TC-S04: returns 409 when status is running", async () => { - sandboxState.status = "running"; - const res = await request(server) - .post("/api/install-openclaw") - .set("Content-Type", "application/json"); - expect(res.status).toBe(409); - expect(res.body.ok).toBe(false); - expect(res.body.error).toContain("already running"); - }); - - it("TC-S05: spawns background process with correct args", async () => { - await request(server) - .post("/api/install-openclaw") - .set("Content-Type", "application/json"); - - expect(spawn).toHaveBeenCalled(); - const [cmd, args] = spawn.mock.calls[0]; - expect(cmd).toBe("openshell"); - expect(args).toContain("sandbox"); - expect(args).toContain("create"); - expect(args).toContain("--name"); - expect(args).toContain("openclaw-nvidia"); - expect(args).toContain("--from"); - expect(args).toContain("--forward"); - expect(args).toContain("18789"); - }); - - it("TC-S06: cleanup runs openshell sandbox delete before creation", async () => { - await request(server) - .post("/api/install-openclaw") - .set("Content-Type", "application/json"); - - // execFile should have been called with delete command - const deleteCalls = execFile.mock.calls.filter( - (c) => c[0] === "openshell" && c[1][0] === "sandbox" && c[1][1] === "delete" - ); - expect(deleteCalls.length).toBeGreaterThanOrEqual(1); - }); - - it("TC-S09: CHAT_UI_URL is derived from the incoming public request URL", async () => { - await request(server) - .post("/api/install-openclaw") - .set("Host", "preview.example.net") - .set("X-Forwarded-Proto", "https") - .set("Content-Type", "application/json"); - - if (spawn.mock.calls.length > 0) { - const args = spawn.mock.calls[0][1]; - const envIdx = args.indexOf("env"); - expect(envIdx).toBeGreaterThan(-1); - const chatUrl = args[envIdx + 1]; - expect(chatUrl).toBe("CHAT_UI_URL=https://preview.example.net/"); - } - }); -}); - -describe("GET /api/sandbox-status", () => { - beforeEach(() => { - _resetForTesting(); - cleanTempFiles(); - }); - - it("TC-S12: returns status=idle when no install triggered", async () => { - const res = await request(server).get("/api/sandbox-status"); - expect(res.status).toBe(200); - expect(res.body.status).toBe("idle"); - expect(res.body.url).toBeNull(); - expect(res.body.error).toBeNull(); - }); - - it("TC-S13: returns status=creating during sandbox creation", async () => { - sandboxState.status = "creating"; - const res = await request(server).get("/api/sandbox-status"); - expect(res.status).toBe(200); - expect(res.body.status).toBe("creating"); - }); - - it("TC-S14: returns status=running with url when sandbox is ready", async () => { - sandboxState.status = "running"; - sandboxState.url = "http://127.0.0.1:8081/?token=abc"; - const res = await request(server).get("/api/sandbox-status"); - expect(res.status).toBe(200); - expect(res.body.status).toBe("running"); - expect(res.body.url).toBe("http://127.0.0.1:8081/?token=abc"); - }); - - it("TC-S15: returns status=error with error message on failure", async () => { - sandboxState.status = "error"; - sandboxState.error = "something broke"; - const res = await request(server).get("/api/sandbox-status"); - expect(res.status).toBe(200); - expect(res.body.status).toBe("error"); - expect(res.body.error).toBe("something broke"); - }); - - it("TC-S16: key_injected is false when no key injected", async () => { - const res = await request(server).get("/api/sandbox-status"); - expect(res.body.key_injected).toBe(false); - }); - - it("TC-S17: key_injected is true when injection is done", async () => { - injectKeyState.status = "done"; - const res = await request(server).get("/api/sandbox-status"); - expect(res.body.key_injected).toBe(true); - }); - - it("TC-S18: key_inject_error contains error string on failure", async () => { - injectKeyState.status = "error"; - injectKeyState.error = "key injection failed"; - const res = await request(server).get("/api/sandbox-status"); - expect(res.body.key_inject_error).toBe("key injection failed"); - }); -}); - -describe("readiness detection", () => { - beforeEach(() => { - _resetForTesting(); - cleanTempFiles(); - }); - - it("TC-S19: transitions creating→running when sentinel+port found", async () => { - sandboxState.status = "creating"; - // Write log with sentinel - writeLogFile(FIXTURES.gatewayLogWithToken); - // Note: portOpen will actually try TCP connect which will fail in tests. - // The sandbox-status handler checks portOpen; it will fail, so status stays creating. - const res = await request(server).get("/api/sandbox-status"); - // Without an actual open port, status stays creating - expect(["creating", "running"]).toContain(res.body.status); - }); - - it("TC-S20: does NOT transition if only sentinel found (port closed)", async () => { - sandboxState.status = "creating"; - writeLogFile(FIXTURES.gatewayLogWithToken); - // Port 18789 is NOT open, so should stay creating - const res = await request(server).get("/api/sandbox-status"); - expect(res.body.status).toBe("creating"); - }); - - it("TC-S21: does NOT transition if only port open (no sentinel)", async () => { - sandboxState.status = "creating"; - // No log file written, so sentinel check fails - const res = await request(server).get("/api/sandbox-status"); - expect(res.body.status).toBe("creating"); - }); - - it("TC-S22: error state stores last 2000 chars of log on non-zero exit", () => { - const longLog = "x".repeat(3000); - fs.writeFileSync(LOG_FILE, longLog); - // When the background process fails, it reads the last 2000 chars - // We verify the helper function behavior - const content = fs.readFileSync(LOG_FILE, "utf-8"); - const truncated = content.slice(-2000); - expect(truncated.length).toBe(2000); - }); -}); - -describe("gateway log helpers", () => { - beforeEach(() => { - cleanTempFiles(); - }); - - it("gatewayLogReady returns true when sentinel is in log", () => { - writeLogFile(FIXTURES.gatewayLogWithToken); - expect(gatewayLogReady()).toBe(true); - }); - - it("gatewayLogReady returns false when log missing", () => { - expect(gatewayLogReady()).toBe(false); - }); - - it("readOpenclawToken extracts token from log URL", () => { - writeLogFile(FIXTURES.gatewayLogWithToken); - expect(readOpenclawToken()).toBe("abc123XYZ"); - }); - - it("readOpenclawToken returns null when no token in log", () => { - writeLogFile("no token here\n"); - expect(readOpenclawToken()).toBeNull(); - }); -}); diff --git a/brev/welcome-ui/__tests__/setup.js b/brev/welcome-ui/__tests__/setup.js deleted file mode 100644 index 1cd06edc..00000000 --- a/brev/welcome-ui/__tests__/setup.js +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Shared test utilities, fixtures, and mock helpers for the welcome-ui test suite. - -const fs = require("fs"); -const path = require("path"); -const os = require("os"); - -const LOG_FILE = "/tmp/nemoclaw-sandbox-create.log"; -const CACHE_FILE = "/tmp/nemoclaw-provider-config-cache.json"; - -function cleanTempFiles() { - for (const f of [LOG_FILE, CACHE_FILE]) { - try { fs.unlinkSync(f); } catch { /* ignore */ } - } -} - -function writeLogFile(content) { - fs.writeFileSync(LOG_FILE, content, "utf-8"); -} - -function writeCacheFile(obj) { - fs.writeFileSync(CACHE_FILE, JSON.stringify(obj), "utf-8"); -} - -function readCacheFile() { - try { - return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8")); - } catch { - return null; - } -} - -// CLI output fixtures matching the nemoclaw CLI text format - -const FIXTURES = { - providerListOutput: "nvidia-endpoints\ncustom-provider\n", - - providerGetOutput: [ - "Id: abc-123", - "Name: nvidia-endpoints", - "Type: openai", - "Credential keys: OPENAI_API_KEY", - "Config keys: OPENAI_BASE_URL", - ].join("\n"), - - providerGetNone: [ - "Id: def-456", - "Name: empty-provider", - "Type: custom", - "Credential keys: ", - "Config keys: ", - ].join("\n"), - - providerGetAnsi: - "\x1b[32mId:\x1b[0m abc-123\n" + - "\x1b[32mName:\x1b[0m nvidia-endpoints\n" + - "\x1b[32mType:\x1b[0m openai\n" + - "\x1b[32mCredential keys:\x1b[0m OPENAI_API_KEY\n" + - "\x1b[32mConfig keys:\x1b[0m OPENAI_BASE_URL\n", - - clusterInferenceOutput: [ - "Provider: nvidia-endpoints", - "Model: meta/llama-3.1-70b-instruct", - "Version: 2", - ].join("\n"), - - clusterInferenceAnsi: - "\x1b[1;34mProvider:\x1b[0m nvidia-endpoints\n" + - "\x1b[1;34mModel:\x1b[0m meta/llama-3.1-70b-instruct\n" + - "\x1b[1;34mVersion:\x1b[0m 2\n", - - policySyncSuccess: "Policy set for sandbox nemoclaw\nversion 3\nhash: deadbeef01234567\n", - - validPolicyYaml: [ - "version: 1", - "inference:", - " model: gpt-4", - " provider: openai", - "process:", - " run_as_user: sandbox", - " run_as_group: sandbox", - "filesystem_policy:", - " include_workdir: true", - " read_only:", - " - /usr", - "network_policies:", - " github:", - " name: github", - " endpoints:", - " - { host: github.com, port: 443 }", - ].join("\n"), - - sampleApiKey: "nvapi-test-key-1234567890", - sampleApiKey2: "sk-different-key-0987654321", - - gatewayLogWithToken: - "Starting sandbox...\n" + - "OpenClaw gateway starting in background.\n" + - " UI: http://127.0.0.1:18789/?token=abc123XYZ\n", - - gatewayLogNoToken: - "Starting sandbox...\n" + - "OpenClaw gateway starting in background.\n" + - " UI: http://127.0.0.1:18789/\n", -}; - -/** - * Build a mock implementation for child_process.execFile that routes - * commands to canned responses. - * - * @param {Object} routes - Map of "cmd subcommand..." → {stdout, stderr, code} - * @returns {Function} Mock execFile(cmd, args, opts, cb) - */ -function buildExecFileMock(routes = {}) { - return (cmd, args, opts, cb) => { - if (typeof opts === "function") { - cb = opts; - opts = {}; - } - const key = [cmd, ...(args || [])].join(" "); - - for (const [pattern, response] of Object.entries(routes)) { - if (key.startsWith(pattern) || key === pattern) { - const { stdout = "", stderr = "", code = 0 } = response; - if (code !== 0) { - const err = new Error(`Command failed: ${key}`); - err.code = code; - return cb(err, stdout, stderr); - } - return cb(null, stdout, stderr); - } - } - cb(null, "", ""); - }; -} - -module.exports = { - LOG_FILE, - CACHE_FILE, - cleanTempFiles, - writeLogFile, - writeCacheFile, - readCacheFile, - FIXTURES, - buildExecFileMock, -}; diff --git a/brev/welcome-ui/__tests__/static-files.test.js b/brev/welcome-ui/__tests__/static-files.test.js deleted file mode 100644 index d33f2e59..00000000 --- a/brev/welcome-ui/__tests__/static-files.test.js +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, afterAll, beforeEach } from 'vitest'; -import supertest from 'supertest'; -import serverModule from '../server.js'; -const { server, _resetForTesting } = serverModule; -import setupModule from './setup.js'; -const { cleanTempFiles } = setupModule; -const request = supertest; - -// === TC-SF01 through TC-SF06: Static file serving === - -describe("static file serving", () => { - beforeEach(() => { - _resetForTesting(); - }); - - afterAll(() => { - server.close(); - }); - - it("TC-SF01: GET /styles.css returns CSS with text/css content-type", async () => { - const res = await request(server).get("/styles.css"); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("text/css"); - expect(res.text).toContain("OpenShell"); - }); - - it("TC-SF02: GET /app.js returns JS with application/javascript content-type", async () => { - const res = await request(server).get("/app.js"); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("application/javascript"); - }); - - it("TC-SF03: GET /nonexistent.txt returns 404", async () => { - const res = await request(server).get("/nonexistent.txt"); - expect(res.status).toBe(404); - }); - - it("TC-SF04: GET / returns templated index.html", async () => { - const res = await request(server).get("/"); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("text/html"); - expect(res.text).not.toContain("{{OTHER_AGENTS_MODAL}}"); - expect(res.text).toContain("OpenShell"); - }); - - it("TC-SF05: GET /index.html returns templated index.html", async () => { - const res = await request(server).get("/index.html"); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("text/html"); - expect(res.text).not.toContain("{{OTHER_AGENTS_MODAL}}"); - }); - - it("TC-SF06: HEAD /styles.css returns headers but no body", async () => { - const res = await request(server).head("/styles.css"); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("text/css"); - expect(res.text).toBeFalsy(); - }); -}); diff --git a/brev/welcome-ui/__tests__/template-render.test.js b/brev/welcome-ui/__tests__/template-render.test.js deleted file mode 100644 index aa3da49b..00000000 --- a/brev/welcome-ui/__tests__/template-render.test.js +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeEach } from 'vitest'; -import serverModule from '../server.js'; -const { - renderOtherAgentsModal, - renderInferenceProviderPickerAndInstructions, - getRenderedIndex, - escapeHtml, - _resetForTesting, -} = serverModule; - -// === TC-T01 through TC-T14: YAML-to-HTML template rendering === - -describe("escapeHtml", () => { - it("TC-T14: HTML special characters are escaped", () => { - expect(escapeHtml('')).toBe( - "<script>"test"&</script>" - ); - expect(escapeHtml("it's")).toBe("it's"); - }); -}); - -describe("renderOtherAgentsModal", () => { - // renderOtherAgentsModal reads the real other-agents.yaml from disk. - // These tests validate the rendered HTML structure. - - it("TC-T05: title from YAML appears in modal__title", () => { - const html = renderOtherAgentsModal(); - if (!html) return; // skip if yaml missing - expect(html).toContain('